@utilitywarehouse/hearth-react-native 0.31.1 → 0.32.0

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 (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +13 -13
  3. package/CHANGELOG.md +55 -0
  4. package/build/components/Rating/Rating.d.ts +6 -0
  5. package/build/components/Rating/Rating.js +76 -0
  6. package/build/components/Rating/Rating.props.d.ts +18 -0
  7. package/build/components/Rating/Rating.props.js +1 -0
  8. package/build/components/Rating/RatingStarEmpty.d.ts +6 -0
  9. package/build/components/Rating/RatingStarEmpty.js +9 -0
  10. package/build/components/Rating/RatingStarFilled.d.ts +6 -0
  11. package/build/components/Rating/RatingStarFilled.js +9 -0
  12. package/build/components/Rating/index.d.ts +2 -0
  13. package/build/components/Rating/index.js +1 -0
  14. package/build/components/Roundel/Roundel.d.ts +6 -0
  15. package/build/components/Roundel/Roundel.js +40 -0
  16. package/build/components/Roundel/Roundel.props.d.ts +6 -0
  17. package/build/components/Roundel/Roundel.props.js +1 -0
  18. package/build/components/Roundel/index.d.ts +2 -0
  19. package/build/components/Roundel/index.js +1 -0
  20. package/build/components/StepperInput/StepperButton.d.ts +22 -0
  21. package/build/components/StepperInput/StepperButton.js +55 -0
  22. package/build/components/StepperInput/StepperInput.d.ts +6 -0
  23. package/build/components/StepperInput/StepperInput.js +196 -0
  24. package/build/components/StepperInput/StepperInput.props.d.ts +31 -0
  25. package/build/components/StepperInput/StepperInput.props.js +1 -0
  26. package/build/components/StepperInput/index.d.ts +2 -0
  27. package/build/components/StepperInput/index.js +1 -0
  28. package/build/components/Textarea/Textarea.d.ts +1 -1
  29. package/build/components/Textarea/Textarea.js +10 -3
  30. package/build/components/Textarea/Textarea.props.d.ts +11 -0
  31. package/build/components/index.d.ts +3 -0
  32. package/build/components/index.js +3 -0
  33. package/docs/adding-shadows.mdx +2 -2
  34. package/docs/changelog.mdx +16 -0
  35. package/docs/components/AllComponents.web.tsx +30 -1
  36. package/docs/dark-mode-best-practice.mdx +328 -0
  37. package/package.json +3 -3
  38. package/src/components/Modal/Modal.docs.mdx +58 -4
  39. package/src/components/NavModal/NavModal.docs.mdx +2 -2
  40. package/src/components/Rating/Rating.docs.mdx +178 -0
  41. package/src/components/Rating/Rating.figma.tsx +20 -0
  42. package/src/components/Rating/Rating.props.ts +22 -0
  43. package/src/components/Rating/Rating.stories.tsx +95 -0
  44. package/src/components/Rating/Rating.tsx +140 -0
  45. package/src/components/Rating/RatingStarEmpty.tsx +22 -0
  46. package/src/components/Rating/RatingStarFilled.tsx +27 -0
  47. package/src/components/Rating/index.ts +2 -0
  48. package/src/components/Roundel/Roundel.docs.mdx +48 -0
  49. package/src/components/Roundel/Roundel.figma.tsx +17 -0
  50. package/src/components/Roundel/Roundel.props.ts +8 -0
  51. package/src/components/Roundel/Roundel.stories.tsx +49 -0
  52. package/src/components/Roundel/Roundel.tsx +51 -0
  53. package/src/components/Roundel/index.ts +2 -0
  54. package/src/components/StepperInput/StepperButton.tsx +83 -0
  55. package/src/components/StepperInput/StepperInput.docs.mdx +121 -0
  56. package/src/components/StepperInput/StepperInput.figma.tsx +45 -0
  57. package/src/components/StepperInput/StepperInput.props.ts +39 -0
  58. package/src/components/StepperInput/StepperInput.stories.tsx +270 -0
  59. package/src/components/StepperInput/StepperInput.tsx +349 -0
  60. package/src/components/StepperInput/index.ts +2 -0
  61. package/src/components/Textarea/Textarea.docs.mdx +2 -0
  62. package/src/components/Textarea/Textarea.props.ts +11 -0
  63. package/src/components/Textarea/Textarea.stories.tsx +14 -0
  64. package/src/components/Textarea/Textarea.tsx +11 -2
  65. package/src/components/index.ts +3 -0
@@ -1,4 +1,4 @@
1
1
 
2
- > @utilitywarehouse/hearth-react-native@0.31.1 build /home/runner/work/hearth/hearth/packages/react-native
2
+ > @utilitywarehouse/hearth-react-native@0.32.0 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.31.1 lint /home/runner/work/hearth/hearth/packages/react-native
2
+ > @utilitywarehouse/hearth-react-native@0.32.0 lint /home/runner/work/hearth/hearth/packages/react-native
3
3
  > TIMING=1 eslint .
4
4
 
5
5
 
@@ -48,15 +48,15 @@
48
48
 
49
49
  ✖ 21 problems (0 errors, 21 warnings)
50
50
 
51
- Rule | Time (ms) | Relative
52
- :-------------------------------------------------|----------:|--------:
53
- @typescript-eslint/no-unused-vars | 1546.291 | 60.2%
54
- react-hooks/exhaustive-deps | 108.680 | 4.2%
55
- no-global-assign | 99.957 | 3.9%
56
- react-hooks/rules-of-hooks | 90.616 | 3.5%
57
- no-misleading-character-class | 53.413 | 2.1%
58
- no-unexpected-multiline | 51.887 | 2.0%
59
- @typescript-eslint/ban-ts-comment | 49.938 | 1.9%
60
- @typescript-eslint/no-unnecessary-type-constraint | 38.622 | 1.5%
61
- no-loss-of-precision | 33.899 | 1.3%
62
- no-useless-escape | 30.813 | 1.2%
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%
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # @utilitywarehouse/hearth-react-native
2
2
 
3
+ ## 0.32.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#1134](https://github.com/utilitywarehouse/hearth/pull/1134) [`8824186`](https://github.com/utilitywarehouse/hearth/commit/ebccb55afebcbd47508d7992614b2495c7839cc6) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: Add `Roundel` status indicator component.
8
+
9
+ `Roundel` is a compact status indicator with `success`, `pending`, and `error` variants, intended for inline state cues.
10
+
11
+ **Components affected**:
12
+ - `Roundel`
13
+
14
+ **Developer changes**:
15
+
16
+ Import and use `Roundel` from `@utilitywarehouse/hearth-react-native`:
17
+
18
+ ```tsx
19
+ import { Roundel } from '@utilitywarehouse/hearth-react-native';
20
+
21
+ <Roundel variant="success" />
22
+ ```
23
+
24
+ - [#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
25
+
26
+ **Components affected**:
27
+ - `Rating`
28
+
29
+ **Developer changes**:
30
+
31
+ ```tsx
32
+ import { Rating } from '@utilitywarehouse/hearth-react-native';
33
+
34
+ const MyComponent = () => <Rating value={3} />;
35
+ ```
36
+
37
+ - [#1129](https://github.com/utilitywarehouse/hearth/pull/1129) [`ec385a8`](https://github.com/utilitywarehouse/hearth/commit/ec385a8185bfa4ec7f4d5f1366ecc069a98cbba8) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: Add `StepperInput` for controlled numeric input with increment and decrement buttons.
38
+
39
+ `StepperInput` is a new React Native form component for adjusting numeric values with direct text entry and dedicated step controls. It supports min and max bounds, configurable step size, validation and helper text through `FormField`, and an opt-in `focusInputOnStepPress` prop for keyboard-first flows.
40
+
41
+ **Components affected**:
42
+ - `StepperInput`
43
+
44
+ **Developer changes**:
45
+
46
+ Import and use `StepperInput` from `@utilitywarehouse/hearth-react-native`:
47
+
48
+ ```tsx
49
+ import { StepperInput } from '@utilitywarehouse/hearth-react-native';
50
+
51
+ <StepperInput label="Guests" min={1} max={10} value={value} onChangeText={setValue} />;
52
+ ```
53
+
54
+ ### Patch Changes
55
+
56
+ - [#1133](https://github.com/utilitywarehouse/hearth/pull/1133) [`5cae98e`](https://github.com/utilitywarehouse/hearth/commit/5cae98e640a708a7d99eaf0395b7b52e71b8e6ec) Thanks [@jordmccord](https://github.com/jordmccord)! - 💅 [ENHANCEMENT]: Add a `defaultHeight` prop to `Textarea` so the initial height can be configured.
57
+
3
58
  ## 0.31.1
4
59
 
5
60
  ### Patch Changes
@@ -0,0 +1,6 @@
1
+ import type RatingProps from './Rating.props';
2
+ declare const Rating: {
3
+ ({ value, defaultValue, onChange, disabled, labels, hideLabel, style, accessibilityLabel, ...props }: RatingProps): import("react/jsx-runtime").JSX.Element;
4
+ displayName: string;
5
+ };
6
+ export default Rating;
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { Pressable, View } from 'react-native';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import { BodyText } from '../BodyText';
6
+ import RatingStarEmpty from './RatingStarEmpty';
7
+ import RatingStarFilled from './RatingStarFilled';
8
+ const MAX_RATING = 5;
9
+ const STAR_WIDTH = 32;
10
+ const STAR_HEIGHT = 30;
11
+ const STAR_CONTAINER_SIZE = 40;
12
+ const DEFAULT_LABELS = {
13
+ 0: 'Select a rating',
14
+ 1: 'Awful',
15
+ 2: 'Bad',
16
+ 3: 'Okay',
17
+ 4: 'Good',
18
+ 5: 'Great!',
19
+ };
20
+ const clampRating = (value) => Math.min(MAX_RATING, Math.max(0, Math.round(value)));
21
+ const Rating = ({ value, defaultValue = 0, onChange, disabled = false, labels, hideLabel = false, style, accessibilityLabel, ...props }) => {
22
+ const isControlled = value !== undefined;
23
+ const [internalValue, setInternalValue] = useState(clampRating(defaultValue));
24
+ useEffect(() => {
25
+ if (!isControlled) {
26
+ setInternalValue(clampRating(defaultValue));
27
+ }
28
+ }, [defaultValue, isControlled]);
29
+ const resolvedLabels = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);
30
+ const resolvedValue = clampRating(isControlled ? value : internalValue);
31
+ const currentLabel = resolvedLabels[resolvedValue] ?? DEFAULT_LABELS[resolvedValue];
32
+ const labelColor = resolvedValue === 0 ? 'secondary' : 'primary';
33
+ const handlePress = useCallback((nextValue) => {
34
+ if (disabled)
35
+ return;
36
+ if (!isControlled) {
37
+ setInternalValue(nextValue);
38
+ }
39
+ onChange?.(nextValue);
40
+ }, [disabled, isControlled, onChange]);
41
+ styles.useVariants({ disabled });
42
+ return (_jsxs(View, { ...props, accessibilityRole: "radiogroup", accessibilityState: { disabled }, accessibilityLabel: accessibilityLabel ?? currentLabel, style: [styles.container, style], children: [_jsx(View, { style: styles.stars, children: [1, 2, 3, 4, 5].map(starValue => {
43
+ const isFilled = starValue <= resolvedValue;
44
+ const starLabel = resolvedLabels[starValue] ?? DEFAULT_LABELS[starValue];
45
+ return (_jsx(Pressable, { accessibilityRole: "radio", accessibilityState: { selected: resolvedValue === starValue, disabled }, accessibilityLabel: `Rate ${starLabel}`, disabled: disabled, hitSlop: 8, onPress: () => handlePress(starValue), style: styles.star, children: isFilled ? (_jsx(RatingStarFilled, { width: STAR_WIDTH, height: STAR_HEIGHT })) : (_jsx(RatingStarEmpty, { width: STAR_WIDTH, height: STAR_HEIGHT })) }, starValue));
46
+ }) }), !hideLabel ? (_jsx(BodyText, { size: "md", color: labelColor, style: styles.label, children: currentLabel })) : null] }));
47
+ };
48
+ Rating.displayName = 'Rating';
49
+ const styles = StyleSheet.create(theme => ({
50
+ container: {
51
+ alignItems: 'center',
52
+ gap: theme.components.rating.gap,
53
+ variants: {
54
+ disabled: {
55
+ true: {
56
+ opacity: theme.opacity.disabled,
57
+ },
58
+ },
59
+ },
60
+ },
61
+ stars: {
62
+ flexDirection: 'row',
63
+ gap: theme.components.rating.gap,
64
+ },
65
+ star: {
66
+ width: STAR_CONTAINER_SIZE,
67
+ height: STAR_CONTAINER_SIZE,
68
+ alignItems: 'center',
69
+ justifyContent: 'center',
70
+ padding: theme.components.rating.borderWidth,
71
+ },
72
+ label: {
73
+ textAlign: 'center',
74
+ },
75
+ }));
76
+ export default Rating;
@@ -0,0 +1,18 @@
1
+ import type { ViewProps } from 'react-native';
2
+ export type RatingValue = 0 | 1 | 2 | 3 | 4 | 5;
3
+ export type RatingLabels = Partial<Record<RatingValue, string>>;
4
+ export interface RatingProps extends Omit<ViewProps, 'children'> {
5
+ /** Current rating value. */
6
+ value?: RatingValue;
7
+ /** Initial rating value when uncontrolled. */
8
+ defaultValue?: RatingValue;
9
+ /** Called when a star is selected. */
10
+ onChange?: (value: RatingValue) => void;
11
+ /** Disables the rating input. */
12
+ disabled?: boolean;
13
+ /** Override labels for specific rating values. */
14
+ labels?: RatingLabels;
15
+ /** Hide the label text below the stars. */
16
+ hideLabel?: boolean;
17
+ }
18
+ export default RatingProps;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ type RatingStarProps = {
2
+ width?: number;
3
+ height?: number;
4
+ };
5
+ declare const RatingStarEmpty: ({ width, height }: RatingStarProps) => import("react/jsx-runtime").JSX.Element;
6
+ export default RatingStarEmpty;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Path, Svg } from 'react-native-svg';
3
+ import { useTheme } from '../../hooks';
4
+ const STAR_PATH = 'M16 1C16.0831 1 16.1883 1.02107 16.3262 1.09766C16.3896 1.13286 16.4424 1.183 16.4902 1.28711H16.4912L20.1777 9.8916L20.4141 10.4424L21.0117 10.4941L30.5107 11.3096C30.7062 11.3326 30.8 11.3864 30.8467 11.4238C30.915 11.4787 30.9492 11.5325 30.9707 11.6035C31.0029 11.71 31.0094 11.8192 30.9844 11.9463V11.9482C30.98 11.9702 30.9618 12.0389 30.832 12.1514L23.6338 18.2939L23.1689 18.6904L23.3096 19.2852L25.458 28.3926H25.459C25.4832 28.5045 25.4775 28.5714 25.4658 28.6133L25.4531 28.6475C25.4155 28.7291 25.3682 28.7951 25.3076 28.8516L25.2432 28.9043C25.172 28.9558 25.0868 28.9907 24.96 28.999C24.8763 29.0045 24.793 28.9887 24.6865 28.9229L24.6787 28.918L24.6699 28.9131L16.5088 24.0898L16 23.7891L15.4912 24.0898L7.33008 28.9131L7.32129 28.918L7.31348 28.9229C7.20704 28.9887 7.12371 29.0045 7.04004 28.999C6.94495 28.9928 6.87333 28.9713 6.81348 28.9395L6.75684 28.9043C6.68716 28.8537 6.63221 28.7954 6.58789 28.7236L6.54688 28.6475C6.53436 28.6202 6.51869 28.5743 6.52539 28.4902L6.54102 28.3926L8.69043 19.2852L8.83105 18.6904L8.36621 18.2939L1.16699 12.1514C1.03725 12.0389 1.01999 11.9702 1.01563 11.9482V11.9463L1.00195 11.8545C0.994777 11.765 1.00514 11.6834 1.0293 11.6035C1.05077 11.5325 1.08501 11.4787 1.15332 11.4238C1.19992 11.3864 1.2935 11.3327 1.48828 11.3096L10.9883 10.4941L11.5859 10.4424L11.8223 9.8916L15.5088 1.28711C15.5567 1.18274 15.6103 1.1329 15.6738 1.09766C15.8117 1.02107 15.9169 1 16 1Z';
5
+ const RatingStarEmpty = ({ width = 32, height = 30 }) => {
6
+ const theme = useTheme();
7
+ return (_jsx(Svg, { width: width, height: height, viewBox: "0 0 32 30", preserveAspectRatio: "none", children: _jsx(Path, { d: STAR_PATH, strokeWidth: 2, fill: "none", stroke: theme.color.border.subtle }) }));
8
+ };
9
+ export default RatingStarEmpty;
@@ -0,0 +1,6 @@
1
+ type RatingStarProps = {
2
+ width?: number;
3
+ height?: number;
4
+ };
5
+ declare const RatingStarFilled: ({ width, height }: RatingStarProps) => import("react/jsx-runtime").JSX.Element;
6
+ export default RatingStarFilled;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Path, Svg } from 'react-native-svg';
3
+ import { useTheme } from '../../hooks';
4
+ const STAR_PATH = 'M16 1C16.0831 1 16.1883 1.02107 16.3262 1.09766C16.3896 1.13286 16.4424 1.183 16.4902 1.28711H16.4912L20.1777 9.8916L20.4141 10.4424L21.0117 10.4941L30.5107 11.3096C30.7062 11.3326 30.8 11.3864 30.8467 11.4238C30.915 11.4787 30.9492 11.5325 30.9707 11.6035C31.0029 11.71 31.0094 11.8192 30.9844 11.9463V11.9482C30.98 11.9702 30.9618 12.0389 30.832 12.1514L23.6338 18.2939L23.1689 18.6904L23.3096 19.2852L25.458 28.3926H25.459C25.4832 28.5045 25.4775 28.5714 25.4658 28.6133L25.4531 28.6475C25.4155 28.7291 25.3682 28.7951 25.3076 28.8516L25.2432 28.9043C25.172 28.9558 25.0868 28.9907 24.96 28.999C24.8763 29.0045 24.793 28.9887 24.6865 28.9229L24.6787 28.918L24.6699 28.9131L16.5088 24.0898L16 23.7891L15.4912 24.0898L7.33008 28.9131L7.32129 28.918L7.31348 28.9229C7.20704 28.9887 7.12371 29.0045 7.04004 28.999C6.94495 28.9928 6.87333 28.9713 6.81348 28.9395L6.75684 28.9043C6.68716 28.8537 6.63221 28.7954 6.58789 28.7236L6.54688 28.6475C6.53436 28.6202 6.51869 28.5743 6.52539 28.4902L6.54102 28.3926L8.69043 19.2852L8.83105 18.6904L8.36621 18.2939L1.16699 12.1514C1.03725 12.0389 1.01999 11.9702 1.01563 11.9482V11.9463L1.00195 11.8545C0.994777 11.765 1.00514 11.6834 1.0293 11.6035C1.05077 11.5325 1.08501 11.4787 1.15332 11.4238C1.19992 11.3864 1.2935 11.3327 1.48828 11.3096L10.9883 10.4941L11.5859 10.4424L11.8223 9.8916L15.5088 1.28711C15.5567 1.18274 15.6103 1.1329 15.6738 1.09766C15.8117 1.02107 15.9169 1 16 1Z';
5
+ const RatingStarFilled = ({ width = 32, height = 30 }) => {
6
+ const theme = useTheme();
7
+ return (_jsx(Svg, { width: width, height: height, viewBox: "0 0 32 30", preserveAspectRatio: "none", children: _jsx(Path, { d: STAR_PATH, strokeWidth: 2, fill: theme.color.surface.highlight.default, stroke: theme.color.border.strong }) }));
8
+ };
9
+ export default RatingStarFilled;
@@ -0,0 +1,2 @@
1
+ export { default as Rating } from './Rating';
2
+ export type { RatingLabels, RatingProps, RatingValue } from './Rating.props';
@@ -0,0 +1 @@
1
+ export { default as Rating } from './Rating';
@@ -0,0 +1,6 @@
1
+ import type RoundelProps from './Roundel.props';
2
+ declare const Roundel: {
3
+ ({ variant, style, ...props }: RoundelProps): import("react/jsx-runtime").JSX.Element;
4
+ displayName: string;
5
+ };
6
+ export default Roundel;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { CloseSmallIcon, TickSmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
3
+ import { View } from 'react-native';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import { Icon } from '../Icon';
6
+ const Roundel = ({ variant = 'success', style, ...props }) => {
7
+ styles.useVariants({ variant });
8
+ const icon = variant === 'error' ? CloseSmallIcon : variant === 'success' ? TickSmallIcon : undefined;
9
+ return (_jsx(View, { ...props, style: [styles.container, style], children: icon ? _jsx(Icon, { as: icon, size: "sm" }) : null }));
10
+ };
11
+ Roundel.displayName = 'Roundel';
12
+ const styles = StyleSheet.create(theme => ({
13
+ container: {
14
+ width: theme.space[300],
15
+ height: theme.space[300],
16
+ borderRadius: theme.components.parts.roundel.borderRadius,
17
+ alignItems: 'center',
18
+ justifyContent: 'center',
19
+ overflow: 'hidden',
20
+ backgroundColor: 'transparent',
21
+ borderWidth: 0,
22
+ borderStyle: 'solid',
23
+ variants: {
24
+ variant: {
25
+ success: {
26
+ backgroundColor: theme.color.feedback.positive.surface.default,
27
+ },
28
+ error: {
29
+ backgroundColor: theme.color.feedback.danger.surface.default,
30
+ },
31
+ pending: {
32
+ borderWidth: theme.components.parts.roundel.pending.borderWidth,
33
+ borderStyle: 'dashed',
34
+ borderColor: theme.color.border.strong,
35
+ },
36
+ },
37
+ },
38
+ },
39
+ }));
40
+ export default Roundel;
@@ -0,0 +1,6 @@
1
+ import type { ViewProps } from 'react-native';
2
+ export interface RoundelProps extends Omit<ViewProps, 'children'> {
3
+ /** Visual variant for the roundel status. */
4
+ variant?: 'success' | 'pending' | 'error';
5
+ }
6
+ export default RoundelProps;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export { default as Roundel } from './Roundel';
2
+ export type { RoundelProps } from './Roundel.props';
@@ -0,0 +1 @@
1
+ export { default as Roundel } from './Roundel';
@@ -0,0 +1,22 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { PressableProps } from 'react-native';
3
+ declare const StepperButton: import("react").ForwardRefExoticComponent<{
4
+ icon: ComponentType;
5
+ disabled?: boolean;
6
+ } & Omit<PressableProps, "children"> & {
7
+ states?: {
8
+ active?: boolean;
9
+ disabled?: boolean;
10
+ };
11
+ } & Omit<import("react-native").PressableProps, "children"> & {
12
+ tabIndex?: 0 | -1 | undefined;
13
+ } & {
14
+ children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
15
+ hovered?: boolean | undefined;
16
+ pressed?: boolean | undefined;
17
+ focused?: boolean | undefined;
18
+ focusVisible?: boolean | undefined;
19
+ disabled?: boolean | undefined;
20
+ }) => import("react").ReactNode);
21
+ } & import("react").RefAttributes<unknown>>;
22
+ export default StepperButton;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createPressable } from '@gluestack-ui/pressable';
3
+ import { Pressable } from 'react-native';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import { Icon } from '../Icon';
6
+ const StepperButtonRoot = ({ icon, disabled, states, ...props }) => {
7
+ const isDisabled = disabled ?? states?.disabled ?? false;
8
+ const isActive = states?.active ?? false;
9
+ styles.useVariants({ active: isActive, disabled: isDisabled });
10
+ return (_jsx(Pressable, { ...props, accessibilityRole: "button", accessibilityState: { disabled: isDisabled, ...props.accessibilityState }, disabled: isDisabled, style: [styles.button, props.style], children: _jsx(Icon, { as: icon, size: "sm", style: styles.icon }) }));
11
+ };
12
+ const StepperButton = createPressable({ Root: StepperButtonRoot });
13
+ StepperButton.displayName = 'StepperButton';
14
+ const styles = StyleSheet.create(theme => ({
15
+ button: {
16
+ width: theme.components.iconButton.md.width,
17
+ height: theme.components.iconButton.md.height,
18
+ alignItems: 'center',
19
+ justifyContent: 'center',
20
+ borderRadius: theme.components.input.borderRadius,
21
+ borderWidth: theme.components.input.borderWidth,
22
+ borderColor: theme.color.border.strong,
23
+ backgroundColor: theme.color.surface.neutral.strong,
24
+ _web: {
25
+ _hover: {
26
+ backgroundColor: theme.color.interactive.neutral.surface.subtle.hover,
27
+ },
28
+ _active: {
29
+ backgroundColor: theme.color.interactive.neutral.surface.subtle.active,
30
+ },
31
+ '_focus-visible': {
32
+ outlineStyle: 'solid',
33
+ outlineWidth: 2,
34
+ outlineColor: theme.color.focus.primary,
35
+ outlineOffset: 2,
36
+ },
37
+ },
38
+ variants: {
39
+ active: {
40
+ true: {
41
+ backgroundColor: theme.color.interactive.neutral.surface.subtle.active,
42
+ },
43
+ },
44
+ disabled: {
45
+ true: {
46
+ opacity: theme.opacity.disabled,
47
+ },
48
+ },
49
+ },
50
+ },
51
+ icon: {
52
+ color: theme.color.icon.primary,
53
+ },
54
+ }));
55
+ export default StepperButton;
@@ -0,0 +1,6 @@
1
+ import StepperInputProps from './StepperInput.props';
2
+ declare const StepperInput: {
3
+ ({ value, defaultValue, onChangeText, onChangeValue, min, max, step, focusInputOnStepPress, validationStatus, disabled, readonly, focused, inBottomSheet, required, label, labelVariant, helperText, helperIcon, validText, invalidText, style, decrementAccessibilityLabel, incrementAccessibilityLabel, onFocus, onBlur, ref, ...props }: StepperInputProps): import("react/jsx-runtime").JSX.Element;
4
+ displayName: string;
5
+ };
6
+ export default StepperInput;
@@ -0,0 +1,196 @@
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 { FormField } from '../FormField';
7
+ import { InputComponent, InputField } from '../Input/Input';
8
+ import StepperButton from './StepperButton';
9
+ const normalizeValue = (value) => {
10
+ if (value === undefined || value === null || value === '') {
11
+ return '';
12
+ }
13
+ return `${value}`;
14
+ };
15
+ const getDecimalPlaces = (value) => {
16
+ if (value === undefined || value === null || value === '') {
17
+ return 0;
18
+ }
19
+ const normalizedValue = `${value}`;
20
+ const decimalPart = normalizedValue.split('.')[1];
21
+ return decimalPart ? decimalPart.length : 0;
22
+ };
23
+ const formatNumber = (value, precision) => {
24
+ if (precision <= 0) {
25
+ return `${Math.trunc(value)}`;
26
+ }
27
+ return value
28
+ .toFixed(precision)
29
+ .replace(/\.0+$/, '')
30
+ .replace(/(\.\d*?)0+$/, '$1');
31
+ };
32
+ const sanitizeValue = (value, allowNegative, allowDecimal) => {
33
+ const strippedValue = value.replace(allowDecimal ? /[^\d,.-]/g : allowNegative ? /[^\d-]/g : /\D/g, '');
34
+ const normalizedValue = allowDecimal ? strippedValue.replace(/,/g, '.') : strippedValue;
35
+ if (!allowNegative) {
36
+ const unsignedValue = normalizedValue.replace(/-/g, '');
37
+ if (!allowDecimal) {
38
+ return unsignedValue;
39
+ }
40
+ const [integerPart = '', ...decimalParts] = unsignedValue.split('.');
41
+ const decimalPart = decimalParts.join('');
42
+ return decimalParts.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
43
+ }
44
+ const hasLeadingMinus = normalizedValue.startsWith('-');
45
+ const unsignedValue = normalizedValue.replace(/-/g, '');
46
+ if (!allowDecimal) {
47
+ return `${hasLeadingMinus ? '-' : ''}${unsignedValue}`;
48
+ }
49
+ const [integerPart = '', ...decimalParts] = unsignedValue.split('.');
50
+ const decimalPart = decimalParts.join('');
51
+ const composedValue = decimalParts.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
52
+ return `${hasLeadingMinus ? '-' : ''}${composedValue}`;
53
+ };
54
+ const parseValue = (value) => {
55
+ if (!value || value === '-' || value === '.' || value === '-.') {
56
+ return null;
57
+ }
58
+ const parsedValue = Number(value);
59
+ return Number.isNaN(parsedValue) ? null : parsedValue;
60
+ };
61
+ const clampValue = (value, min, max) => {
62
+ let nextValue = value;
63
+ if (typeof min === 'number') {
64
+ nextValue = Math.max(min, nextValue);
65
+ }
66
+ if (typeof max === 'number') {
67
+ nextValue = Math.min(max, nextValue);
68
+ }
69
+ return nextValue;
70
+ };
71
+ 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 }) => {
72
+ const inputRef = useRef(null);
73
+ const isControlled = value !== undefined;
74
+ const [internalValue, setInternalValue] = useState(() => normalizeValue(defaultValue));
75
+ const [isInputFocused, setIsInputFocused] = useState(false);
76
+ const displayValue = isControlled ? normalizeValue(value) : internalValue;
77
+ const parsedValue = parseValue(displayValue);
78
+ const resolvedFocused = focused || isInputFocused;
79
+ const allowNegative = typeof min !== 'number' || min < 0 || (typeof max === 'number' && max < 0);
80
+ const decimalPrecision = Math.max(getDecimalPlaces(value), getDecimalPlaces(defaultValue), getDecimalPlaces(min), getDecimalPlaces(max), getDecimalPlaces(step));
81
+ const allowDecimal = decimalPrecision > 0;
82
+ const keyboardType = allowNegative || allowDecimal ? 'numeric' : 'number-pad';
83
+ const inputMode = allowDecimal ? 'decimal' : 'numeric';
84
+ useImperativeHandle(ref, () => inputRef.current, []);
85
+ useEffect(() => {
86
+ if (!isControlled && defaultValue !== undefined) {
87
+ setInternalValue(normalizeValue(defaultValue));
88
+ }
89
+ }, [defaultValue, isControlled]);
90
+ const updateValue = (nextValue) => {
91
+ if (!isControlled) {
92
+ setInternalValue(nextValue);
93
+ }
94
+ onChangeText?.(nextValue);
95
+ const nextParsedValue = parseValue(nextValue);
96
+ if (nextParsedValue !== null) {
97
+ onChangeValue?.(clampValue(nextParsedValue, min, max));
98
+ }
99
+ };
100
+ const handleChangeText = (nextText) => {
101
+ const sanitizedValue = sanitizeValue(nextText, allowNegative, allowDecimal);
102
+ if (sanitizedValue === '' ||
103
+ sanitizedValue === '-' ||
104
+ sanitizedValue === '.' ||
105
+ sanitizedValue === '-.' ||
106
+ (allowDecimal && sanitizedValue.endsWith('.'))) {
107
+ updateValue(sanitizedValue);
108
+ return;
109
+ }
110
+ const nextParsedValue = parseValue(sanitizedValue);
111
+ if (nextParsedValue === null) {
112
+ updateValue(sanitizedValue);
113
+ return;
114
+ }
115
+ const clampedText = formatNumber(clampValue(nextParsedValue, min, max), decimalPrecision);
116
+ updateValue(clampedText);
117
+ };
118
+ const handleStepPress = (direction) => {
119
+ const baseValue = parsedValue ?? (typeof min === 'number' ? min : 0);
120
+ const nextValue = clampValue(baseValue + direction * step, min, max);
121
+ const normalizedValue = formatNumber(nextValue, decimalPrecision);
122
+ updateValue(normalizedValue);
123
+ if (focusInputOnStepPress) {
124
+ inputRef.current?.focus();
125
+ }
126
+ };
127
+ const decrementDisabled = disabled || readonly || (typeof min === 'number' && parsedValue !== null && parsedValue <= min);
128
+ const incrementDisabled = disabled || readonly || (typeof max === 'number' && parsedValue !== null && parsedValue >= max);
129
+ const handleFocus = (event) => {
130
+ setIsInputFocused(true);
131
+ onFocus?.(event);
132
+ };
133
+ const handleBlur = (event) => {
134
+ setIsInputFocused(false);
135
+ onBlur?.(event);
136
+ };
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
+ 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
+ // @ts-expect-error - ref forwarding issue mirrors the base Input component
167
+ , {
168
+ // @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: {
170
+ ...(props.accessibilityState ?? {}),
171
+ disabled: disabled || readonly,
172
+ }, "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) })] }) }));
173
+ };
174
+ StepperInput.displayName = 'StepperInput';
175
+ const styles = StyleSheet.create(theme => ({
176
+ root: {
177
+ width: '100%',
178
+ maxWidth: theme.components.input.maxWidth,
179
+ },
180
+ controls: {
181
+ flexDirection: 'row',
182
+ alignItems: 'center',
183
+ gap: theme.components.input.stepper.gap,
184
+ },
185
+ inputRoot: {
186
+ width: 80,
187
+ minWidth: 80,
188
+ paddingHorizontal: 0,
189
+ justifyContent: 'center',
190
+ },
191
+ inputField: {
192
+ textAlign: 'center',
193
+ paddingHorizontal: 0,
194
+ },
195
+ }));
196
+ 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';