@utilitywarehouse/hearth-react-native 0.31.1 → 0.32.1

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 (87) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +15 -18
  3. package/CHANGELOG.md +62 -0
  4. package/build/components/Card/Card.props.d.ts +1 -1
  5. package/build/components/Card/CardRoot.js +19 -0
  6. package/build/components/Input/Input.js +13 -31
  7. package/build/components/Rating/Rating.d.ts +6 -0
  8. package/build/components/Rating/Rating.js +76 -0
  9. package/build/components/Rating/Rating.props.d.ts +18 -0
  10. package/build/components/Rating/Rating.props.js +1 -0
  11. package/build/components/Rating/RatingStarEmpty.d.ts +6 -0
  12. package/build/components/Rating/RatingStarEmpty.js +9 -0
  13. package/build/components/Rating/RatingStarFilled.d.ts +6 -0
  14. package/build/components/Rating/RatingStarFilled.js +9 -0
  15. package/build/components/Rating/index.d.ts +2 -0
  16. package/build/components/Rating/index.js +1 -0
  17. package/build/components/Roundel/Roundel.d.ts +6 -0
  18. package/build/components/Roundel/Roundel.js +40 -0
  19. package/build/components/Roundel/Roundel.props.d.ts +6 -0
  20. package/build/components/Roundel/Roundel.props.js +1 -0
  21. package/build/components/Roundel/index.d.ts +2 -0
  22. package/build/components/Roundel/index.js +1 -0
  23. package/build/components/StepperInput/StepperButton.d.ts +22 -0
  24. package/build/components/StepperInput/StepperButton.js +55 -0
  25. package/build/components/StepperInput/StepperInput.d.ts +6 -0
  26. package/build/components/StepperInput/StepperInput.js +179 -0
  27. package/build/components/StepperInput/StepperInput.props.d.ts +31 -0
  28. package/build/components/StepperInput/StepperInput.props.js +1 -0
  29. package/build/components/StepperInput/index.d.ts +2 -0
  30. package/build/components/StepperInput/index.js +1 -0
  31. package/build/components/Textarea/Textarea.d.ts +1 -1
  32. package/build/components/Textarea/Textarea.js +21 -32
  33. package/build/components/Textarea/Textarea.props.d.ts +11 -0
  34. package/build/components/VerificationInput/VerificationInput.js +12 -22
  35. package/build/components/index.d.ts +3 -0
  36. package/build/components/index.js +3 -0
  37. package/build/hooks/index.d.ts +1 -0
  38. package/build/hooks/index.js +1 -0
  39. package/build/hooks/useFormFieldAccessibility.d.ts +17 -0
  40. package/build/hooks/useFormFieldAccessibility.js +32 -0
  41. package/build/hooks/useFormFieldAccessibility.test.d.ts +1 -0
  42. package/build/hooks/useFormFieldAccessibility.test.js +56 -0
  43. package/docs/adding-shadows.mdx +2 -2
  44. package/docs/changelog.mdx +16 -0
  45. package/docs/components/AllComponents.web.tsx +30 -1
  46. package/docs/dark-mode-best-practice.mdx +328 -0
  47. package/package.json +6 -4
  48. package/src/components/Banner/Banner.stories.tsx +14 -0
  49. package/src/components/Card/Card.docs.mdx +16 -17
  50. package/src/components/Card/Card.props.ts +1 -0
  51. package/src/components/Card/Card.stories.tsx +35 -21
  52. package/src/components/Card/CardRoot.tsx +19 -0
  53. package/src/components/Icon/Icon.docs.mdx +1 -1
  54. package/src/components/Input/Input.tsx +14 -35
  55. package/src/components/List/List.docs.mdx +4 -2
  56. package/src/components/Modal/Modal.docs.mdx +58 -4
  57. package/src/components/NavModal/NavModal.docs.mdx +2 -2
  58. package/src/components/Rating/Rating.docs.mdx +178 -0
  59. package/src/components/Rating/Rating.figma.tsx +20 -0
  60. package/src/components/Rating/Rating.props.ts +22 -0
  61. package/src/components/Rating/Rating.stories.tsx +95 -0
  62. package/src/components/Rating/Rating.tsx +140 -0
  63. package/src/components/Rating/RatingStarEmpty.tsx +22 -0
  64. package/src/components/Rating/RatingStarFilled.tsx +27 -0
  65. package/src/components/Rating/index.ts +2 -0
  66. package/src/components/Roundel/Roundel.docs.mdx +48 -0
  67. package/src/components/Roundel/Roundel.figma.tsx +17 -0
  68. package/src/components/Roundel/Roundel.props.ts +8 -0
  69. package/src/components/Roundel/Roundel.stories.tsx +49 -0
  70. package/src/components/Roundel/Roundel.tsx +51 -0
  71. package/src/components/Roundel/index.ts +2 -0
  72. package/src/components/StepperInput/StepperButton.tsx +83 -0
  73. package/src/components/StepperInput/StepperInput.docs.mdx +121 -0
  74. package/src/components/StepperInput/StepperInput.figma.tsx +45 -0
  75. package/src/components/StepperInput/StepperInput.props.ts +39 -0
  76. package/src/components/StepperInput/StepperInput.stories.tsx +270 -0
  77. package/src/components/StepperInput/StepperInput.tsx +322 -0
  78. package/src/components/StepperInput/index.ts +2 -0
  79. package/src/components/Textarea/Textarea.docs.mdx +2 -0
  80. package/src/components/Textarea/Textarea.props.ts +11 -0
  81. package/src/components/Textarea/Textarea.stories.tsx +14 -0
  82. package/src/components/Textarea/Textarea.tsx +22 -34
  83. package/src/components/VerificationInput/VerificationInput.tsx +13 -25
  84. package/src/components/index.ts +3 -0
  85. package/src/hooks/index.ts +1 -0
  86. package/src/hooks/useFormFieldAccessibility.test.tsx +74 -0
  87. package/src/hooks/useFormFieldAccessibility.ts +67 -0
@@ -0,0 +1,179 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AddSmallIcon, MinusSmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
3
+ import { useEffect, useImperativeHandle, useRef, useState } from 'react';
4
+ import { View } from 'react-native';
5
+ import { StyleSheet } from 'react-native-unistyles';
6
+ import { useFormFieldAccessibility } from '../../hooks';
7
+ import { FormField } from '../FormField';
8
+ import { InputComponent, InputField } from '../Input/Input';
9
+ import StepperButton from './StepperButton';
10
+ const normalizeValue = (value) => {
11
+ if (value === undefined || value === null || value === '') {
12
+ return '';
13
+ }
14
+ return `${value}`;
15
+ };
16
+ const getDecimalPlaces = (value) => {
17
+ if (value === undefined || value === null || value === '') {
18
+ return 0;
19
+ }
20
+ const normalizedValue = `${value}`;
21
+ const decimalPart = normalizedValue.split('.')[1];
22
+ return decimalPart ? decimalPart.length : 0;
23
+ };
24
+ const formatNumber = (value, precision) => {
25
+ if (precision <= 0) {
26
+ return `${Math.trunc(value)}`;
27
+ }
28
+ return value
29
+ .toFixed(precision)
30
+ .replace(/\.0+$/, '')
31
+ .replace(/(\.\d*?)0+$/, '$1');
32
+ };
33
+ const sanitizeValue = (value, allowNegative, allowDecimal) => {
34
+ const strippedValue = value.replace(allowDecimal ? /[^\d,.-]/g : allowNegative ? /[^\d-]/g : /\D/g, '');
35
+ const normalizedValue = allowDecimal ? strippedValue.replace(/,/g, '.') : strippedValue;
36
+ if (!allowNegative) {
37
+ const unsignedValue = normalizedValue.replace(/-/g, '');
38
+ if (!allowDecimal) {
39
+ return unsignedValue;
40
+ }
41
+ const [integerPart = '', ...decimalParts] = unsignedValue.split('.');
42
+ const decimalPart = decimalParts.join('');
43
+ return decimalParts.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
44
+ }
45
+ const hasLeadingMinus = normalizedValue.startsWith('-');
46
+ const unsignedValue = normalizedValue.replace(/-/g, '');
47
+ if (!allowDecimal) {
48
+ return `${hasLeadingMinus ? '-' : ''}${unsignedValue}`;
49
+ }
50
+ const [integerPart = '', ...decimalParts] = unsignedValue.split('.');
51
+ const decimalPart = decimalParts.join('');
52
+ const composedValue = decimalParts.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
53
+ return `${hasLeadingMinus ? '-' : ''}${composedValue}`;
54
+ };
55
+ const parseValue = (value) => {
56
+ if (!value || value === '-' || value === '.' || value === '-.') {
57
+ return null;
58
+ }
59
+ const parsedValue = Number(value);
60
+ return Number.isNaN(parsedValue) ? null : parsedValue;
61
+ };
62
+ const clampValue = (value, min, max) => {
63
+ let nextValue = value;
64
+ if (typeof min === 'number') {
65
+ nextValue = Math.max(min, nextValue);
66
+ }
67
+ if (typeof max === 'number') {
68
+ nextValue = Math.min(max, nextValue);
69
+ }
70
+ return nextValue;
71
+ };
72
+ const StepperInput = ({ value, defaultValue, onChangeText, onChangeValue, min, max, step = 1, focusInputOnStepPress = false, validationStatus = 'initial', disabled = false, readonly = false, focused = false, inBottomSheet = false, required = true, label, labelVariant = 'body', helperText, helperIcon, validText, invalidText, style, decrementAccessibilityLabel = 'Decrease value', incrementAccessibilityLabel = 'Increase value', onFocus, onBlur, ref, ...props }) => {
73
+ const inputRef = useRef(null);
74
+ const isControlled = value !== undefined;
75
+ const [internalValue, setInternalValue] = useState(() => normalizeValue(defaultValue));
76
+ const [isInputFocused, setIsInputFocused] = useState(false);
77
+ const displayValue = isControlled ? normalizeValue(value) : internalValue;
78
+ const parsedValue = parseValue(displayValue);
79
+ const resolvedFocused = focused || isInputFocused;
80
+ const allowNegative = typeof min !== 'number' || min < 0 || (typeof max === 'number' && max < 0);
81
+ const decimalPrecision = Math.max(getDecimalPlaces(value), getDecimalPlaces(defaultValue), getDecimalPlaces(min), getDecimalPlaces(max), getDecimalPlaces(step));
82
+ const allowDecimal = decimalPrecision > 0;
83
+ const keyboardType = allowNegative || allowDecimal ? 'numeric' : 'number-pad';
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
+ });
95
+ useImperativeHandle(ref, () => inputRef.current, []);
96
+ useEffect(() => {
97
+ if (!isControlled && defaultValue !== undefined) {
98
+ setInternalValue(normalizeValue(defaultValue));
99
+ }
100
+ }, [defaultValue, isControlled]);
101
+ const updateValue = (nextValue) => {
102
+ if (!isControlled) {
103
+ setInternalValue(nextValue);
104
+ }
105
+ onChangeText?.(nextValue);
106
+ const nextParsedValue = parseValue(nextValue);
107
+ if (nextParsedValue !== null) {
108
+ onChangeValue?.(clampValue(nextParsedValue, min, max));
109
+ }
110
+ };
111
+ const handleChangeText = (nextText) => {
112
+ const sanitizedValue = sanitizeValue(nextText, allowNegative, allowDecimal);
113
+ if (sanitizedValue === '' ||
114
+ sanitizedValue === '-' ||
115
+ sanitizedValue === '.' ||
116
+ sanitizedValue === '-.' ||
117
+ (allowDecimal && sanitizedValue.endsWith('.'))) {
118
+ updateValue(sanitizedValue);
119
+ return;
120
+ }
121
+ const nextParsedValue = parseValue(sanitizedValue);
122
+ if (nextParsedValue === null) {
123
+ updateValue(sanitizedValue);
124
+ return;
125
+ }
126
+ const clampedText = formatNumber(clampValue(nextParsedValue, min, max), decimalPrecision);
127
+ updateValue(clampedText);
128
+ };
129
+ const handleStepPress = (direction) => {
130
+ const baseValue = parsedValue ?? (typeof min === 'number' ? min : 0);
131
+ const nextValue = clampValue(baseValue + direction * step, min, max);
132
+ const normalizedValue = formatNumber(nextValue, decimalPrecision);
133
+ updateValue(normalizedValue);
134
+ if (focusInputOnStepPress) {
135
+ inputRef.current?.focus();
136
+ }
137
+ };
138
+ const decrementDisabled = disabled || readonly || (typeof min === 'number' && parsedValue !== null && parsedValue <= min);
139
+ const incrementDisabled = disabled || readonly || (typeof max === 'number' && parsedValue !== null && parsedValue >= max);
140
+ const handleFocus = (event) => {
141
+ setIsInputFocused(true);
142
+ onFocus?.(event);
143
+ };
144
+ const handleBlur = (event) => {
145
+ setIsInputFocused(false);
146
+ onBlur?.(event);
147
+ };
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
149
+ // @ts-expect-error - ref forwarding issue mirrors the base Input component
150
+ , {
151
+ // @ts-expect-error - ref forwarding issue mirrors the base Input component
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: {
153
+ ...(props.accessibilityState ?? {}),
154
+ disabled: disabled || readonly,
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) })] }) }));
156
+ };
157
+ StepperInput.displayName = 'StepperInput';
158
+ const styles = StyleSheet.create(theme => ({
159
+ root: {
160
+ width: '100%',
161
+ maxWidth: theme.components.input.maxWidth,
162
+ },
163
+ controls: {
164
+ flexDirection: 'row',
165
+ alignItems: 'center',
166
+ gap: theme.components.input.stepper.gap,
167
+ },
168
+ inputRoot: {
169
+ width: 80,
170
+ minWidth: 80,
171
+ paddingHorizontal: 0,
172
+ justifyContent: 'center',
173
+ },
174
+ inputField: {
175
+ textAlign: 'center',
176
+ paddingHorizontal: 0,
177
+ },
178
+ }));
179
+ export default StepperInput;
@@ -0,0 +1,31 @@
1
+ import type { ComponentType, Ref } from 'react';
2
+ import type { TextInput, TextInputProps, ViewProps } from 'react-native';
3
+ export interface StepperBaseProps {
4
+ ref?: Ref<TextInput>;
5
+ disabled?: boolean;
6
+ validationStatus?: 'initial' | 'valid' | 'invalid';
7
+ readonly?: boolean;
8
+ focused?: boolean;
9
+ placeholder?: string;
10
+ inBottomSheet?: boolean;
11
+ required?: boolean;
12
+ label?: string;
13
+ labelVariant?: 'heading' | 'body';
14
+ helperText?: string;
15
+ helperIcon?: ComponentType;
16
+ validText?: string;
17
+ invalidText?: string;
18
+ value?: number | string;
19
+ defaultValue?: number;
20
+ min?: number;
21
+ max?: number;
22
+ step?: number;
23
+ onChangeValue?: (value: number) => void;
24
+ focusInputOnStepPress?: boolean;
25
+ decrementAccessibilityLabel?: string;
26
+ incrementAccessibilityLabel?: string;
27
+ }
28
+ export type StepperInputProps = StepperBaseProps & Omit<TextInputProps, 'children' | 'value' | 'defaultValue' | 'onChangeText' | 'editable' | 'keyboardType'> & ViewProps & {
29
+ onChangeText?: (text: string) => void;
30
+ };
31
+ export default StepperInputProps;
@@ -0,0 +1,2 @@
1
+ export { default as StepperInput } from './StepperInput';
2
+ export type { StepperInputProps } from './StepperInput.props';
@@ -0,0 +1 @@
1
+ export { default as StepperInput } from './StepperInput';
@@ -7,5 +7,5 @@ export declare const TextareaComponent: import("@gluestack-ui/textarea/lib/types
7
7
  };
8
8
  }, import("react-native").TextInputProps>;
9
9
  export declare const TextareaField: import("react").ForwardRefExoticComponent<import("react-native").TextInputProps & import("react").RefAttributes<import("react-native").TextInputProps> & import("@gluestack-ui/textarea/lib/typescript/types").IInputProps>;
10
- declare const Textarea: ({ validationStatus, children, resizable, disabled, focused, readonly, label, labelVariant, helperText, validText, invalidText, required, helperIcon, onLayout, ...props }: TextareaProps) => import("react/jsx-runtime").JSX.Element;
10
+ declare const Textarea: ({ validationStatus, children, resizable, defaultHeight, disabled, focused, readonly, label, labelVariant, helperText, validText, invalidText, required, helperIcon, onLayout, ...props }: TextareaProps) => import("react/jsx-runtime").JSX.Element;
11
11
  export default Textarea;
@@ -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';
@@ -18,7 +18,7 @@ export const TextareaField = TextareaComponent.Input;
18
18
  const DEFAULT_TEXTAREA_HEIGHT = 96;
19
19
  const RESIZE_HANDLE_TOUCH_SIZE = 28;
20
20
  const RESIZE_HANDLE_ICON_SIZE = 9;
21
- const Textarea = ({ validationStatus = 'initial', children, resizable = false, disabled, focused, readonly, label, labelVariant, helperText, validText, invalidText, required, helperIcon, onLayout, ...props }) => {
21
+ const Textarea = ({ validationStatus = 'initial', children, resizable = false, defaultHeight, disabled, focused, readonly, label, labelVariant, helperText, validText, invalidText, required, helperIcon, onLayout, ...props }) => {
22
22
  const formFieldContext = useFormFieldContext();
23
23
  const hasMeasuredHeight = useRef(false);
24
24
  const textareaLabel = label ?? formFieldContext?.label;
@@ -29,42 +29,31 @@ const Textarea = ({ validationStatus = 'initial', children, resizable = false, d
29
29
  const textareaDisabled = disabled ?? formFieldContext?.disabled;
30
30
  const textareaReadonly = readonly ?? formFieldContext?.readonly;
31
31
  const textareaValidationStatus = formFieldContext?.validationStatus ?? validationStatus;
32
- const textareaHeight = useSharedValue(DEFAULT_TEXTAREA_HEIGHT);
33
- const resizeStartHeight = useSharedValue(DEFAULT_TEXTAREA_HEIGHT);
32
+ const textareaDefaultHeight = defaultHeight ?? DEFAULT_TEXTAREA_HEIGHT;
33
+ const textareaHeight = useSharedValue(textareaDefaultHeight);
34
+ const resizeStartHeight = useSharedValue(textareaDefaultHeight);
34
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
+ });
35
46
  useEffect(() => {
36
47
  if (formFieldContext?.setShouldHandleAccessibility) {
37
48
  formFieldContext.setShouldHandleAccessibility(true);
38
49
  }
39
50
  }, [formFieldContext]);
40
- const getAccessibilityLabel = () => {
41
- let accessibilityLabel = '';
42
- if (textareaLabel) {
43
- accessibilityLabel = accessibilityLabel + textareaLabel;
44
- }
45
- if (textareaRequired) {
46
- accessibilityLabel = accessibilityLabel + ', required';
47
- }
48
- return accessibilityLabel || props.accessibilityLabel;
49
- };
50
- const getAccessibilityHint = () => {
51
- let accessibilityHint = '';
52
- if (textareaHelperText) {
53
- accessibilityHint = accessibilityHint + textareaHelperText;
54
- }
55
- if (textareaValidationStatus !== 'initial') {
56
- if (accessibilityHint.length > 0) {
57
- accessibilityHint = accessibilityHint + ', ';
58
- }
59
- if (textareaValidationStatus === 'invalid' && textareaInvalidText) {
60
- accessibilityHint = accessibilityHint + textareaInvalidText;
61
- }
62
- if (textareaValidationStatus === 'valid' && textareaValidText) {
63
- accessibilityHint = accessibilityHint + textareaValidText;
64
- }
51
+ useEffect(() => {
52
+ if (!hasMeasuredHeight.current) {
53
+ textareaHeight.value = textareaDefaultHeight;
54
+ resizeStartHeight.value = textareaDefaultHeight;
65
55
  }
66
- return accessibilityHint || props.accessibilityHint;
67
- };
56
+ }, [resizeStartHeight, textareaDefaultHeight, textareaHeight]);
68
57
  const handleTextareaLayout = (event) => {
69
58
  if (!hasMeasuredHeight.current) {
70
59
  textareaHeight.value = event.nativeEvent.layout.height;
@@ -90,7 +79,7 @@ const Textarea = ({ validationStatus = 'initial', children, resizable = false, d
90
79
  const rootStyle = (children ? props.style : undefined);
91
80
  const inputStyle = (!children ? props.style : undefined);
92
81
  const textareaStyle = (resizable ? [rootStyle, animatedHeightStyle] : rootStyle);
93
- 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] }) }));
94
83
  };
95
84
  const styles = StyleSheet.create({
96
85
  textarea: {
@@ -1,5 +1,16 @@
1
1
  import type { TextInputProps, ViewProps } from 'react-native';
2
2
  export interface TextareaBaseProps {
3
+ /**
4
+ * Sets the initial height of a resizable textarea in pixels.
5
+ * Has no effect unless `resizable` is enabled.
6
+ *
7
+ * @type number
8
+ * @example
9
+ * ```tsx
10
+ * <Textarea resizable defaultHeight={140} />
11
+ * ```
12
+ */
13
+ defaultHeight?: number;
3
14
  /**
4
15
  * If true, the textarea can be resized vertically using a drag handle.
5
16
  *
@@ -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 ||
@@ -47,11 +47,14 @@ export * from './ProgressBar';
47
47
  export * from './ProgressStepper';
48
48
  export * from './Radio';
49
49
  export * from './RadioCard';
50
+ export * from './Rating';
51
+ export * from './Roundel';
50
52
  export * from './SectionHeader';
51
53
  export * from './SegmentedControl';
52
54
  export * from './Select';
53
55
  export * from './Skeleton';
54
56
  export * from './Spinner';
57
+ export * from './StepperInput';
55
58
  export * from './Switch';
56
59
  export * from './Table';
57
60
  export * from './Tabs';
@@ -48,11 +48,14 @@ export * from './ProgressBar';
48
48
  export * from './ProgressStepper';
49
49
  export * from './Radio';
50
50
  export * from './RadioCard';
51
+ export * from './Rating';
52
+ export * from './Roundel';
51
53
  export * from './SectionHeader';
52
54
  export * from './SegmentedControl';
53
55
  export * from './Select';
54
56
  export * from './Skeleton';
55
57
  export * from './Spinner';
58
+ export * from './StepperInput';
56
59
  export * from './Switch';
57
60
  export * from './Table';
58
61
  export * from './Tabs';
@@ -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
+ });
@@ -62,6 +62,6 @@ const MyComponent = () => <Card shadowColor="brand">{/* Card content */}</Card>;
62
62
  <NextPrevPage
63
63
  prevLink="all-components"
64
64
  prevTitle="All Components"
65
- nextLink="primitives-box"
66
- nextTitle="Box"
65
+ nextLink="guides-dark-mode-best-practice"
66
+ nextTitle="Dark Mode Best Practice"
67
67
  />
@@ -9,6 +9,22 @@ import { BackToTopButton, NextPrevPage } from './components';
9
9
  The changelog for the Hearth React Native library. Here you can find all the changes, improvements, and bug fixes for each version.
10
10
 
11
11
 
12
+ ## 0.31.1
13
+
14
+ ### Patch Changes
15
+
16
+ - [#1119](https://github.com/utilitywarehouse/hearth/pull/1119) [`19415d4`](https://github.com/utilitywarehouse/hearth/commit/19415d4d54458b3fb019df6647b9a5e4c375b672) Thanks [@jordmccord](https://github.com/jordmccord)! - 🐛 [FIX]: Refresh dark mode tokens across components and semantic colors.
17
+
18
+ Dark mode color tokens have been updated across semantic and component tokens to improve contrast and visual consistency. This also fixes `TableHeaderCell` text colors so purple and white header variants resolve the correct foreground token.
19
+
20
+ **Components affected**:
21
+ - dark mode tokens
22
+ - `TableHeaderCell`
23
+
24
+ **Developer changes**:
25
+
26
+ No code changes are required unless you rely on the previous dark mode token values or visual snapshots.
27
+
12
28
  ## 0.31.0
13
29
 
14
30
  ### Minor Changes