@utilitywarehouse/hearth-react-native 0.32.0 → 0.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +15 -18
  3. package/CHANGELOG.md +30 -1
  4. package/build/components/Badge/Badge.js +2 -2
  5. package/build/components/Card/Card.props.d.ts +1 -1
  6. package/build/components/Card/CardRoot.js +19 -0
  7. package/build/components/Input/Input.js +13 -31
  8. package/build/components/StepperInput/StepperInput.js +12 -29
  9. package/build/components/Tabs/Tab.js +2 -2
  10. package/build/components/Textarea/Textarea.js +12 -30
  11. package/build/components/VerificationInput/VerificationInput.js +12 -22
  12. package/build/hooks/index.d.ts +1 -0
  13. package/build/hooks/index.js +1 -0
  14. package/build/hooks/useFormFieldAccessibility.d.ts +17 -0
  15. package/build/hooks/useFormFieldAccessibility.js +32 -0
  16. package/build/hooks/useFormFieldAccessibility.test.d.ts +1 -0
  17. package/build/hooks/useFormFieldAccessibility.test.js +56 -0
  18. package/docs/changelog.mdx +84 -0
  19. package/package.json +11 -9
  20. package/src/components/Badge/Badge.tsx +2 -2
  21. package/src/components/Banner/Banner.stories.tsx +14 -0
  22. package/src/components/Card/Card.docs.mdx +16 -17
  23. package/src/components/Card/Card.props.ts +1 -0
  24. package/src/components/Card/Card.stories.tsx +35 -21
  25. package/src/components/Card/CardRoot.tsx +19 -0
  26. package/src/components/Icon/Icon.docs.mdx +1 -1
  27. package/src/components/Input/Input.tsx +14 -35
  28. package/src/components/List/List.docs.mdx +4 -2
  29. package/src/components/StepperInput/StepperInput.tsx +13 -40
  30. package/src/components/Tabs/Tab.tsx +2 -2
  31. package/src/components/Textarea/Textarea.tsx +13 -34
  32. package/src/components/VerificationInput/VerificationInput.tsx +13 -25
  33. package/src/hooks/index.ts +1 -0
  34. package/src/hooks/useFormFieldAccessibility.test.tsx +74 -0
  35. package/src/hooks/useFormFieldAccessibility.ts +67 -0
@@ -1,4 +1,4 @@
1
1
 
2
- > @utilitywarehouse/hearth-react-native@0.32.0 build /home/runner/work/hearth/hearth/packages/react-native
2
+ > @utilitywarehouse/hearth-react-native@0.32.2 build /home/runner/work/hearth/hearth/packages/react-native
3
3
  > tsc
4
4
 
@@ -1,5 +1,5 @@
1
1
 
2
- > @utilitywarehouse/hearth-react-native@0.32.0 lint /home/runner/work/hearth/hearth/packages/react-native
2
+ > @utilitywarehouse/hearth-react-native@0.32.2 lint /home/runner/work/hearth/hearth/packages/react-native
3
3
  > TIMING=1 eslint .
4
4
 
5
5
 
@@ -27,9 +27,6 @@
27
27
  /home/runner/work/hearth/hearth/packages/react-native/src/components/DatePicker/DatePickerYears.tsx
28
28
  52:6 warning React Hook useCallback has a missing dependency: 'containerHeight'. Either include it or remove the dependency array. Outer scope values like 'styles' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
29
29
 
30
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Input/Input.tsx
31
- 78:8 warning React Hook useEffect has a missing dependency: 'formFieldContext'. Either include it or remove the dependency array react-hooks/exhaustive-deps
32
-
33
30
  /home/runner/work/hearth/hearth/packages/react-native/src/components/PillGroup/PillGroup.tsx
34
31
  17:9 warning The 'normalizedValue' conditional could make the dependencies of useMemo Hook (at line 33) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of 'normalizedValue' in its own useMemo() Hook react-hooks/exhaustive-deps
35
32
 
@@ -46,17 +43,17 @@
46
43
  14:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
47
44
  106:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
48
45
 
49
- 21 problems (0 errors, 21 warnings)
50
-
51
- Rule | Time (ms) | Relative
52
- :-----------------------------------------|----------:|--------:
53
- @typescript-eslint/no-unused-vars | 1597.986 | 54.8%
54
- react-hooks/exhaustive-deps | 222.159 | 7.6%
55
- no-global-assign | 113.864 | 3.9%
56
- react-hooks/rules-of-hooks | 86.855 | 3.0%
57
- no-misleading-character-class | 82.947 | 2.8%
58
- no-unexpected-multiline | 65.031 | 2.2%
59
- @typescript-eslint/ban-ts-comment | 59.965 | 2.1%
60
- no-regex-spaces | 42.086 | 1.4%
61
- no-useless-escape | 37.102 | 1.3%
62
- @typescript-eslint/triple-slash-reference | 36.097 | 1.2%
46
+ 20 problems (0 errors, 20 warnings)
47
+
48
+ Rule | Time (ms) | Relative
49
+ :----------------------------------------|----------:|--------:
50
+ @typescript-eslint/no-unused-vars | 1652.894 | 62.0%
51
+ react-hooks/exhaustive-deps | 139.717 | 5.2%
52
+ react-hooks/rules-of-hooks | 103.240 | 3.9%
53
+ no-global-assign | 68.299 | 2.6%
54
+ no-misleading-character-class | 54.368 | 2.0%
55
+ no-unexpected-multiline | 52.346 | 2.0%
56
+ @typescript-eslint/ban-ts-comment | 50.938 | 1.9%
57
+ no-useless-escape | 46.618 | 1.7%
58
+ no-loss-of-precision | 37.323 | 1.4%
59
+ @typescript-eslint/no-unused-expressions | 32.985 | 1.2%
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @utilitywarehouse/hearth-react-native
2
2
 
3
+ ## 0.32.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1164](https://github.com/utilitywarehouse/hearth/pull/1164) [`c8848d9`](https://github.com/utilitywarehouse/hearth/commit/c8848d9b01611e4c25b9caef7f211b8c623432c4) Thanks [@MichalCiesliczka](https://github.com/MichalCiesliczka)! - 🐛 [FIX]: Badge and Tabs now adapt their height for larger accessibility font sizes.
8
+
9
+ When larger text sizes are enabled (for example in iOS accessibility settings),
10
+ Badge and Tabs no longer clip text within fixed-height layouts. Their containers
11
+ now grow to fit scaled text while keeping the default visual sizing at standard
12
+ font settings.
13
+
14
+ **Components affected**:
15
+ - Badge
16
+ - Tab
17
+
18
+ **Developer changes**:
19
+
20
+ No code changes are required.
21
+
22
+ - Updated dependencies [[`e4167f2`](https://github.com/utilitywarehouse/hearth/commit/e4167f27325dacc0cbc1feae456697387162aa77)]:
23
+ - @utilitywarehouse/hearth-react-native-icons@0.8.1
24
+
25
+ ## 0.32.1
26
+
27
+ ### Patch Changes
28
+
29
+ - [#1144](https://github.com/utilitywarehouse/hearth/pull/1144) [`85459f2`](https://github.com/utilitywarehouse/hearth/commit/85459f2f4d7dcd8a99685a11dcda070530cee8dc) Thanks [@jordmccord](https://github.com/jordmccord)! - 🐛 [FIX]: Add the missing `highlight` color scheme support across the `Banner` and `Card` components.
30
+
3
31
  ## 0.32.0
4
32
 
5
33
  ### Minor Changes
@@ -9,6 +37,7 @@
9
37
  `Roundel` is a compact status indicator with `success`, `pending`, and `error` variants, intended for inline state cues.
10
38
 
11
39
  **Components affected**:
40
+
12
41
  - `Roundel`
13
42
 
14
43
  **Developer changes**:
@@ -18,7 +47,7 @@ Import and use `Roundel` from `@utilitywarehouse/hearth-react-native`:
18
47
  ```tsx
19
48
  import { Roundel } from '@utilitywarehouse/hearth-react-native';
20
49
 
21
- <Roundel variant="success" />
50
+ <Roundel variant="success" />;
22
51
  ```
23
52
 
24
53
  - [#1132](https://github.com/utilitywarehouse/hearth/pull/1132) [`8824186`](https://github.com/utilitywarehouse/hearth/commit/882418633ee8c3a11e204329d07363dc411996dc) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: Add the `Rating` component
@@ -47,11 +47,11 @@ const styles = StyleSheet.create(theme => ({
47
47
  size: {
48
48
  sm: {
49
49
  paddingVertical: theme.components.badge.sm.paddingVertical,
50
- height: theme.components.badge.sm.height,
50
+ minHeight: theme.components.badge.sm.height,
51
51
  },
52
52
  md: {
53
53
  paddingVertical: theme.components.badge.md.paddingVertical,
54
- height: theme.components.badge.md.height,
54
+ minHeight: theme.components.badge.md.height,
55
55
  },
56
56
  },
57
57
  flatBase: {
@@ -2,7 +2,7 @@ import { PressableProps } from 'react-native';
2
2
  import { DisplayProps, FlexLayoutProps, GapProps, MarginProps, SpacingValues } from '../../types';
3
3
  interface CardProps extends PressableProps, MarginProps, GapProps, FlexLayoutProps, Omit<DisplayProps, 'direction'> {
4
4
  variant?: 'emphasis' | 'subtle';
5
- colorScheme?: 'neutralStrong' | 'neutralSubtle' | 'brand' | 'energy' | 'broadband' | 'mobile' | 'insurance' | 'cashback' | 'pig';
5
+ colorScheme?: 'neutralStrong' | 'neutralSubtle' | 'brand' | 'energy' | 'broadband' | 'highlight' | 'mobile' | 'insurance' | 'cashback' | 'pig';
6
6
  shadowColor?: 'functional' | 'brand' | 'energy' | 'broadband' | 'mobile' | 'insurance' | 'cashback' | 'pig';
7
7
  noPadding?: boolean;
8
8
  disabled?: boolean;
@@ -111,6 +111,9 @@ const styles = StyleSheet.create(theme => ({
111
111
  pig: {
112
112
  borderWidth: theme.components.card.brand.borderWidth,
113
113
  },
114
+ highlight: {
115
+ borderWidth: theme.components.card.brand.borderWidth,
116
+ },
114
117
  },
115
118
  shadowColor: {
116
119
  functional: {
@@ -278,6 +281,22 @@ const styles = StyleSheet.create(theme => ({
278
281
  borderColor: theme.color.border.strong,
279
282
  },
280
283
  },
284
+ {
285
+ variant: 'subtle',
286
+ colorScheme: 'highlight',
287
+ styles: {
288
+ backgroundColor: theme.color.surface.highlight.subtle,
289
+ borderColor: theme.color.border.strong,
290
+ },
291
+ },
292
+ {
293
+ variant: 'emphasis',
294
+ colorScheme: 'highlight',
295
+ styles: {
296
+ backgroundColor: theme.color.surface.highlight.default,
297
+ borderColor: theme.color.border.strong,
298
+ },
299
+ },
281
300
  ],
282
301
  },
283
302
  }));
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { createInput } from '@gluestack-ui/input';
3
3
  import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
4
  import { CloseSmallIcon, EyeOffSmallIcon, EyeSmallIcon, SearchMediumIcon, } from '@utilitywarehouse/hearth-react-native-icons';
5
- import { useTheme } from '../../hooks';
5
+ import { useFormFieldAccessibility, useTheme } from '../../hooks';
6
6
  import { BodyText } from '../BodyText';
7
7
  import { FormField, useFormFieldContext } from '../FormField';
8
8
  import { Spinner } from '../Spinner';
@@ -34,7 +34,7 @@ const Input = forwardRef(({ validationStatus = 'initial', children, disabled, fo
34
34
  if (formFieldContext?.setShouldHandleAccessibility) {
35
35
  formFieldContext.setShouldHandleAccessibility(true);
36
36
  }
37
- }, []);
37
+ }, [formFieldContext]);
38
38
  const [fieldType, setFieldType] = useState(type === 'password' ? 'password' : 'text');
39
39
  const { color } = useTheme();
40
40
  const inputRef = useRef(null);
@@ -42,6 +42,16 @@ const Input = forwardRef(({ validationStatus = 'initial', children, disabled, fo
42
42
  useImperativeHandle(ref, () => inputRef.current, []);
43
43
  const shouldShowPasswordToggle = type === 'password' && showPasswordToggle;
44
44
  const shouldShowClear = clearable && !!props?.value;
45
+ const { accessibilityHint, accessibilityLabel } = useFormFieldAccessibility({
46
+ label: inputLabel,
47
+ helperText: inputHelperText,
48
+ validText: inputValidText,
49
+ invalidText: inputInvalidText,
50
+ required: inputRequired,
51
+ validationStatus: inputValidationStatus,
52
+ fallbackLabel: props.accessibilityLabel,
53
+ fallbackHint: props.accessibilityHint,
54
+ });
45
55
  const toggleFieldType = () => {
46
56
  setFieldType(fieldType === 'password' ? 'text' : 'password');
47
57
  };
@@ -57,39 +67,11 @@ const Input = forwardRef(({ validationStatus = 'initial', children, disabled, fo
57
67
  }
58
68
  return undefined;
59
69
  })();
60
- const getAccessibilityLabel = () => {
61
- let accessibilityLabel = '';
62
- if (inputLabel) {
63
- accessibilityLabel = accessibilityLabel + inputLabel;
64
- }
65
- if (inputRequired) {
66
- accessibilityLabel = accessibilityLabel + ', required';
67
- }
68
- return accessibilityLabel || props.accessibilityLabel;
69
- };
70
- const getAccessibilityHint = () => {
71
- let accessibilityHint = '';
72
- if (inputHelperText) {
73
- accessibilityHint = accessibilityHint + inputHelperText;
74
- }
75
- if (inputValidationStatus !== 'initial') {
76
- if (accessibilityHint.length > 0) {
77
- accessibilityHint = accessibilityHint + ', ';
78
- }
79
- if (inputValidationStatus === 'invalid' && inputInvalidText) {
80
- accessibilityHint = accessibilityHint + inputInvalidText;
81
- }
82
- if (inputValidationStatus === 'valid' && inputValidText) {
83
- accessibilityHint = accessibilityHint + inputValidText;
84
- }
85
- }
86
- return accessibilityHint || props.accessibilityHint;
87
- };
88
70
  return (_jsx(FormField, { label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, validationStatus: validationStatus, disabled: disabled, readonly: readonly, accessibilityHandledByChildren: true, children: _jsx(InputComponent, { ...(children ? props : {}), validationStatus: inputValidationStatus, isInvalid: inputValidationStatus === 'invalid', isReadOnly: inputReadonly, isDisabled: inputDisabled, isFocused: focused, type: type, isRequired: inputRequired, style: style, children: children ? (_jsx(_Fragment, { children: children })) : (_jsxs(_Fragment, { children: [!!leadingIconComponent && (_jsx(InputSlot, { children: _jsx(InputIcon, { as: leadingIconComponent }) })), !!prefix && (_jsx(InputSlot, { children: typeof prefix === 'string' || typeof prefix === 'number' ? (_jsx(BodyText, { children: prefix })) : (prefix) })), _jsx(InputField
89
71
  // @ts-expect-error - ref forwarding issue
90
72
  , {
91
73
  // @ts-expect-error - ref forwarding issue
92
- ref: inputRef, type: fieldType, inputMode: getInputMode, inBottomSheet: inBottomSheet, ...props, "aria-label": getAccessibilityLabel(), accessibilityHint: getAccessibilityHint() }), shouldShowClear && (_jsx(InputSlot, { children: _jsx(UnstyledIconButton, { onPress: onClear, icon: CloseSmallIcon }) })), loading && (_jsx(InputSlot, { children: _jsx(Spinner, { size: "xs", color: color.icon.primary }) })), shouldShowPasswordToggle && (_jsx(InputSlot, { children: _jsx(UnstyledIconButton, { onPress: toggleFieldType, icon: fieldType === 'password' ? EyeSmallIcon : EyeOffSmallIcon }) })), !!suffix && (_jsx(InputSlot, { children: typeof suffix === 'string' || typeof suffix === 'number' ? (_jsx(BodyText, { children: suffix })) : (suffix) })), !!trailingIcon && (_jsx(InputSlot, { children: _jsx(InputIcon, { as: trailingIcon }) }))] })) }) }));
74
+ ref: inputRef, type: fieldType, inputMode: getInputMode, inBottomSheet: inBottomSheet, ...props, "aria-label": accessibilityLabel, accessibilityHint: accessibilityHint }), shouldShowClear && (_jsx(InputSlot, { children: _jsx(UnstyledIconButton, { onPress: onClear, icon: CloseSmallIcon }) })), loading && (_jsx(InputSlot, { children: _jsx(Spinner, { size: "xs", color: color.icon.primary }) })), shouldShowPasswordToggle && (_jsx(InputSlot, { children: _jsx(UnstyledIconButton, { onPress: toggleFieldType, icon: fieldType === 'password' ? EyeSmallIcon : EyeOffSmallIcon }) })), !!suffix && (_jsx(InputSlot, { children: typeof suffix === 'string' || typeof suffix === 'number' ? (_jsx(BodyText, { children: suffix })) : (suffix) })), !!trailingIcon && (_jsx(InputSlot, { children: _jsx(InputIcon, { as: trailingIcon }) }))] })) }) }));
93
75
  });
94
76
  Input.displayName = 'Input';
95
77
  export default Input;
@@ -3,6 +3,7 @@ import { AddSmallIcon, MinusSmallIcon } from '@utilitywarehouse/hearth-react-nat
3
3
  import { useEffect, useImperativeHandle, useRef, useState } from 'react';
4
4
  import { View } from 'react-native';
5
5
  import { StyleSheet } from 'react-native-unistyles';
6
+ import { useFormFieldAccessibility } from '../../hooks';
6
7
  import { FormField } from '../FormField';
7
8
  import { InputComponent, InputField } from '../Input/Input';
8
9
  import StepperButton from './StepperButton';
@@ -81,6 +82,16 @@ const StepperInput = ({ value, defaultValue, onChangeText, onChangeValue, min, m
81
82
  const allowDecimal = decimalPrecision > 0;
82
83
  const keyboardType = allowNegative || allowDecimal ? 'numeric' : 'number-pad';
83
84
  const inputMode = allowDecimal ? 'decimal' : 'numeric';
85
+ const { accessibilityHint, accessibilityLabel } = useFormFieldAccessibility({
86
+ label,
87
+ helperText,
88
+ validText,
89
+ invalidText,
90
+ required,
91
+ validationStatus,
92
+ fallbackLabel: props.accessibilityLabel,
93
+ fallbackHint: props.accessibilityHint,
94
+ });
84
95
  useImperativeHandle(ref, () => inputRef.current, []);
85
96
  useEffect(() => {
86
97
  if (!isControlled && defaultValue !== undefined) {
@@ -134,39 +145,11 @@ const StepperInput = ({ value, defaultValue, onChangeText, onChangeValue, min, m
134
145
  setIsInputFocused(false);
135
146
  onBlur?.(event);
136
147
  };
137
- const getAccessibilityLabel = () => {
138
- let accessibilityLabel = '';
139
- if (label) {
140
- accessibilityLabel = accessibilityLabel + label;
141
- }
142
- if (required) {
143
- accessibilityLabel = accessibilityLabel + ', required';
144
- }
145
- return accessibilityLabel || props.accessibilityLabel;
146
- };
147
- const getAccessibilityHint = () => {
148
- let accessibilityHint = '';
149
- if (helperText) {
150
- accessibilityHint = accessibilityHint + helperText;
151
- }
152
- if (validationStatus !== 'initial') {
153
- if (accessibilityHint.length > 0) {
154
- accessibilityHint = accessibilityHint + ', ';
155
- }
156
- if (validationStatus === 'invalid' && invalidText) {
157
- accessibilityHint = accessibilityHint + invalidText;
158
- }
159
- if (validationStatus === 'valid' && validText) {
160
- accessibilityHint = accessibilityHint + validText;
161
- }
162
- }
163
- return accessibilityHint || props.accessibilityHint;
164
- };
165
148
  return (_jsx(FormField, { label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, validationStatus: validationStatus, disabled: disabled, readonly: readonly, accessibilityHandledByChildren: true, style: [styles.root, style], children: _jsxs(View, { style: styles.controls, children: [_jsx(StepperButton, { icon: MinusSmallIcon, disabled: decrementDisabled, accessibilityLabel: decrementAccessibilityLabel, onPress: () => handleStepPress(-1) }), _jsx(InputComponent, { validationStatus: validationStatus, isInvalid: validationStatus === 'invalid', isReadOnly: readonly, isDisabled: disabled, isFocused: resolvedFocused, isRequired: required, style: styles.inputRoot, children: _jsx(InputField
166
149
  // @ts-expect-error - ref forwarding issue mirrors the base Input component
167
150
  , {
168
151
  // @ts-expect-error - ref forwarding issue mirrors the base Input component
169
- ref: inputRef, inputMode: inputMode, keyboardType: keyboardType, inBottomSheet: inBottomSheet, editable: !disabled && !readonly, textAlign: "center", value: displayValue, onFocus: handleFocus, onBlur: handleBlur, onChangeText: handleChangeText, accessibilityLabel: getAccessibilityLabel(), accessibilityHint: getAccessibilityHint(), accessibilityState: {
152
+ ref: inputRef, inputMode: inputMode, keyboardType: keyboardType, inBottomSheet: inBottomSheet, editable: !disabled && !readonly, textAlign: "center", value: displayValue, onFocus: handleFocus, onBlur: handleBlur, onChangeText: handleChangeText, accessibilityLabel: accessibilityLabel, accessibilityHint: accessibilityHint, accessibilityState: {
170
153
  ...(props.accessibilityState ?? {}),
171
154
  disabled: disabled || readonly,
172
155
  }, "aria-disabled": disabled || readonly, "aria-readonly": readonly, "aria-required": required, "aria-invalid": validationStatus === 'invalid', ...props, style: styles.inputField }) }), _jsx(StepperButton, { icon: AddSmallIcon, disabled: incrementDisabled, accessibilityLabel: incrementAccessibilityLabel, onPress: () => handleStepPress(1) })] }) }));
@@ -49,8 +49,8 @@ const styles = StyleSheet.create(theme => ({
49
49
  },
50
50
  variants: {
51
51
  size: {
52
- md: { height: theme.components.tabs.md.height },
53
- lg: { height: theme.components.tabs.lg.height },
52
+ md: { minHeight: theme.components.tabs.md.height },
53
+ lg: { minHeight: theme.components.tabs.lg.height },
54
54
  },
55
55
  pressed: {
56
56
  true: {
@@ -6,7 +6,7 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
6
6
  import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
7
7
  import { Path, Svg } from 'react-native-svg';
8
8
  import { StyleSheet } from 'react-native-unistyles';
9
- import { useTheme } from '../../hooks';
9
+ import { useFormFieldAccessibility, useTheme } from '../../hooks';
10
10
  import { FormField, useFormFieldContext } from '../FormField';
11
11
  import TextareaFieldComponent from './TextareaField';
12
12
  import TextareaRoot from './TextareaRoot';
@@ -33,6 +33,16 @@ const Textarea = ({ validationStatus = 'initial', children, resizable = false, d
33
33
  const textareaHeight = useSharedValue(textareaDefaultHeight);
34
34
  const resizeStartHeight = useSharedValue(textareaDefaultHeight);
35
35
  const theme = useTheme();
36
+ const { accessibilityHint, accessibilityLabel } = useFormFieldAccessibility({
37
+ label: textareaLabel,
38
+ helperText: textareaHelperText,
39
+ validText: textareaValidText,
40
+ invalidText: textareaInvalidText,
41
+ required: textareaRequired,
42
+ validationStatus: textareaValidationStatus,
43
+ fallbackLabel: props.accessibilityLabel,
44
+ fallbackHint: props.accessibilityHint,
45
+ });
36
46
  useEffect(() => {
37
47
  if (formFieldContext?.setShouldHandleAccessibility) {
38
48
  formFieldContext.setShouldHandleAccessibility(true);
@@ -44,34 +54,6 @@ const Textarea = ({ validationStatus = 'initial', children, resizable = false, d
44
54
  resizeStartHeight.value = textareaDefaultHeight;
45
55
  }
46
56
  }, [resizeStartHeight, textareaDefaultHeight, textareaHeight]);
47
- const getAccessibilityLabel = () => {
48
- let accessibilityLabel = '';
49
- if (textareaLabel) {
50
- accessibilityLabel = accessibilityLabel + textareaLabel;
51
- }
52
- if (textareaRequired) {
53
- accessibilityLabel = accessibilityLabel + ', required';
54
- }
55
- return accessibilityLabel || props.accessibilityLabel;
56
- };
57
- const getAccessibilityHint = () => {
58
- let accessibilityHint = '';
59
- if (textareaHelperText) {
60
- accessibilityHint = accessibilityHint + textareaHelperText;
61
- }
62
- if (textareaValidationStatus !== 'initial') {
63
- if (accessibilityHint.length > 0) {
64
- accessibilityHint = accessibilityHint + ', ';
65
- }
66
- if (textareaValidationStatus === 'invalid' && textareaInvalidText) {
67
- accessibilityHint = accessibilityHint + textareaInvalidText;
68
- }
69
- if (textareaValidationStatus === 'valid' && textareaValidText) {
70
- accessibilityHint = accessibilityHint + textareaValidText;
71
- }
72
- }
73
- return accessibilityHint || props.accessibilityHint;
74
- };
75
57
  const handleTextareaLayout = (event) => {
76
58
  if (!hasMeasuredHeight.current) {
77
59
  textareaHeight.value = event.nativeEvent.layout.height;
@@ -97,7 +79,7 @@ const Textarea = ({ validationStatus = 'initial', children, resizable = false, d
97
79
  const rootStyle = (children ? props.style : undefined);
98
80
  const inputStyle = (!children ? props.style : undefined);
99
81
  const textareaStyle = (resizable ? [rootStyle, animatedHeightStyle] : rootStyle);
100
- return (_jsx(FormField, { label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, validationStatus: validationStatus, disabled: disabled, readonly: readonly, accessibilityHandledByChildren: true, children: _jsxs(TextareaComponent, { ...(children ? props : {}), onLayout: handleTextareaLayout, style: textareaStyle, validationStatus: textareaValidationStatus, isInvalid: textareaValidationStatus === 'invalid', isReadOnly: textareaReadonly, isDisabled: textareaDisabled, isFocused: focused, required: textareaRequired, "aria-label": getAccessibilityLabel(), accessibilityHint: getAccessibilityHint(), children: [children ? (_jsx(_Fragment, { children: children })) : (_jsx(_Fragment, { children: _jsx(TextareaField, { ...props, onLayout: onLayout, style: [styles.textarea, inputStyle] }) })), resizable && !textareaDisabled ? (_jsx(GestureDetector, { gesture: resizeGesture, children: _jsx(View, { style: styles.resizeHandle, children: _jsx(Svg, { width: RESIZE_HANDLE_ICON_SIZE, height: RESIZE_HANDLE_ICON_SIZE, viewBox: "0 0 9 9", fill: "none", children: _jsx(Path, { d: "M0.353516 8.35355L8.35352 0.353546M4.35352 8.35355L8.35352 4.35355", stroke: theme.color.icon.primary }) }) }) })) : null] }) }));
82
+ return (_jsx(FormField, { label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, validationStatus: validationStatus, disabled: disabled, readonly: readonly, accessibilityHandledByChildren: true, children: _jsxs(TextareaComponent, { ...(children ? props : {}), onLayout: handleTextareaLayout, style: textareaStyle, validationStatus: textareaValidationStatus, isInvalid: textareaValidationStatus === 'invalid', isReadOnly: textareaReadonly, isDisabled: textareaDisabled, isFocused: focused, required: textareaRequired, "aria-label": accessibilityLabel, accessibilityHint: accessibilityHint, children: [children ? (_jsx(_Fragment, { children: children })) : (_jsx(_Fragment, { children: _jsx(TextareaField, { ...props, onLayout: onLayout, style: [styles.textarea, inputStyle] }) })), resizable && !textareaDisabled ? (_jsx(GestureDetector, { gesture: resizeGesture, children: _jsx(View, { style: styles.resizeHandle, children: _jsx(Svg, { width: RESIZE_HANDLE_ICON_SIZE, height: RESIZE_HANDLE_ICON_SIZE, viewBox: "0 0 9 9", fill: "none", children: _jsx(Path, { d: "M0.353516 8.35355L8.35352 0.353546M4.35352 8.35355L8.35352 4.35355", stroke: theme.color.icon.primary }) }) }) })) : null] }) }));
101
83
  };
102
84
  const styles = StyleSheet.create({
103
85
  textarea: {
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
3
  import { TextInput, View } from 'react-native';
4
4
  import { StyleSheet } from 'react-native-unistyles';
5
+ import { useFormFieldAccessibility } from '../../hooks';
5
6
  import { FormField } from '../FormField';
6
7
  import { getNextIndexFromValueChange } from './VerificationInput.utils';
7
8
  import { VerificationInputSlot } from './VerificationInputSlot';
@@ -125,28 +126,17 @@ const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVa
125
126
  },
126
127
  }), [length, updateValue]);
127
128
  const slots = Array.from({ length }, (_, index) => index);
128
- const getAccessibilityLabel = () => {
129
- return label || props.accessibilityLabel;
130
- };
131
- const getAccessibilityHint = () => {
132
- let accessibilityHint = '';
133
- if (helperText) {
134
- accessibilityHint = accessibilityHint + helperText;
135
- }
136
- if (validationStatus !== 'initial') {
137
- if (accessibilityHint.length > 0) {
138
- accessibilityHint = accessibilityHint + ', ';
139
- }
140
- if (validationStatus === 'invalid' && invalidText) {
141
- accessibilityHint = accessibilityHint + invalidText;
142
- }
143
- if (validationStatus === 'valid' && validText) {
144
- accessibilityHint = accessibilityHint + validText;
145
- }
146
- }
147
- return accessibilityHint || props.accessibilityHint;
148
- };
149
- return (_jsx(FormField, { label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validationStatus: validationStatus, validText: validText, invalidText: invalidText, disabled: disabled, readonly: readonly, accessibilityHandledByChildren: true, style: [styles.root, style], ...props, children: _jsxs(View, { style: styles.slotsContainer, children: [_jsx(TextInput, { ref: inputRef, value: displayValue, autoFocus: autoFocus, editable: !disabled && !readonly, accessibilityLabel: getAccessibilityLabel(), accessibilityHint: getAccessibilityHint(), accessibilityState: { disabled: disabled || readonly }, importantForAccessibility: "yes", onChangeText: handleChangeText, onSelectionChange: event => {
129
+ const { accessibilityHint, accessibilityLabel } = useFormFieldAccessibility({
130
+ label,
131
+ helperText,
132
+ validText,
133
+ invalidText,
134
+ validationStatus,
135
+ fallbackLabel: props.accessibilityLabel,
136
+ fallbackHint: props.accessibilityHint,
137
+ includeRequiredInLabel: false,
138
+ });
139
+ return (_jsx(FormField, { label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validationStatus: validationStatus, validText: validText, invalidText: invalidText, disabled: disabled, readonly: readonly, accessibilityHandledByChildren: true, style: [styles.root, style], ...props, children: _jsxs(View, { style: styles.slotsContainer, children: [_jsx(TextInput, { ref: inputRef, value: displayValue, autoFocus: autoFocus, editable: !disabled && !readonly, accessibilityLabel: accessibilityLabel, accessibilityHint: accessibilityHint, accessibilityState: { disabled: disabled || readonly }, importantForAccessibility: "yes", onChangeText: handleChangeText, onSelectionChange: event => {
150
140
  const nextSelection = event.nativeEvent.selection;
151
141
  if (ignoreNextSelectionRef.current &&
152
142
  (nextSelection.start !== latestSelectionRef.current.start ||
@@ -1,5 +1,6 @@
1
1
  export { default as useBreakpointValue } from './useBreakpointValue';
2
2
  export { default as useColorMode } from './useColorMode';
3
+ export { default as useFormFieldAccessibility } from './useFormFieldAccessibility';
3
4
  export { default as useMedia } from './useMedia';
4
5
  export { usePrevious } from './usePrevious';
5
6
  export { useStyleProps } from './useStyleProps';
@@ -1,5 +1,6 @@
1
1
  export { default as useBreakpointValue } from './useBreakpointValue';
2
2
  export { default as useColorMode } from './useColorMode';
3
+ export { default as useFormFieldAccessibility } from './useFormFieldAccessibility';
3
4
  export { default as useMedia } from './useMedia';
4
5
  export { usePrevious } from './usePrevious';
5
6
  export { useStyleProps } from './useStyleProps';
@@ -0,0 +1,17 @@
1
+ type ValidationStatus = 'initial' | 'valid' | 'invalid';
2
+ type UseFormFieldAccessibilityArgs = {
3
+ label?: string;
4
+ helperText?: string;
5
+ validText?: string;
6
+ invalidText?: string;
7
+ required?: boolean;
8
+ validationStatus?: ValidationStatus;
9
+ fallbackLabel?: string;
10
+ fallbackHint?: string;
11
+ includeRequiredInLabel?: boolean;
12
+ };
13
+ declare const useFormFieldAccessibility: ({ label, helperText, validText, invalidText, required, validationStatus, fallbackLabel, fallbackHint, includeRequiredInLabel, }: UseFormFieldAccessibilityArgs) => {
14
+ accessibilityHint: string | undefined;
15
+ accessibilityLabel: string | undefined;
16
+ };
17
+ export default useFormFieldAccessibility;
@@ -0,0 +1,32 @@
1
+ import { useMemo } from 'react';
2
+ const useFormFieldAccessibility = ({ label, helperText, validText, invalidText, required = false, validationStatus = 'initial', fallbackLabel, fallbackHint, includeRequiredInLabel = true, }) => {
3
+ const accessibilityLabel = useMemo(() => {
4
+ const nextAccessibilityLabelParts = [];
5
+ if (label) {
6
+ nextAccessibilityLabelParts.push(label);
7
+ }
8
+ if (includeRequiredInLabel && required) {
9
+ nextAccessibilityLabelParts.push('required');
10
+ }
11
+ const nextAccessibilityLabel = nextAccessibilityLabelParts.join(', ');
12
+ return nextAccessibilityLabel || fallbackLabel;
13
+ }, [fallbackLabel, includeRequiredInLabel, label, required]);
14
+ const accessibilityHint = useMemo(() => {
15
+ const accessibilityHints = [];
16
+ if (helperText) {
17
+ accessibilityHints.push(helperText);
18
+ }
19
+ if (validationStatus === 'invalid' && invalidText) {
20
+ accessibilityHints.push(invalidText);
21
+ }
22
+ if (validationStatus === 'valid' && validText) {
23
+ accessibilityHints.push(validText);
24
+ }
25
+ return accessibilityHints.join(', ') || fallbackHint;
26
+ }, [fallbackHint, helperText, invalidText, validText, validationStatus]);
27
+ return {
28
+ accessibilityHint,
29
+ accessibilityLabel,
30
+ };
31
+ };
32
+ export default useFormFieldAccessibility;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+ import useFormFieldAccessibility from './useFormFieldAccessibility';
5
+ const renderHook = (args) => {
6
+ let result;
7
+ const TestComponent = () => {
8
+ result = useFormFieldAccessibility(args);
9
+ return null;
10
+ };
11
+ renderToStaticMarkup(_jsx(TestComponent, {}));
12
+ if (!result) {
13
+ throw new Error('Hook did not return a result');
14
+ }
15
+ return result;
16
+ };
17
+ describe('useFormFieldAccessibility', () => {
18
+ it('builds a label from the field label and required state', () => {
19
+ expect(renderHook({
20
+ label: 'Email',
21
+ required: true,
22
+ })).toEqual({
23
+ accessibilityHint: undefined,
24
+ accessibilityLabel: 'Email, required',
25
+ });
26
+ });
27
+ it('builds a hint from helper text and validation feedback', () => {
28
+ expect(renderHook({
29
+ helperText: 'Enter the code we sent you',
30
+ validationStatus: 'invalid',
31
+ invalidText: 'Code is invalid',
32
+ })).toEqual({
33
+ accessibilityHint: 'Enter the code we sent you, Code is invalid',
34
+ accessibilityLabel: undefined,
35
+ });
36
+ });
37
+ it('falls back to provided accessibility props when no composed value exists', () => {
38
+ expect(renderHook({
39
+ fallbackLabel: 'Custom label',
40
+ fallbackHint: 'Custom hint',
41
+ })).toEqual({
42
+ accessibilityHint: 'Custom hint',
43
+ accessibilityLabel: 'Custom label',
44
+ });
45
+ });
46
+ it('supports omitting the required suffix from the label', () => {
47
+ expect(renderHook({
48
+ label: 'Verification code',
49
+ required: true,
50
+ includeRequiredInLabel: false,
51
+ })).toEqual({
52
+ accessibilityHint: undefined,
53
+ accessibilityLabel: 'Verification code',
54
+ });
55
+ });
56
+ });