diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 850b5f2e45..a16779c71c 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -1340,6 +1340,8 @@ onboarding-user_height-title = What is your height? onboarding-user_height-description = We need your height to calculate your body proportions and accurately represent your movements. You can either let SlimeVR calculate it, or input your height manually. onboarding-user_height-need_head_tracker = A headset and controllers with positional tracking are required to perform the calibration. onboarding-user_height-calculate = Calculate my height automatically +onboarding-user_height-error_bounds = Input too high or too low +onboarding-user_height-error_format = Input wrong format onboarding-user_height-next_step = Continue and save onboarding-user_height-prev_step = Back onboarding-user_height-manual-proportions = Manual Proportions diff --git a/gui/app/src/components/commons/Input.tsx b/gui/app/src/components/commons/Input.tsx index ba93fc99b3..b597bf932f 100644 --- a/gui/app/src/components/commons/Input.tsx +++ b/gui/app/src/components/commons/Input.tsx @@ -24,6 +24,9 @@ export const InputInside = forwardRef< label?: string; error?: FieldError; autocomplete?: boolean | string; + className?: string; + errorClassName?: string; + onBlur?: () => void; } & Partial> >(function AppInput( { @@ -37,6 +40,9 @@ export const InputInside = forwardRef< value, error, variant = 'primary', + className, + errorClassName, + onBlur, }, ref ) { @@ -90,9 +96,13 @@ export const InputInside = forwardRef<
{type === 'password' && (
)} {error?.message && ( -
+
{error.message}
)} @@ -129,11 +145,17 @@ export const Input = ({ disabled, variant = 'primary', rules, + className, + errorClassName, + onBlur, }: { rules?: UseControllerProps>['rules']; control: Control; name: FieldPath; autocomplete?: boolean | string; + className?: string; + errorClassName?: string; + onBlur?: () => void; } & Omit & Partial>) => { return ( @@ -157,6 +179,9 @@ export const Input = ({ onChange={onChange} ref={ref} name={name} + className={className} + errorClassName={errorClassName} + onBlur={onBlur} /> )} /> diff --git a/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx b/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx index ac7b42a2dc..8b1cb19dc7 100644 --- a/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx +++ b/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx @@ -1,10 +1,15 @@ +import { Input } from '@/components/commons/Input'; import { Typography } from '@/components/commons/Typography'; import { useBreakpoint } from '@/hooks/breakpoint'; import { EYE_HEIGHT_TO_HEIGHT_RATIO } from '@/hooks/height'; import { useLocaleConfig } from '@/i18n/config'; import classNames from 'classnames'; import convert from 'convert'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { useLocalization } from '@fluent/react'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { string, object } from 'yup'; function IncrementButton({ value, @@ -121,6 +126,9 @@ export function HeightSelectionInput({ const { isXs } = useBreakpoint('xs'); const [unit, setUnit] = useState<'meter' | 'foot'>('meter'); const { currentLocales } = useLocaleConfig(); + const { l10n } = useLocalization(); + const isSubmitting = useRef(false); + const footRegex = /^(\d+)(?:[′'.,\s]+(\d+(?:\.\d+)?)?[″"”]?)?$/; const formattedHeight = useMemo(() => { if (!hmdHeight) return '--'; @@ -140,6 +148,95 @@ export function HeightSelectionInput({ return formatInFoot(displayHeight, currentLocales); }, [hmdHeight, unit]); + const defaultValues: { height: string } = { + height: formattedHeight, + }; + + const { reset, control, watch, trigger, handleSubmit } = useForm<{ + height: string; + }>({ + defaultValues, + mode: 'onChange', + reValidateMode: 'onChange', + + resolver: yupResolver( + object({ + height: string() + .defined() + .test( + 'format', + l10n.getString('onboarding-user_height-error_format'), + function (value) { + if (unit === 'meter') { + return !isNaN(Number(value.replace(/[ m]/g, ''))); + } else { + return footRegex.test(value); + } + } + ) + .test( + 'bounds', + l10n.getString('onboarding-user_height-error_bounds'), + function (value) { + if (unit === 'meter') { + const newNum = Number(value.replace(/[ m]/g, '')); + return newNum >= 0.97 && newNum <= 2.56; + } else { + const match = value.match(footRegex); + if (!match) return; + const feet = Number(match[1]); + const inches = Number(match[2] || 0); + return ( + !(feet > 8 || (feet === 8 && inches > 4)) && + !(feet < 3 || (feet === 3 && inches < 2)) + ); + } + } + ), + }) + ), + }); + + const onSubmit = async (values: { height: string }) => { + if (!(await trigger('height'))) return; + isSubmitting.current = true; + + if (unit === 'meter') { + const newFullHeight = Number(values.height.replace(/[ m]/g, '')); + + setHmdHeight(round4Digit(newFullHeight * EYE_HEIGHT_TO_HEIGHT_RATIO)); + } else { + const match = values.height.match(footRegex); + if (!match) return; + + const feet = Number(match[1]); + const inches = Number(match[2] || 0); + + const newFullHeight = convert(feet + inches / 12, 'foot').to('meter'); + + setHmdHeight(round4Digit(newFullHeight * EYE_HEIGHT_TO_HEIGHT_RATIO)); + } + }; + + useEffect(() => { + const subscription = watch((value, { type }) => { + if (type === 'change') { + handleSubmit(onSubmit)(); + } + }); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + if (isSubmitting.current) return; + + reset({ height: defaultValues.height }); + }, [defaultValues.height]); + + useEffect(() => { + reset({ height: formattedHeight }); + }, [unit]); + const incrementMath = (unit: 'inch' | 'cm' | 'foot', value: number) => { const incrementInMeters = convert(value, unit).to('meter'); const oldFull = hmdHeight / EYE_HEIGHT_TO_HEIGHT_RATIO; @@ -182,6 +279,7 @@ export function HeightSelectionInput({ const newEyeHeight = round4Digit( snappedHeight * EYE_HEIGHT_TO_HEIGHT_RATIO ); + setHmdHeight(newEyeHeight); setUnit(newUnit); }; @@ -222,11 +320,19 @@ export function HeightSelectionInput({ )}
-
-
- {formattedHeight} -
-
+
+ { + reset({ height: formattedHeight }); + isSubmitting.current = false; + }} + /> +