@utilitywarehouse/hearth-react-native 0.9.0 → 0.11.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 (106) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/build/components/Avatar/Avatar.d.ts +6 -0
  5. package/build/components/Avatar/Avatar.js +80 -0
  6. package/build/components/Avatar/Avatar.props.d.ts +28 -0
  7. package/build/components/Avatar/Avatar.props.js +1 -0
  8. package/build/components/Avatar/index.d.ts +2 -0
  9. package/build/components/Avatar/index.js +1 -0
  10. package/build/components/DateInput/DateInput.d.ts +6 -0
  11. package/build/components/DateInput/DateInput.js +19 -0
  12. package/build/components/DateInput/DateInput.props.d.ts +79 -0
  13. package/build/components/DateInput/DateInput.props.js +1 -0
  14. package/build/components/DateInput/DateInputSegment.d.ts +20 -0
  15. package/build/components/DateInput/DateInputSegment.js +31 -0
  16. package/build/components/DateInput/index.d.ts +2 -0
  17. package/build/components/DateInput/index.js +1 -0
  18. package/build/components/PillGroup/Pill.d.ts +16 -0
  19. package/build/components/PillGroup/Pill.js +94 -0
  20. package/build/components/PillGroup/Pill.props.d.ts +10 -0
  21. package/build/components/PillGroup/Pill.props.js +1 -0
  22. package/build/components/PillGroup/PillGroup.context.d.ts +6 -0
  23. package/build/components/PillGroup/PillGroup.context.js +5 -0
  24. package/build/components/PillGroup/PillGroup.d.ts +5 -0
  25. package/build/components/PillGroup/PillGroup.js +34 -0
  26. package/build/components/PillGroup/PillGroup.props.d.ts +15 -0
  27. package/build/components/PillGroup/PillGroup.props.js +1 -0
  28. package/build/components/PillGroup/index.d.ts +4 -0
  29. package/build/components/PillGroup/index.js +2 -0
  30. package/build/components/Select/Select.js +2 -1
  31. package/build/components/Toast/Toast.context.d.ts +9 -0
  32. package/build/components/Toast/Toast.context.js +90 -0
  33. package/build/components/Toast/Toast.props.d.ts +29 -0
  34. package/build/components/Toast/Toast.props.js +1 -0
  35. package/build/components/Toast/ToastItem.d.ts +10 -0
  36. package/build/components/Toast/ToastItem.js +129 -0
  37. package/build/components/Toast/index.d.ts +3 -0
  38. package/build/components/Toast/index.js +2 -0
  39. package/build/components/index.d.ts +4 -0
  40. package/build/components/index.js +4 -0
  41. package/build/tokens/components/dark/checkbox.d.ts +3 -0
  42. package/build/tokens/components/dark/checkbox.js +3 -0
  43. package/build/tokens/components/dark/input.d.ts +6 -0
  44. package/build/tokens/components/dark/input.js +6 -0
  45. package/build/tokens/components/dark/radio.d.ts +3 -0
  46. package/build/tokens/components/dark/radio.js +3 -0
  47. package/build/tokens/components/dark/table.d.ts +2 -0
  48. package/build/tokens/components/dark/table.js +2 -0
  49. package/build/tokens/components/dark/toast.d.ts +6 -2
  50. package/build/tokens/components/dark/toast.js +6 -2
  51. package/build/tokens/components/light/checkbox.d.ts +3 -0
  52. package/build/tokens/components/light/checkbox.js +3 -0
  53. package/build/tokens/components/light/input.d.ts +6 -0
  54. package/build/tokens/components/light/input.js +6 -0
  55. package/build/tokens/components/light/radio.d.ts +3 -0
  56. package/build/tokens/components/light/radio.js +3 -0
  57. package/build/tokens/components/light/table.d.ts +2 -0
  58. package/build/tokens/components/light/table.js +2 -0
  59. package/build/tokens/components/light/toast.d.ts +6 -2
  60. package/build/tokens/components/light/toast.js +6 -2
  61. package/build/utils/getInitials.d.ts +1 -0
  62. package/build/utils/getInitials.js +8 -0
  63. package/build/utils/index.d.ts +1 -0
  64. package/build/utils/index.js +1 -0
  65. package/docs/assets/toast-ios.MP4 +0 -0
  66. package/docs/components/AllComponents.web.tsx +43 -0
  67. package/package.json +3 -3
  68. package/src/components/Avatar/Avatar.docs.mdx +105 -0
  69. package/src/components/Avatar/Avatar.props.ts +31 -0
  70. package/src/components/Avatar/Avatar.stories.tsx +77 -0
  71. package/src/components/Avatar/Avatar.tsx +136 -0
  72. package/src/components/Avatar/index.ts +2 -0
  73. package/src/components/DateInput/DateInput.docs.mdx +163 -0
  74. package/src/components/DateInput/DateInput.props.ts +80 -0
  75. package/src/components/DateInput/DateInput.stories.tsx +269 -0
  76. package/src/components/DateInput/DateInput.tsx +117 -0
  77. package/src/components/DateInput/DateInputSegment.tsx +83 -0
  78. package/src/components/DateInput/index.ts +2 -0
  79. package/src/components/PillGroup/Pill.props.ts +13 -0
  80. package/src/components/PillGroup/Pill.tsx +120 -0
  81. package/src/components/PillGroup/PillGroup.context.tsx +12 -0
  82. package/src/components/PillGroup/PillGroup.docs.mdx +96 -0
  83. package/src/components/PillGroup/PillGroup.props.ts +22 -0
  84. package/src/components/PillGroup/PillGroup.stories.tsx +159 -0
  85. package/src/components/PillGroup/PillGroup.tsx +66 -0
  86. package/src/components/PillGroup/index.ts +4 -0
  87. package/src/components/Select/Select.tsx +2 -0
  88. package/src/components/Toast/Toast.context.tsx +118 -0
  89. package/src/components/Toast/Toast.docs.mdx +164 -0
  90. package/src/components/Toast/Toast.props.ts +33 -0
  91. package/src/components/Toast/Toast.stories.tsx +356 -0
  92. package/src/components/Toast/ToastItem.tsx +200 -0
  93. package/src/components/Toast/index.ts +3 -0
  94. package/src/components/index.ts +4 -0
  95. package/src/tokens/components/dark/checkbox.ts +3 -0
  96. package/src/tokens/components/dark/input.ts +6 -0
  97. package/src/tokens/components/dark/radio.ts +3 -0
  98. package/src/tokens/components/dark/table.ts +2 -0
  99. package/src/tokens/components/dark/toast.ts +6 -2
  100. package/src/tokens/components/light/checkbox.ts +3 -0
  101. package/src/tokens/components/light/input.ts +6 -0
  102. package/src/tokens/components/light/radio.ts +3 -0
  103. package/src/tokens/components/light/table.ts +2 -0
  104. package/src/tokens/components/light/toast.ts +6 -2
  105. package/src/utils/getInitials.ts +7 -0
  106. package/src/utils/index.ts +1 -0
@@ -0,0 +1,96 @@
1
+ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks';
2
+ import { BackToTopButton, ViewFigmaButton } from '../../../docs/components';
3
+ import * as PillGroupStories from './PillGroup.stories';
4
+
5
+ <Meta title="Components / Pill Group" />
6
+
7
+ <ViewFigmaButton url="https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens" />
8
+
9
+ <BackToTopButton />
10
+
11
+ # Pill Group
12
+
13
+ A container component that groups multiple `Pill` components together for filtering and categorization. It provides layout control with optional wrapping behavior and manages selection state.
14
+
15
+ - [Playground](#playground)
16
+ - [Usage](#usage)
17
+ - [Props](#props)
18
+ - [Examples](#examples)
19
+
20
+ ## Playground
21
+
22
+ <Canvas of={PillGroupStories.Playground} />
23
+
24
+ <Controls of={PillGroupStories.Playground} />
25
+
26
+ ## Usage
27
+
28
+ The `PillGroup` component is a controlled component that manages the selection state of multiple `Pill` components. It supports both single and multi-select modes.
29
+
30
+ ### Basic Usage
31
+
32
+ ```tsx
33
+ import { Pill, PillGroup } from '@hearth/react-native';
34
+ import { useState } from 'react';
35
+
36
+ const [selectedTags, setSelectedTags] = useState(['ui']);
37
+
38
+ <PillGroup value={selectedTags} onChange={setSelectedTags}>
39
+ <Pill value="ui" label="UI" />
40
+ <Pill value="backend" label="Backend" icon={ServerMediumIcon} />
41
+ <Pill value="devops" label="DevOps" />
42
+ </PillGroup>
43
+ ```
44
+
45
+ ## Props
46
+
47
+ ### PillGroup Props
48
+
49
+ | Prop | Type | Default | Description |
50
+ |------|------|---------|-------------|
51
+ | `value` | `string \| string[]` | Required | Controlled selected value(s). Single string for single-select, array for multi-select |
52
+ | `multiple` | `boolean` | `false` | Enable multi-select mode |
53
+ | `wrap` | `boolean` | `true` | Whether pills should wrap to multiple lines when they overflow |
54
+ | `onChange` | `(value: string \| string[]) => void` | - | Handle selection changes. Returns single string in single-select mode, array in multi-select mode |
55
+ | `children` | `ReactNode` | Required | `Pill` components to group together |
56
+ | ...rest | `ViewProps` | - | All standard View props are supported |
57
+
58
+ ### Pill Props
59
+
60
+ | Prop | Type | Default | Description |
61
+ |------|------|---------|-------------|
62
+ | `value` | `string` | Required | Value returned when selected |
63
+ | `label` | `string` | Required | The text content of the pill |
64
+ | `icon` | `ComponentType<any>` | - | Optional icon component to display before the label |
65
+ | ...rest | `PressableProps` | - | All standard Pressable props are supported |
66
+
67
+
68
+ ### Multi-Select Mode
69
+
70
+ ```tsx
71
+ const [tags, setTags] = useState(['ui', 'backend']);
72
+
73
+ <PillGroup multiple value={tags} onChange={setTags}>
74
+ <Pill value="ui" label="UI" />
75
+ <Pill value="backend" label="Backend" icon={ServerMediumIcon} />
76
+ <Pill value="devops" label="DevOps" />
77
+ </PillGroup>
78
+ ```
79
+
80
+ ## Examples
81
+
82
+ ### Wrap Behavior
83
+
84
+ Compare how pills behave with and without wrapping enabled.
85
+
86
+ <Canvas of={PillGroupStories.WrapBehavior} />
87
+
88
+ ### Multi-Select Example
89
+
90
+ Select multiple options
91
+
92
+ <Canvas of={PillGroupStories.Multiple} />
93
+
94
+ ### All States of Pill
95
+
96
+ <Canvas of={PillGroupStories.PillStates} />
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { ScrollViewProps, ViewStyle } from 'react-native';
3
+
4
+ export interface PillGroupProps
5
+ extends Omit<ScrollViewProps, 'horizontal' | 'contentContainerStyle' | 'showsHorizontalScrollIndicator'> {
6
+ /** Controlled selected value(s) */
7
+ value: string | string[];
8
+
9
+ /** Multi-select mode. Default = false */
10
+ multiple?: boolean;
11
+
12
+ /** Allow pills to wrap lines. Default = true */
13
+ wrap?: boolean;
14
+
15
+ /** Handle selection changes */
16
+ onChange?: (value: string | string[]) => void;
17
+
18
+ /** Children must be <Pill> elements */
19
+ children: React.ReactNode;
20
+
21
+ style?: ViewStyle | ViewStyle[];
22
+ }
@@ -0,0 +1,159 @@
1
+ import React, { useState } from 'react';
2
+ import { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { HeartMediumIcon } from '@utilitywarehouse/hearth-react-native-icons';
4
+ import { PillGroup } from '.';
5
+ import { Pill } from './Pill';
6
+ import { VariantTitle } from '../../../docs/components';
7
+ import { BodyText } from '../BodyText';
8
+ import { Flex } from '../Flex';
9
+ import { Heading } from '../Heading';
10
+
11
+ const meta = {
12
+ title: 'Stories / PillGroup',
13
+ component: PillGroup,
14
+ parameters: {
15
+ layout: 'centered',
16
+ },
17
+ } satisfies Meta<typeof PillGroup>;
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ export const Playground: Story = {
23
+ args: {
24
+ wrap: false,
25
+ multiple: false,
26
+ value: '2',
27
+ children: <></>,
28
+ },
29
+ parameters: {
30
+ controls: { exclude: ['value', 'onChange', 'style', 'children'] },
31
+ },
32
+ render: props => {
33
+ const [value, setValue] = useState<string | string[]>(props.value || '');
34
+
35
+ return (
36
+ <PillGroup {...props} value={value} onChange={setValue}>
37
+ <Pill value="1" label="All" />
38
+ <Pill value="2" label="Energy" />
39
+ <Pill value="3" label="Broadband" />
40
+ <Pill value="4" label="Mobile" />
41
+ </PillGroup>
42
+ );
43
+ },
44
+ };
45
+
46
+ export const PillStates: Story = {
47
+ args: {
48
+ value: '',
49
+ children: <></>,
50
+ },
51
+ parameters: {
52
+ controls: { exclude: ['value', 'onChange', 'multiple', 'wrap', 'style', 'children'] },
53
+ },
54
+ render: () => {
55
+ const selectedValue = '2';
56
+
57
+ return (
58
+ <Flex space="xl" direction="column" align="center">
59
+ <VariantTitle title="Unselected">
60
+ <Flex direction="row" space="sm">
61
+ <PillGroup value={selectedValue} onChange={() => {}}>
62
+ <Pill value="1" label="Label" />
63
+ </PillGroup>
64
+ </Flex>
65
+ </VariantTitle>
66
+ <VariantTitle title="Selected">
67
+ <Flex direction="row" space="sm">
68
+ <PillGroup value={selectedValue} onChange={() => {}}>
69
+ <Pill value="2" label="Label" />
70
+ </PillGroup>
71
+ </Flex>
72
+ </VariantTitle>
73
+ <VariantTitle title="With Icon - Unselected">
74
+ <Flex direction="row" space="sm">
75
+ <PillGroup value={selectedValue} onChange={() => {}}>
76
+ <Pill value="1" label="New" icon={HeartMediumIcon} />
77
+ </PillGroup>
78
+ </Flex>
79
+ </VariantTitle>
80
+ <VariantTitle title="With Icon - Selected">
81
+ <Flex direction="row" space="sm">
82
+ <PillGroup value={selectedValue} onChange={() => {}}>
83
+ <Pill value="2" label="New" icon={HeartMediumIcon} />
84
+ </PillGroup>
85
+ </Flex>
86
+ </VariantTitle>
87
+ </Flex>
88
+ );
89
+ },
90
+ };
91
+
92
+ export const WrapBehavior: Story = {
93
+ args: {
94
+ value: '',
95
+ children: <></>,
96
+ },
97
+ parameters: {
98
+ controls: { exclude: ['wrap', 'value', 'onChange', 'multiple'] },
99
+ },
100
+ render: () => {
101
+ const [value1, setValue1] = useState<string>('2');
102
+ const [value2, setValue2] = useState<string>('7');
103
+
104
+ return (
105
+ <Flex space="xl" direction="column" align="center">
106
+ <VariantTitle title="Wrap: False">
107
+ <PillGroup wrap={false} value={value1} onChange={v => setValue1(v as string)}>
108
+ <Pill value="1" label="New" />
109
+ <Pill value="2" label="Some label" />
110
+ <Pill value="3" label="Short" />
111
+ <Pill value="4" label="Quite a long label" />
112
+ <Pill value="5" label="Hmm, another label" />
113
+ </PillGroup>
114
+ </VariantTitle>
115
+ <VariantTitle title="Wrap: True">
116
+ <PillGroup wrap={true} value={value2} onChange={v => setValue2(v as string)}>
117
+ <Pill value="6" label="New" />
118
+ <Pill value="7" label="Some label" />
119
+ <Pill value="8" label="Short" />
120
+ <Pill value="9" label="Quite a long label" />
121
+ <Pill value="10" label="Hmm, another label" />
122
+ <Pill value="11" label="Custom Range" />
123
+ <Pill value="12" label="Last 7 Days" />
124
+ </PillGroup>
125
+ </VariantTitle>
126
+ </Flex>
127
+ );
128
+ },
129
+ };
130
+
131
+ export const Multiple: Story = {
132
+ args: {
133
+ value: [],
134
+ children: <></>,
135
+ },
136
+ parameters: {
137
+ controls: { exclude: ['wrap', 'value', 'onChange', 'multiple'] },
138
+ },
139
+ render: () => {
140
+ const [selectedCategories, setSelectedCategories] = useState<string[]>(['new', 'read']);
141
+
142
+ return (
143
+ <Flex space="lg" direction="column" align="center" style={{ maxWidth: 400 }}>
144
+ <PillGroup wrap={true} multiple value={selectedCategories} onChange={v => setSelectedCategories(v as string[])}>
145
+ <Pill value="all" label="All" />
146
+ <Pill value="new" label="New" icon={HeartMediumIcon} />
147
+ <Pill value="favourites" label="My favourites" icon={HeartMediumIcon} />
148
+ <Pill value="read" label="Read" />
149
+ <Pill value="yesterday" label="Yesterday" />
150
+ <Pill value="lastweek" label="Last Week" />
151
+ </PillGroup>
152
+ <BodyText>
153
+ Selected: {selectedCategories.length}{' '}
154
+ {selectedCategories.length === 1 ? 'category' : 'categories'}
155
+ </BodyText>
156
+ </Flex>
157
+ );
158
+ },
159
+ };
@@ -0,0 +1,66 @@
1
+ import React, { useMemo } from 'react';
2
+ import { ScrollView } from 'react-native';
3
+ import { StyleSheet } from 'react-native-unistyles';
4
+ import { Box } from '../Box';
5
+ import { PillGroupContext, PillGroupContextValue } from './PillGroup.context';
6
+ import type { PillGroupProps } from './PillGroup.props';
7
+
8
+ export const PillGroup = ({
9
+ children,
10
+ value,
11
+ multiple = false,
12
+ wrap = true,
13
+ onChange,
14
+ style,
15
+ ...props
16
+ }: PillGroupProps) => {
17
+ const normalizedValue = Array.isArray(value) ? value : [value];
18
+
19
+ const contextValue: PillGroupContextValue = useMemo(
20
+ () => ({
21
+ value: normalizedValue,
22
+ onChange: (pillValue: string) => {
23
+ if (multiple) {
24
+ const newValue = normalizedValue.includes(pillValue)
25
+ ? normalizedValue.filter(v => v !== pillValue)
26
+ : [...normalizedValue, pillValue];
27
+ onChange?.(newValue);
28
+ } else {
29
+ onChange?.(pillValue);
30
+ }
31
+ },
32
+ }),
33
+ [normalizedValue, multiple, onChange]
34
+ );
35
+
36
+ return (
37
+ <PillGroupContext.Provider value={contextValue}>
38
+ {wrap ? (
39
+ <Box style={[styles.group, styles.wrap, style]} {...props}>
40
+ {children}
41
+ </Box>
42
+ ) : (
43
+ <ScrollView
44
+ horizontal
45
+ contentContainerStyle={[styles.group, style]}
46
+ showsHorizontalScrollIndicator={false}
47
+ {...props}
48
+ >
49
+ {children}
50
+ </ScrollView>
51
+ )}
52
+ </PillGroupContext.Provider>
53
+ );
54
+ };
55
+
56
+ PillGroup.displayName = 'PillGroup';
57
+
58
+ const styles = StyleSheet.create(theme => ({
59
+ group: {
60
+ flexDirection: 'row',
61
+ gap: theme.components.pill.group.gap,
62
+ },
63
+ wrap: {
64
+ flexWrap: 'wrap',
65
+ },
66
+ }));
@@ -0,0 +1,4 @@
1
+ export { PillGroup } from './PillGroup';
2
+ export type { PillGroupProps } from './PillGroup.props';
3
+ export { Pill } from './Pill';
4
+ export type { PillProps } from './Pill.props';
@@ -181,6 +181,7 @@ const Select = ({
181
181
  <Input
182
182
  placeholder={searchPlaceholder}
183
183
  value={search}
184
+ inBottomSheet
184
185
  onChangeText={setSearch}
185
186
  type="search"
186
187
  />
@@ -287,6 +288,7 @@ const styles = StyleSheet.create(theme => ({
287
288
  emptyContainer: {
288
289
  alignItems: 'center',
289
290
  justifyContent: 'center',
291
+ marginTop: theme.space.md,
290
292
  },
291
293
  }));
292
294
 
@@ -0,0 +1,118 @@
1
+ import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import { StyleSheet } from 'react-native-unistyles';
4
+ import type { ToastContextValue, ToastInstance, ToastOptions } from './Toast.props';
5
+ import ToastItem, { type ToastItemHandle } from './ToastItem';
6
+
7
+ const ToastContext = createContext<ToastContextValue | undefined>(undefined);
8
+
9
+ export const useToastContext = () => {
10
+ const ctx = useContext(ToastContext);
11
+ if (!ctx) throw new Error('useToastContext must be used within ToastProvider');
12
+ return ctx;
13
+ };
14
+
15
+ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
16
+ const [toasts, setToasts] = useState<ToastInstance[]>([]);
17
+ const timers = useRef<Record<string, number>>({});
18
+ const toastRefs = useRef<Record<string, ToastItemHandle | null>>({});
19
+
20
+ const removeToast = useCallback((id: string) => {
21
+ setToasts(s => s.filter(t => t.id !== id));
22
+ const timer = timers.current[id];
23
+ if (timer) {
24
+ clearTimeout(timer);
25
+ delete timers.current[id];
26
+ }
27
+ delete toastRefs.current[id];
28
+ }, []);
29
+
30
+ const addToast = useCallback(
31
+ (opts: ToastOptions) => {
32
+ const id = opts.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
33
+ const toast: ToastInstance = {
34
+ id,
35
+ text: opts.text,
36
+ actionText: opts.actionText,
37
+ onPress: opts.onPress,
38
+ onDismiss: opts.onDismiss,
39
+ icon: opts.icon,
40
+ duration: opts.duration ?? 6000,
41
+ showDismissIcon: opts.showDismissIcon,
42
+ dismissOnPress: opts.dismissOnPress ?? true,
43
+ };
44
+ setToasts(s => [toast, ...s]);
45
+
46
+ // set auto-dismiss timer
47
+ if (toast.duration && toast.duration > 0) {
48
+ const t = setTimeout(() => {
49
+ // call dismiss animation if ref exists, otherwise remove immediately
50
+ const ref = toastRefs.current[id];
51
+ if (ref) {
52
+ ref.dismiss();
53
+ } else {
54
+ removeToast(id);
55
+ }
56
+ }, toast.duration) as unknown as number;
57
+ timers.current[id] = t;
58
+ }
59
+
60
+ return id;
61
+ },
62
+ [removeToast]
63
+ );
64
+
65
+ useEffect(() => {
66
+ return () => {
67
+ // cleanup timers on unmount
68
+ Object.values(timers.current).forEach(t => clearTimeout(t));
69
+ timers.current = {};
70
+ };
71
+ }, []);
72
+
73
+ return (
74
+ <ToastContext.Provider value={{ addToast, removeToast }}>
75
+ {children}
76
+ <View pointerEvents="box-none" style={styles.container as any}>
77
+ <View style={styles.stack as any}>
78
+ {toasts.map(t => (
79
+ <ToastItem
80
+ key={t.id}
81
+ ref={el => {
82
+ toastRefs.current[t.id] = el;
83
+ }}
84
+ toast={t}
85
+ onClose={removeToast}
86
+ />
87
+ ))}
88
+ </View>
89
+ </View>
90
+ </ToastContext.Provider>
91
+ );
92
+ };
93
+
94
+ export const useToast = () => {
95
+ const ctx = useContext(ToastContext);
96
+ if (!ctx) throw new Error('useToast must be used within ToastProvider');
97
+ return ctx;
98
+ };
99
+
100
+ export default ToastContext;
101
+
102
+ const styles = StyleSheet.create(theme => ({
103
+ container: {
104
+ position: 'absolute',
105
+ left: 0,
106
+ right: 0,
107
+ bottom: 0,
108
+ alignItems: 'stretch',
109
+ paddingBottom: theme.space['200'],
110
+ pointerEvents: 'box-none',
111
+ },
112
+ stack: {
113
+ width: '100%',
114
+ alignItems: 'center',
115
+ justifyContent: 'flex-end',
116
+ gap: theme.components.toast.stack.gap,
117
+ },
118
+ }));
@@ -0,0 +1,164 @@
1
+ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks';
2
+ import toastiOSVideo from '../../../docs/assets/toast-ios.MP4';
3
+ import { BackToTopButton, ViewFigmaButton } from '../../../docs/components';
4
+ import * as Stories from './Toast.stories';
5
+
6
+ <ViewFigmaButton url="https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=7072-902" />
7
+
8
+ <Meta title="Components / Toast" />
9
+
10
+ <BackToTopButton />
11
+
12
+ # Toast
13
+
14
+ The `Toast` component provides a non-intrusive way to display brief messages to users. Toasts appear at the bottom of the screen, can be stacked vertically, and automatically dismiss after a configurable duration. They support swipe-to-dismiss gestures and can include icons, action links, and a dismiss button.
15
+
16
+ - [Playground](#playground)
17
+ - [Usage](#usage)
18
+ - [Props](#props)
19
+ - [Features](#features)
20
+ - [Examples](#examples)
21
+ - [Basic Toast](#basic-toast)
22
+ - [With Icon](#with-icon)
23
+ - [With Action](#with-action)
24
+ - [Custom Duration](#custom-duration)
25
+ - [Stacked Toasts](#stacked-toasts)
26
+ - [Programmatic Dismiss](#programmatic-dismiss)
27
+ - [Dismiss Options](#dismiss-options)
28
+ - [Accessibility](#accessibility)
29
+
30
+ ## Playground
31
+
32
+ <Canvas of={Stories.Playground} />
33
+
34
+ <Controls of={Stories.Playground} />
35
+
36
+ ## Usage
37
+
38
+ ### Setup
39
+
40
+ First, wrap your app with the `ToastProvider` at the root level:
41
+
42
+ ```tsx
43
+ import { ToastProvider } from '@utilitywarehouse/hearth-react-native';
44
+
45
+ function App() {
46
+ return <ToastProvider>{/* Your app content */}</ToastProvider>;
47
+ }
48
+ ```
49
+
50
+ **Important:** Place the `ToastProvider` at the root of your app, outside of any scroll views or nested containers. This ensures toasts are displayed in a fixed position at the bottom of the screen, regardless of scroll position.
51
+
52
+ ### Basic Usage
53
+
54
+ Use the `useToast` hook to display toasts from any component:
55
+
56
+ ```tsx
57
+ import { useToast } from '@utilitywarehouse/hearth-react-native';
58
+ import { TickSmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
59
+
60
+ const MyComponent = () => {
61
+ const { addToast } = useToast();
62
+
63
+ const handleSave = () => {
64
+ // Your save logic
65
+ addToast({
66
+ text: 'Settings saved successfully',
67
+ icon: TickSmallIcon,
68
+ });
69
+ };
70
+
71
+ return <Button onPress={handleSave}>Save</Button>;
72
+ };
73
+ ```
74
+
75
+ ## Props
76
+
77
+ ### ToastOptions
78
+
79
+ | Prop | Type | Default | Description |
80
+ | ----------------- | --------------------- | -------------- | ---------------------------------------------------------------- |
81
+ | `text` | `string \| ReactNode` | - | The main message to display |
82
+ | `icon` | `ComponentType` | - | Optional icon component to display |
83
+ | `actionText` | `string` | - | Optional action text to display as a link |
84
+ | `onPress` | `() => void` | - | Optional callback when action or toast is pressed |
85
+ | `onDismiss` | `() => void` | - | Optional callback when toast is dismissed |
86
+ | `duration` | `number` | `6000` | Auto-dismiss duration in ms (0 = no dismiss) |
87
+ | `showDismissIcon` | `boolean` | `true` | Whether to show the dismiss icon button |
88
+ | `dismissOnPress` | `boolean` | `true` | Whether to dismiss the toast when pressed when Toast has onPress |
89
+ | `id` | `string` | auto-generated | Optional custom ID for the toast |
90
+
91
+ ### useToast Hook
92
+
93
+ Returns an object with:
94
+
95
+ | Method | Signature | Description |
96
+ | ------------- | ----------------------------------- | ---------------------------------------- |
97
+ | `addToast` | `(options: ToastOptions) => string` | Shows a new toast and returns its ID |
98
+ | `removeToast` | `(id: string) => void` | Programmatically dismisses a toast by ID |
99
+
100
+ ## Features
101
+
102
+ - **Auto-Dismiss:** Toasts automatically dismiss after 6 seconds by default. You can customize this duration or disable auto-dismiss entirely by setting `duration` to `0`.
103
+ - **Swipe to Dismiss:** Users can swipe down on any toast to dismiss it immediately. The gesture uses smooth animations for a native feel.
104
+ - **Dismiss Button:** Each toast includes a close button in the top-right corner for manual dismissal.
105
+ - **Stacking:** Multiple toasts stack vertically at the bottom of the screen, with the newest toast appearing on top. Each toast maintains its own auto-dismiss timer.
106
+ - **Icons:** Add visual context by including an icon component. Icons are positioned at the start of the toast message.
107
+ - **Action Links:** Include interactive action links (like "Undo" or "View") as secondary actions within the toast using the `actionText` prop. When `onPress` is provided, the entire toast becomes tappable, allowing users to trigger the action by tapping anywhere on the toast.
108
+
109
+ ### Native iOS Toast Animation
110
+
111
+ <video src={toastiOSVideo} width={400} height="auto" controls loop autoPlay />
112
+
113
+ ## Examples
114
+
115
+ ### Basic Toast
116
+
117
+ A simple text-only toast:
118
+
119
+ <Canvas of={Stories.BasicToast} />
120
+
121
+ ### With Icon
122
+
123
+ Toasts with icons for different message types:
124
+
125
+ <Canvas of={Stories.WithIcon} />
126
+
127
+ ### With Action
128
+
129
+ Include interactive action links for secondary actions:
130
+
131
+ <Canvas of={Stories.WithAction} />
132
+
133
+ ### Custom Duration
134
+
135
+ Control how long toasts stay visible:
136
+
137
+ <Canvas of={Stories.CustomDuration} />
138
+
139
+ ### Stacked Toasts
140
+
141
+ Multiple toasts stack vertically, newest on top:
142
+
143
+ <Canvas of={Stories.StackedToasts} />
144
+
145
+ ### Programmatic Dismiss
146
+
147
+ Dismiss toasts programmatically using their ID:
148
+
149
+ <Canvas of={Stories.ProgrammaticDismiss} />
150
+
151
+ ### Dismiss Options
152
+
153
+ Control dismiss behavior with `showDismissIcon` and `dismissOnPress`:
154
+
155
+ <Canvas of={Stories.DismissOptions} />
156
+
157
+ ## Accessibility
158
+
159
+ - **Screen reader announcements**: Toast content is automatically announced when it appears. On iOS, VoiceOver receives the announcement via accessibility live regions. On Android, TalkBack receives a direct announcement to avoid duplication
160
+ - **Semantic structure**: Toasts use proper ARIA roles (`alert` on iOS) for screen readers
161
+ - **Dismiss button**: Includes an accessibility label for easy dismissal
162
+ - **Action links**: Properly accessible as interactive elements when provided
163
+ - **Swipe gestures**: Work alongside manual dismiss options for user flexibility
164
+ - **Non-blocking**: Toasts don't interrupt user interaction with the app or steal focus
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface ToastOptions {
4
+ id?: string;
5
+ text: string | ReactNode;
6
+ /** Optional action text to display as a link */
7
+ actionText?: string;
8
+ /** Optional callback when action link or toast is pressed */
9
+ onPress?: () => void;
10
+ /** Optional callback when toast is dismissed */
11
+ onDismiss?: () => void;
12
+ /** Optional icon component */
13
+ icon?: React.ComponentType;
14
+ /** Duration in milliseconds; default 6000 */
15
+ duration?: number;
16
+ /** Whether to show the dismiss icon button; default true */
17
+ showDismissIcon?: boolean;
18
+ /** Whether to dismiss the toast when pressed; default true */
19
+ dismissOnPress?: boolean;
20
+ }
21
+
22
+ export interface ToastInstance extends ToastOptions {
23
+ id: string;
24
+ /** resolved duration */
25
+ duration: number;
26
+ }
27
+
28
+ export interface ToastContextValue {
29
+ addToast: (opts: ToastOptions) => string;
30
+ removeToast: (id: string) => void;
31
+ }
32
+
33
+ export default ToastOptions;