@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,95 @@
1
+ import { Meta, StoryObj } from '@storybook/react-native';
2
+ import type { ComponentProps } from 'react';
3
+ import { useEffect, useState } from 'react';
4
+ import { Rating } from '.';
5
+ import { VariantTitle } from '../../../docs/components';
6
+ import { Box } from '../Box';
7
+
8
+ type RatingStoryProps = ComponentProps<typeof Rating>;
9
+
10
+ const meta = {
11
+ title: 'Stories / Rating',
12
+ component: Rating,
13
+ parameters: {
14
+ layout: 'centered',
15
+ },
16
+ argTypes: {
17
+ value: {
18
+ control: { type: 'number', min: 0, max: 5, step: 1 },
19
+ },
20
+ defaultValue: {
21
+ control: { type: 'number', min: 0, max: 5, step: 1 },
22
+ },
23
+ labels: {
24
+ control: 'object',
25
+ },
26
+ hideLabel: {
27
+ control: 'boolean',
28
+ },
29
+ disabled: {
30
+ control: 'boolean',
31
+ },
32
+ },
33
+ args: {
34
+ value: 3,
35
+ hideLabel: false,
36
+ disabled: false,
37
+ },
38
+ } satisfies Meta<typeof Rating>;
39
+
40
+ export default meta;
41
+
42
+ type Story = StoryObj<typeof meta>;
43
+
44
+ export const Playground: Story = {
45
+ render: ({ value: initialValue, onChange, ...args }: RatingStoryProps) => {
46
+ const [value, setValue] = useState(initialValue ?? 0);
47
+
48
+ useEffect(() => {
49
+ setValue(initialValue ?? 0);
50
+ }, [initialValue]);
51
+
52
+ return (
53
+ <Rating
54
+ {...args}
55
+ value={value}
56
+ onChange={nextValue => {
57
+ setValue(nextValue);
58
+ onChange?.(nextValue);
59
+ }}
60
+ />
61
+ );
62
+ },
63
+ };
64
+
65
+ export const Variants: Story = {
66
+ parameters: {
67
+ controls: { include: [] },
68
+ },
69
+ render: () => (
70
+ <Box gap="300">
71
+ <VariantTitle title="Default">
72
+ <Rating value={3} />
73
+ </VariantTitle>
74
+ <VariantTitle title="Custom Labels">
75
+ <Rating
76
+ value={4}
77
+ labels={{
78
+ 0: 'Not rated',
79
+ 1: 'Terrible',
80
+ 2: 'Poor',
81
+ 3: 'OK',
82
+ 4: 'Great',
83
+ 5: 'Outstanding',
84
+ }}
85
+ />
86
+ </VariantTitle>
87
+ <VariantTitle title="Hidden Label">
88
+ <Rating value={2} hideLabel />
89
+ </VariantTitle>
90
+ <VariantTitle title="Disabled">
91
+ <Rating value={5} disabled />
92
+ </VariantTitle>
93
+ </Box>
94
+ ),
95
+ };
@@ -0,0 +1,140 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { StyleSheet } from 'react-native-unistyles';
4
+ import { BodyText } from '../BodyText';
5
+ import type RatingProps from './Rating.props';
6
+ import type { RatingLabels, RatingValue } from './Rating.props';
7
+ import RatingStarEmpty from './RatingStarEmpty';
8
+ import RatingStarFilled from './RatingStarFilled';
9
+
10
+ const MAX_RATING: RatingValue = 5;
11
+ const STAR_WIDTH = 32;
12
+ const STAR_HEIGHT = 30;
13
+ const STAR_CONTAINER_SIZE = 40;
14
+
15
+ const DEFAULT_LABELS: Record<RatingValue, string> = {
16
+ 0: 'Select a rating',
17
+ 1: 'Awful',
18
+ 2: 'Bad',
19
+ 3: 'Okay',
20
+ 4: 'Good',
21
+ 5: 'Great!',
22
+ };
23
+
24
+ const clampRating = (value: number) =>
25
+ Math.min(MAX_RATING, Math.max(0, Math.round(value))) as RatingValue;
26
+
27
+ const Rating = ({
28
+ value,
29
+ defaultValue = 0,
30
+ onChange,
31
+ disabled = false,
32
+ labels,
33
+ hideLabel = false,
34
+ style,
35
+ accessibilityLabel,
36
+ ...props
37
+ }: RatingProps) => {
38
+ const isControlled = value !== undefined;
39
+ const [internalValue, setInternalValue] = useState<RatingValue>(clampRating(defaultValue));
40
+
41
+ useEffect(() => {
42
+ if (!isControlled) {
43
+ setInternalValue(clampRating(defaultValue));
44
+ }
45
+ }, [defaultValue, isControlled]);
46
+
47
+ const resolvedLabels = useMemo<RatingLabels>(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);
48
+
49
+ const resolvedValue = clampRating(isControlled ? value : internalValue);
50
+ const currentLabel = resolvedLabels[resolvedValue] ?? DEFAULT_LABELS[resolvedValue];
51
+ const labelColor = resolvedValue === 0 ? 'secondary' : 'primary';
52
+
53
+ const handlePress = useCallback(
54
+ (nextValue: RatingValue) => {
55
+ if (disabled) return;
56
+
57
+ if (!isControlled) {
58
+ setInternalValue(nextValue);
59
+ }
60
+
61
+ onChange?.(nextValue);
62
+ },
63
+ [disabled, isControlled, onChange]
64
+ );
65
+
66
+ styles.useVariants({ disabled });
67
+
68
+ return (
69
+ <View
70
+ {...props}
71
+ accessibilityRole="radiogroup"
72
+ accessibilityState={{ disabled }}
73
+ accessibilityLabel={accessibilityLabel ?? currentLabel}
74
+ style={[styles.container, style]}
75
+ >
76
+ <View style={styles.stars}>
77
+ {([1, 2, 3, 4, 5] as RatingValue[]).map(starValue => {
78
+ const isFilled = starValue <= resolvedValue;
79
+ const starLabel = resolvedLabels[starValue] ?? DEFAULT_LABELS[starValue];
80
+
81
+ return (
82
+ <Pressable
83
+ key={starValue}
84
+ accessibilityRole="radio"
85
+ accessibilityState={{ selected: resolvedValue === starValue, disabled }}
86
+ accessibilityLabel={`Rate ${starLabel}`}
87
+ disabled={disabled}
88
+ hitSlop={8}
89
+ onPress={() => handlePress(starValue)}
90
+ style={styles.star}
91
+ >
92
+ {isFilled ? (
93
+ <RatingStarFilled width={STAR_WIDTH} height={STAR_HEIGHT} />
94
+ ) : (
95
+ <RatingStarEmpty width={STAR_WIDTH} height={STAR_HEIGHT} />
96
+ )}
97
+ </Pressable>
98
+ );
99
+ })}
100
+ </View>
101
+ {!hideLabel ? (
102
+ <BodyText size="md" color={labelColor} style={styles.label}>
103
+ {currentLabel}
104
+ </BodyText>
105
+ ) : null}
106
+ </View>
107
+ );
108
+ };
109
+
110
+ Rating.displayName = 'Rating';
111
+
112
+ const styles = StyleSheet.create(theme => ({
113
+ container: {
114
+ alignItems: 'center',
115
+ gap: theme.components.rating.gap,
116
+ variants: {
117
+ disabled: {
118
+ true: {
119
+ opacity: theme.opacity.disabled,
120
+ },
121
+ },
122
+ },
123
+ },
124
+ stars: {
125
+ flexDirection: 'row',
126
+ gap: theme.components.rating.gap,
127
+ },
128
+ star: {
129
+ width: STAR_CONTAINER_SIZE,
130
+ height: STAR_CONTAINER_SIZE,
131
+ alignItems: 'center',
132
+ justifyContent: 'center',
133
+ padding: theme.components.rating.borderWidth,
134
+ },
135
+ label: {
136
+ textAlign: 'center',
137
+ },
138
+ }));
139
+
140
+ export default Rating;
@@ -0,0 +1,22 @@
1
+ import { Path, Svg } from 'react-native-svg';
2
+ import { useTheme } from '../../hooks';
3
+
4
+ type RatingStarProps = {
5
+ width?: number;
6
+ height?: number;
7
+ };
8
+
9
+ const STAR_PATH =
10
+ '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';
11
+
12
+ const RatingStarEmpty = ({ width = 32, height = 30 }: RatingStarProps) => {
13
+ const theme = useTheme();
14
+
15
+ return (
16
+ <Svg width={width} height={height} viewBox="0 0 32 30" preserveAspectRatio="none">
17
+ <Path d={STAR_PATH} strokeWidth={2} fill="none" stroke={theme.color.border.subtle} />
18
+ </Svg>
19
+ );
20
+ };
21
+
22
+ export default RatingStarEmpty;
@@ -0,0 +1,27 @@
1
+ import { Path, Svg } from 'react-native-svg';
2
+ import { useTheme } from '../../hooks';
3
+
4
+ type RatingStarProps = {
5
+ width?: number;
6
+ height?: number;
7
+ };
8
+
9
+ const STAR_PATH =
10
+ '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';
11
+
12
+ const RatingStarFilled = ({ width = 32, height = 30 }: RatingStarProps) => {
13
+ const theme = useTheme();
14
+
15
+ return (
16
+ <Svg width={width} height={height} viewBox="0 0 32 30" preserveAspectRatio="none">
17
+ <Path
18
+ d={STAR_PATH}
19
+ strokeWidth={2}
20
+ fill={theme.color.surface.highlight.default}
21
+ stroke={theme.color.border.strong}
22
+ />
23
+ </Svg>
24
+ );
25
+ };
26
+
27
+ 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,48 @@
1
+ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks';
2
+ import { Roundel } from '../../';
3
+ import { BackToTopButton, UsageWrap, ViewFigmaButton } from '../../../docs/components';
4
+ import * as Stories from './Roundel.stories';
5
+
6
+ <Meta title="Components / Roundel" />
7
+
8
+ <BackToTopButton />
9
+
10
+ <ViewFigmaButton url="https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=6414-8697&t=aFS22MoPV7Y08jdq-4" />
11
+
12
+ # Roundel
13
+
14
+ The `Roundel` component is a compact status indicator with success, pending, and error variants.
15
+
16
+ - [Playground](#playground)
17
+ - [Usage](#usage)
18
+ - [Props](#props)
19
+ - [Examples](#examples)
20
+
21
+ ## Playground
22
+
23
+ <Canvas of={Stories.Playground} />
24
+
25
+ <Controls of={Stories.Playground} />
26
+
27
+ ## Usage
28
+
29
+ <UsageWrap>
30
+ <Roundel variant="success" />
31
+ </UsageWrap>
32
+
33
+ ```tsx
34
+ import { Roundel } from '@utilitywarehouse/hearth-react-native';
35
+
36
+ const MyComponent = () => <Roundel variant="success" />;
37
+ ```
38
+
39
+ ## Props
40
+
41
+ | Prop | Type | Default | Description |
42
+ | --------- | ----------------------------------- | ----------- | ---------------------------------------- |
43
+ | `variant` | `'success' \| 'pending' \| 'error'` | `'success'` | Sets the status styling for the roundel. |
44
+ | `...View` | React Native `ViewProps` | - | Any additional View props. |
45
+
46
+ ## Examples
47
+
48
+ <Canvas of={Stories.Variants} />
@@ -0,0 +1,17 @@
1
+ import figma from '@figma/code-connect';
2
+ import { Roundel } from '..';
3
+
4
+ figma.connect(
5
+ Roundel,
6
+ 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=6414%3A8697&m=dev',
7
+ {
8
+ props: {
9
+ variant: figma.enum('Variant', {
10
+ Success: 'success',
11
+ Pending: 'pending',
12
+ Error: 'error',
13
+ }),
14
+ },
15
+ example: props => <Roundel variant={props.variant} />,
16
+ }
17
+ );
@@ -0,0 +1,8 @@
1
+ import type { ViewProps } from 'react-native';
2
+
3
+ export interface RoundelProps extends Omit<ViewProps, 'children'> {
4
+ /** Visual variant for the roundel status. */
5
+ variant?: 'success' | 'pending' | 'error';
6
+ }
7
+
8
+ export default RoundelProps;
@@ -0,0 +1,49 @@
1
+ import { Meta, StoryObj } from '@storybook/react-native';
2
+ import { ComponentProps } from 'react';
3
+ import { VariantTitle } from '../../../docs/components';
4
+ import { Flex } from '../Flex';
5
+ import { Roundel } from './';
6
+
7
+ const meta: Meta<typeof Roundel> = {
8
+ title: 'Stories / Roundel',
9
+ component: Roundel,
10
+ parameters: {
11
+ layout: 'centered',
12
+ },
13
+ argTypes: {
14
+ variant: {
15
+ control: 'radio',
16
+ options: ['success', 'pending', 'error'],
17
+ },
18
+ },
19
+ args: {
20
+ variant: 'success',
21
+ },
22
+ };
23
+
24
+ export default meta;
25
+
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ export const Playground: Story = {
29
+ render: (args: ComponentProps<typeof Roundel>) => <Roundel {...args} />,
30
+ };
31
+
32
+ export const Variants: Story = {
33
+ parameters: {
34
+ controls: { exclude: ['variant'] },
35
+ },
36
+ render: () => (
37
+ <Flex direction="column" spacing="md" alignItems="center">
38
+ <VariantTitle title="Success">
39
+ <Roundel variant="success" />
40
+ </VariantTitle>
41
+ <VariantTitle title="Pending">
42
+ <Roundel variant="pending" />
43
+ </VariantTitle>
44
+ <VariantTitle title="Error">
45
+ <Roundel variant="error" />
46
+ </VariantTitle>
47
+ </Flex>
48
+ ),
49
+ };
@@ -0,0 +1,51 @@
1
+ import { CloseSmallIcon, TickSmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
2
+ import { View } from 'react-native';
3
+ import { StyleSheet } from 'react-native-unistyles';
4
+ import { Icon } from '../Icon';
5
+ import type RoundelProps from './Roundel.props';
6
+
7
+ const Roundel = ({ variant = 'success', style, ...props }: RoundelProps) => {
8
+ styles.useVariants({ variant });
9
+
10
+ const icon =
11
+ variant === 'error' ? CloseSmallIcon : variant === 'success' ? TickSmallIcon : undefined;
12
+
13
+ return (
14
+ <View {...props} style={[styles.container, style]}>
15
+ {icon ? <Icon as={icon} size="sm" /> : null}
16
+ </View>
17
+ );
18
+ };
19
+
20
+ Roundel.displayName = 'Roundel';
21
+
22
+ const styles = StyleSheet.create(theme => ({
23
+ container: {
24
+ width: theme.space[300],
25
+ height: theme.space[300],
26
+ borderRadius: theme.components.parts.roundel.borderRadius,
27
+ alignItems: 'center',
28
+ justifyContent: 'center',
29
+ overflow: 'hidden',
30
+ backgroundColor: 'transparent',
31
+ borderWidth: 0,
32
+ borderStyle: 'solid',
33
+ variants: {
34
+ variant: {
35
+ success: {
36
+ backgroundColor: theme.color.feedback.positive.surface.default,
37
+ },
38
+ error: {
39
+ backgroundColor: theme.color.feedback.danger.surface.default,
40
+ },
41
+ pending: {
42
+ borderWidth: theme.components.parts.roundel.pending.borderWidth,
43
+ borderStyle: 'dashed',
44
+ borderColor: theme.color.border.strong,
45
+ },
46
+ },
47
+ },
48
+ },
49
+ }));
50
+
51
+ export default Roundel;
@@ -0,0 +1,2 @@
1
+ export { default as Roundel } from './Roundel';
2
+ export type { RoundelProps } from './Roundel.props';
@@ -0,0 +1,83 @@
1
+ import { createPressable } from '@gluestack-ui/pressable';
2
+ import type { ComponentType } from 'react';
3
+ import type { PressableProps, ViewStyle } from 'react-native';
4
+ import { Pressable } from 'react-native';
5
+ import { StyleSheet } from 'react-native-unistyles';
6
+ import { Icon } from '../Icon';
7
+
8
+ type StepperButtonProps = {
9
+ icon: ComponentType;
10
+ disabled?: boolean;
11
+ } & Omit<PressableProps, 'children'>;
12
+
13
+ const StepperButtonRoot = ({
14
+ icon,
15
+ disabled,
16
+ states,
17
+ ...props
18
+ }: StepperButtonProps & { states?: { active?: boolean; disabled?: boolean } }) => {
19
+ const isDisabled = disabled ?? states?.disabled ?? false;
20
+ const isActive = states?.active ?? false;
21
+
22
+ styles.useVariants({ active: isActive, disabled: isDisabled });
23
+
24
+ return (
25
+ <Pressable
26
+ {...props}
27
+ accessibilityRole="button"
28
+ accessibilityState={{ disabled: isDisabled, ...props.accessibilityState }}
29
+ disabled={isDisabled}
30
+ style={[styles.button, props.style as ViewStyle]}
31
+ >
32
+ <Icon as={icon} size="sm" style={styles.icon} />
33
+ </Pressable>
34
+ );
35
+ };
36
+
37
+ const StepperButton = createPressable({ Root: StepperButtonRoot });
38
+
39
+ StepperButton.displayName = 'StepperButton';
40
+
41
+ const styles = StyleSheet.create(theme => ({
42
+ button: {
43
+ width: theme.components.iconButton.md.width,
44
+ height: theme.components.iconButton.md.height,
45
+ alignItems: 'center',
46
+ justifyContent: 'center',
47
+ borderRadius: theme.components.input.borderRadius,
48
+ borderWidth: theme.components.input.borderWidth,
49
+ borderColor: theme.color.border.strong,
50
+ backgroundColor: theme.color.surface.neutral.strong,
51
+ _web: {
52
+ _hover: {
53
+ backgroundColor: theme.color.interactive.neutral.surface.subtle.hover,
54
+ },
55
+ _active: {
56
+ backgroundColor: theme.color.interactive.neutral.surface.subtle.active,
57
+ },
58
+ '_focus-visible': {
59
+ outlineStyle: 'solid',
60
+ outlineWidth: 2,
61
+ outlineColor: theme.color.focus.primary,
62
+ outlineOffset: 2,
63
+ },
64
+ },
65
+ variants: {
66
+ active: {
67
+ true: {
68
+ backgroundColor: theme.color.interactive.neutral.surface.subtle.active,
69
+ },
70
+ },
71
+ disabled: {
72
+ true: {
73
+ opacity: theme.opacity.disabled,
74
+ },
75
+ },
76
+ },
77
+ },
78
+ icon: {
79
+ color: theme.color.icon.primary,
80
+ },
81
+ }));
82
+
83
+ export default StepperButton;
@@ -0,0 +1,121 @@
1
+ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks';
2
+ import { StepperInput } from '../../';
3
+ import { BackToTopButton, UsageWrap, ViewFigmaButton } from '../../../docs/components';
4
+ import * as Stories from './StepperInput.stories';
5
+
6
+ <Meta title="Forms / Stepper Input" />
7
+
8
+ <BackToTopButton />
9
+
10
+ <ViewFigmaButton url="https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=10612-1860&t=aFS22MoPV7Y08jdq-4" />
11
+
12
+ # Stepper Input
13
+
14
+ The Stepper Input combines a numeric text input with decrement and increment controls. Use it when the value should stay within a defined range but still needs direct text entry.
15
+
16
+ - [Playground](#playground)
17
+ - [Usage](#usage)
18
+ - [Props](#props)
19
+ - [Examples](#examples)
20
+ - [Accessibility](#accessibility)
21
+
22
+ ## Playground
23
+
24
+ <Canvas of={Stories.Playground} />
25
+
26
+ <Controls of={Stories.Playground} />
27
+
28
+ ## Usage
29
+
30
+ <UsageWrap>
31
+ <StepperInput
32
+ label="Guests"
33
+ helperText="Choose between 1 and 10"
34
+ value="2"
35
+ onChangeText={() => undefined}
36
+ />
37
+ </UsageWrap>
38
+
39
+ ```tsx
40
+ import { useState } from 'react';
41
+ import { StepperInput } from '@utilitywarehouse/hearth-react-native';
42
+
43
+ const MyComponent = () => {
44
+ const [value, setValue] = useState('2');
45
+
46
+ return (
47
+ <StepperInput
48
+ label="Guests"
49
+ helperText="Choose between 1 and 10"
50
+ min={1}
51
+ max={10}
52
+ value={value}
53
+ onChangeText={setValue}
54
+ />
55
+ );
56
+ };
57
+ ```
58
+
59
+ ## Props
60
+
61
+ The Stepper Input inherits React Native `TextInput` props except `children`, `value`, `defaultValue`, `onChangeText`, `editable`, and `keyboardType`. It also supports the following props:
62
+
63
+ | Prop | Type | Default | Description |
64
+ | ----------------------------- | ----------------------------------- | ------------------ | ------------------------------------------------------------------------------------------- |
65
+ | `value` | `string \| number` | - | Controlled value displayed in the input. |
66
+ | `defaultValue` | `number` | - | Initial value for uncontrolled usage. |
67
+ | `onChangeText` | `(text: string) => void` | - | Called whenever the displayed string changes. |
68
+ | `onChangeValue` | `(value: number) => void` | - | Called whenever the current text can be parsed as a number. |
69
+ | `min` | `number` | - | Minimum allowed numeric value. |
70
+ | `max` | `number` | - | Maximum allowed numeric value. |
71
+ | `step` | `number` | `1` | Amount added or removed when the side buttons are pressed. Fractional values are supported. |
72
+ | `focusInputOnStepPress` | `boolean` | `false` | When `true`, pressing a step button moves focus to the text input after updating the value. |
73
+ | `validationStatus` | `'initial' \| 'valid' \| 'invalid'` | `'initial'` | Validation styling for the center input. |
74
+ | `label` | `string` | - | Label shown above the stepper. |
75
+ | `labelVariant` | `'heading' \| 'body'` | `'body'` | Typography variant used for the label. |
76
+ | `helperText` | `string` | - | Helper text shown below the label. |
77
+ | `helperIcon` | `ComponentType` | - | Optional helper icon shown next to helper text. |
78
+ | `invalidText` | `string` | - | Validation text shown when `validationStatus="invalid"`. |
79
+ | `validText` | `string` | - | Validation text shown when `validationStatus="valid"`. |
80
+ | `disabled` | `boolean` | `false` | Disables the input and both stepper buttons. |
81
+ | `readonly` | `boolean` | `false` | Prevents editing and disables both buttons. |
82
+ | `focused` | `boolean` | `false` | Forces the focused visual state on the center input. |
83
+ | `decrementAccessibilityLabel` | `string` | `'Decrease value'` | Accessibility label for the decrement button. |
84
+ | `incrementAccessibilityLabel` | `string` | `'Increase value'` | Accessibility label for the increment button. |
85
+
86
+ ## Examples
87
+
88
+ ### States
89
+
90
+ <Canvas of={Stories.States} />
91
+
92
+ ### Bounds
93
+
94
+ <Canvas of={Stories.Bounds} />
95
+
96
+ ### Fractional Values
97
+
98
+ `StepperInput` supports fractional steps and negative ranges. When `step`, `min`, `max`, or the current value uses decimals, the input switches to a numeric keyboard path that preserves decimal editing.
99
+
100
+ <Canvas of={Stories.DecimalStep} />
101
+
102
+ ```tsx
103
+ <StepperInput label="Weight" min={-2} max={2} step={0.5} value={value} onChangeText={setValue} />
104
+ ```
105
+
106
+ ### Opt-In Focus On Step Press
107
+
108
+ By default, the increment and decrement buttons update the value without moving focus into the text field. Enable `focusInputOnStepPress` when keyboard-first flows need the caret to stay in the input after a button press.
109
+
110
+ <Canvas of={Stories.FocusOnStepPress} />
111
+
112
+ ```tsx
113
+ <StepperInput label="Guests" value={value} onChangeText={setValue} focusInputOnStepPress />
114
+ ```
115
+
116
+ ## Accessibility
117
+
118
+ - The center field remains a standard text input, so screen readers can edit the value directly.
119
+ - The side controls expose button semantics with separate accessibility labels for increment and decrement.
120
+ - Button presses do not move focus into the text field unless `focusInputOnStepPress` is enabled.
121
+ - Validation, helper text, and disabled state follow the same `FormField` treatment as other input components.