@duro-app/ui 5.12.0 → 5.13.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.
- package/dist/Table-Bms2ftt3.js +1694 -0
- package/dist/Table-Bms2ftt3.js.map +1 -0
- package/dist/components/AppShell/native.css.d.ts +53 -0
- package/dist/components/AppShell/native.css.d.ts.map +1 -0
- package/dist/components/Drawer/Drawer.d.ts +7 -35
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerSnap.stories.d.ts +20 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
- package/dist/components/Drawer/native.css.d.ts +55 -0
- package/dist/components/Drawer/native.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.css.d.ts +96 -0
- package/dist/components/Drawer/parts.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.d.ts +56 -0
- package/dist/components/Drawer/parts.d.ts.map +1 -0
- package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
- package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
- package/dist/components/Drawer/styles.css.d.ts +13 -58
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/touchInput.d.ts +35 -0
- package/dist/components/Drawer/touchInput.d.ts.map +1 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
- package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
- package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts +7 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/native.css.d.ts +14 -0
- package/dist/components/Menu/native.css.d.ts.map +1 -0
- package/dist/components/Menu/useMenuOpen.d.ts +16 -0
- package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
- package/dist/components/Menu/useMenuRoot.d.ts +3 -6
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/native.css.d.ts +11 -0
- package/dist/components/Popover/native.css.d.ts.map +1 -0
- package/dist/components/SideNav/Chevron.d.ts +4 -0
- package/dist/components/SideNav/Chevron.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/styles.css.d.ts +6 -0
- package/dist/components/SideNav/styles.css.d.ts.map +1 -1
- package/dist/components/SideNav/types.d.ts +6 -0
- package/dist/components/SideNav/types.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.d.ts +38 -0
- package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
- package/dist/components/SplitPane/styles.css.d.ts +78 -0
- package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
- package/dist/components/Tabs/TabLayout.d.ts +7 -0
- package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.d.ts.map +1 -1
- package/dist/components/Tabs/indicator.d.ts +28 -0
- package/dist/components/Tabs/indicator.d.ts.map +1 -0
- package/dist/components/Tabs/styles.css.d.ts +4 -0
- package/dist/components/Tabs/styles.css.d.ts.map +1 -1
- package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
- package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
- package/dist/components/Tabs/useTabKeys.d.ts +13 -0
- package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
- package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
- package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
- package/dist/components/Tag/Tag.d.ts +2 -24
- package/dist/components/Tag/Tag.d.ts.map +1 -1
- package/dist/components/Tag/styles.css.d.ts +8 -0
- package/dist/components/Tag/styles.css.d.ts.map +1 -1
- package/dist/components/Tag/types.d.ts +39 -0
- package/dist/components/Tag/types.d.ts.map +1 -0
- package/dist/components/Toast/ToastProvider.d.ts +2 -21
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/native.css.d.ts +81 -0
- package/dist/components/Toast/native.css.d.ts.map +1 -0
- package/dist/components/Toast/store.d.ts +47 -0
- package/dist/components/Toast/store.d.ts.map +1 -0
- package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
- package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/styles.css.d.ts +6 -0
- package/dist/components/Tree/styles.css.d.ts.map +1 -1
- package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
- package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.d.ts +33 -0
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
- package/dist/components/VirtualList/styles.css.d.ts +28 -0
- package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4935 -4260
- package/dist/index.js.map +1 -1
- package/dist/shared/ButtonHost.d.ts +11 -0
- package/dist/shared/ButtonHost.d.ts.map +1 -0
- package/dist/shared/pressableChild.d.ts +14 -0
- package/dist/shared/pressableChild.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +1 -1
- package/src/components/AppShell/AppShell.native.tsx +174 -0
- package/src/components/AppShell/native.css.ts +56 -0
- package/src/components/Button/Button.tsx +3 -3
- package/src/components/Drawer/Drawer.meta.ts +2 -1
- package/src/components/Drawer/Drawer.native.tsx +469 -0
- package/src/components/Drawer/Drawer.tsx +58 -145
- package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
- package/src/components/Drawer/DrawerSnap.stories.tsx +205 -0
- package/src/components/Drawer/native.css.ts +58 -0
- package/src/components/Drawer/parts.css.ts +125 -0
- package/src/components/Drawer/parts.tsx +110 -0
- package/src/components/Drawer/sheetPhysics.ts +103 -0
- package/src/components/Drawer/styles.css.ts +15 -74
- package/src/components/Drawer/touchInput.ts +81 -0
- package/src/components/Drawer/useDrawerRoot.ts +90 -0
- package/src/components/Drawer/useSheetHeights.ts +42 -0
- package/src/components/Drawer/useSwipeDismiss.ts +146 -23
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.native.tsx +268 -0
- package/src/components/Menu/Menu.tsx +11 -2
- package/src/components/Menu/native.css.ts +18 -0
- package/src/components/Menu/useMenuOpen.ts +33 -0
- package/src/components/Menu/useMenuRoot.ts +7 -26
- package/src/components/Popover/Popover.meta.ts +1 -1
- package/src/components/Popover/Popover.native.tsx +205 -0
- package/src/components/Popover/native.css.ts +13 -0
- package/src/components/SideNav/Chevron.native.tsx +20 -0
- package/src/components/SideNav/Chevron.tsx +20 -0
- package/src/components/SideNav/SideNav.meta.ts +1 -1
- package/src/components/SideNav/SideNav.tsx +57 -41
- package/src/components/SideNav/styles.css.ts +8 -0
- package/src/components/SideNav/types.ts +6 -0
- package/src/components/SplitPane/SplitPane.meta.ts +40 -0
- package/src/components/SplitPane/SplitPane.native.tsx +47 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
- package/src/components/SplitPane/SplitPane.tsx +218 -0
- package/src/components/SplitPane/styles.css.ts +81 -0
- package/src/components/Tabs/TabLayout.native.tsx +9 -0
- package/src/components/Tabs/TabLayout.tsx +6 -0
- package/src/components/Tabs/Tabs.meta.ts +1 -1
- package/src/components/Tabs/Tabs.tsx +38 -136
- package/src/components/Tabs/indicator.ts +36 -0
- package/src/components/Tabs/styles.css.ts +7 -0
- package/src/components/Tabs/useTabIndicator.native.ts +35 -0
- package/src/components/Tabs/useTabIndicator.ts +49 -0
- package/src/components/Tabs/useTabKeys.native.ts +2 -0
- package/src/components/Tabs/useTabKeys.ts +99 -0
- package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
- package/src/components/Tag/RemoveGlyph.tsx +10 -0
- package/src/components/Tag/Tag.meta.ts +1 -1
- package/src/components/Tag/Tag.native.tsx +70 -0
- package/src/components/Tag/Tag.tsx +9 -37
- package/src/components/Tag/styles.css.ts +9 -0
- package/src/components/Tag/types.ts +43 -0
- package/src/components/Toast/Toast.meta.ts +1 -1
- package/src/components/Toast/ToastProvider.native.tsx +226 -0
- package/src/components/Toast/ToastProvider.tsx +22 -84
- package/src/components/Toast/native.css.ts +78 -0
- package/src/components/Toast/store.ts +96 -0
- package/src/components/Toggle/Toggle.tsx +3 -3
- package/src/components/Tooltip/Tooltip.meta.ts +1 -1
- package/src/components/Tooltip/Tooltip.native.tsx +36 -0
- package/src/components/Tooltip/TooltipHint.ts +13 -0
- package/src/components/Tree/Tree.meta.ts +1 -1
- package/src/components/Tree/Tree.tsx +23 -135
- package/src/components/Tree/styles.css.ts +6 -0
- package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
- package/src/components/Tree/useTreeKeyboard.ts +152 -0
- package/src/components/VirtualList/VirtualList.meta.ts +39 -0
- package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
- package/src/components/VirtualList/VirtualList.tsx +103 -0
- package/src/components/VirtualList/styles.css.ts +31 -0
- package/src/hooks/useContainerBelow.native.ts +30 -0
- package/src/index.native.ts +33 -0
- package/src/index.ts +8 -1
- package/src/shared/ButtonHost.native.tsx +70 -0
- package/src/shared/ButtonHost.tsx +12 -0
- package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
- package/src/shared/pressableChild.ts +33 -0
- package/src/shared/useKeyboardHeight.native.ts +18 -0
- package/src/shared/useScreenReaderEnabled.native.ts +19 -0
- package/dist/Table-ColfpgKV.js +0 -1552
- package/dist/Table-ColfpgKV.js.map +0 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
useSyncExternalStore,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import {
|
|
13
|
+
AccessibilityInfo,
|
|
14
|
+
type LayoutChangeEvent,
|
|
15
|
+
Platform,
|
|
16
|
+
Pressable,
|
|
17
|
+
StyleSheet,
|
|
18
|
+
} from 'react-native'
|
|
19
|
+
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
|
20
|
+
import Animated, {
|
|
21
|
+
Easing,
|
|
22
|
+
useAnimatedStyle,
|
|
23
|
+
useSharedValue,
|
|
24
|
+
withTiming,
|
|
25
|
+
} from 'react-native-reanimated'
|
|
26
|
+
import {scheduleOnRN} from 'react-native-worklets'
|
|
27
|
+
import {html} from 'react-strict-dom'
|
|
28
|
+
import {DURATION_MS, LAYERS, SPACING_PX} from '@duro-app/tokens/keys'
|
|
29
|
+
import {Icon} from '../Icon/Icon'
|
|
30
|
+
import {Portal} from '../../shared/portal/PortalHost'
|
|
31
|
+
import {useSafeAreaInsets} from '../../shared/safeArea'
|
|
32
|
+
import {useKeyboardHeight} from '../../shared/useKeyboardHeight'
|
|
33
|
+
import {useScreenReaderEnabled} from '../../shared/useScreenReaderEnabled'
|
|
34
|
+
import {DISMISS_DURATION, DRAG_SLOP, SNAP_DURATION, shouldDismissEdge} from '../Drawer/sheetPhysics'
|
|
35
|
+
import {nativeStyles} from './native.css'
|
|
36
|
+
import {createToastStore, type ToastContextValue, type ToastEntry, type ToastStore} from './store'
|
|
37
|
+
|
|
38
|
+
export type {ToastAction, ToastContextValue, ToastOptions, ToastVariant} from './store'
|
|
39
|
+
|
|
40
|
+
const ToastContext = createContext<ToastContextValue | null>(null)
|
|
41
|
+
|
|
42
|
+
export function useToast(): ToastContextValue {
|
|
43
|
+
const ctx = useContext(ToastContext)
|
|
44
|
+
if (!ctx) throw new Error('useToast must be used within a <ToastProvider>')
|
|
45
|
+
return ctx
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const easeOut = Easing.out(Easing.cubic)
|
|
49
|
+
|
|
50
|
+
function ToastItem({
|
|
51
|
+
entry,
|
|
52
|
+
store,
|
|
53
|
+
onDismiss,
|
|
54
|
+
dismissLabel,
|
|
55
|
+
screenReader,
|
|
56
|
+
}: {
|
|
57
|
+
entry: ToastEntry
|
|
58
|
+
store: ToastStore
|
|
59
|
+
onDismiss: (id: string) => void
|
|
60
|
+
dismissLabel: string
|
|
61
|
+
screenReader: boolean
|
|
62
|
+
}) {
|
|
63
|
+
const {id, variant, message, action, duration} = entry
|
|
64
|
+
const [pressed, setPressed] = useState(false)
|
|
65
|
+
// A press holds the toast as hover does on the web; a screen reader holds
|
|
66
|
+
// it for as long as it is on (WCAG 2.2.1).
|
|
67
|
+
const paused = pressed || screenReader
|
|
68
|
+
|
|
69
|
+
// The store keeps what is left, so a toast that moves to another host
|
|
70
|
+
// (a Dialog above it closed) carries on where it was.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (duration <= 0 || paused) return
|
|
73
|
+
const started = Date.now()
|
|
74
|
+
const timer = setTimeout(() => onDismiss(id), Math.max(store.remaining(id), 0))
|
|
75
|
+
return () => {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
store.spend(id, Date.now() - started)
|
|
78
|
+
}
|
|
79
|
+
}, [id, duration, paused, onDismiss, store])
|
|
80
|
+
|
|
81
|
+
// iOS has no live regions: announce the message once. Android reads the
|
|
82
|
+
// live region itself, so announcing there too would read it twice.
|
|
83
|
+
const announced = useRef(false)
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (Platform.OS !== 'ios' || announced.current || typeof message !== 'string') return
|
|
86
|
+
announced.current = true
|
|
87
|
+
AccessibilityInfo.announceForAccessibility(message)
|
|
88
|
+
}, [message])
|
|
89
|
+
|
|
90
|
+
const x = useSharedValue(0)
|
|
91
|
+
const shown = useSharedValue(0)
|
|
92
|
+
const width = useSharedValue(1)
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
shown.value = withTiming(1, {duration: DURATION_MS.brisk, easing: easeOut})
|
|
95
|
+
}, [shown])
|
|
96
|
+
|
|
97
|
+
const pan = useMemo(
|
|
98
|
+
() =>
|
|
99
|
+
Gesture.Pan()
|
|
100
|
+
.activeOffsetX([-DRAG_SLOP, DRAG_SLOP])
|
|
101
|
+
.failOffsetY([-DRAG_SLOP, DRAG_SLOP])
|
|
102
|
+
.onUpdate((e) => {
|
|
103
|
+
x.value = e.translationX
|
|
104
|
+
})
|
|
105
|
+
.onEnd((e) => {
|
|
106
|
+
const distance = Math.abs(e.translationX)
|
|
107
|
+
if (shouldDismissEdge(distance, width.value, Math.abs(e.velocityX) / 1000)) {
|
|
108
|
+
const off = Math.sign(e.translationX || e.velocityX) * width.value
|
|
109
|
+
x.value = withTiming(off, {duration: DISMISS_DURATION}, (done) => {
|
|
110
|
+
if (done) scheduleOnRN(onDismiss, id)
|
|
111
|
+
})
|
|
112
|
+
} else {
|
|
113
|
+
x.value = withTiming(0, {duration: SNAP_DURATION, easing: easeOut})
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
[id, onDismiss, x, width],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const motion = useAnimatedStyle(() => ({
|
|
120
|
+
opacity: shown.value * (1 - Math.min(1, Math.abs(x.value) / width.value)),
|
|
121
|
+
transform: [{translateX: x.value}, {translateY: (1 - shown.value) * SPACING_PX.sm}],
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
125
|
+
width.value = e.nativeEvent.layout.width
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<GestureDetector gesture={pan}>
|
|
130
|
+
<Animated.View onLayout={onLayout} style={[layout.row, motion]}>
|
|
131
|
+
<Pressable
|
|
132
|
+
onPressIn={() => setPressed(true)}
|
|
133
|
+
onPressOut={() => setPressed(false)}
|
|
134
|
+
accessible={false}
|
|
135
|
+
style={layout.row}
|
|
136
|
+
>
|
|
137
|
+
<html.div
|
|
138
|
+
role={variant === 'error' ? 'alert' : 'status'}
|
|
139
|
+
aria-live={variant === 'error' ? 'assertive' : 'polite'}
|
|
140
|
+
style={[nativeStyles.toast, nativeStyles[variant]]}
|
|
141
|
+
>
|
|
142
|
+
<html.div style={nativeStyles.message}>{message}</html.div>
|
|
143
|
+
{action ? (
|
|
144
|
+
<html.button
|
|
145
|
+
type="button"
|
|
146
|
+
style={nativeStyles.action}
|
|
147
|
+
onClick={() => {
|
|
148
|
+
action.onClick()
|
|
149
|
+
onDismiss(id)
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
{action.label}
|
|
153
|
+
</html.button>
|
|
154
|
+
) : null}
|
|
155
|
+
<html.button
|
|
156
|
+
type="button"
|
|
157
|
+
aria-label={dismissLabel}
|
|
158
|
+
style={nativeStyles.close}
|
|
159
|
+
onClick={() => onDismiss(id)}
|
|
160
|
+
>
|
|
161
|
+
<Icon name="x" size="sm" color="textMuted" />
|
|
162
|
+
</html.button>
|
|
163
|
+
</html.div>
|
|
164
|
+
</Pressable>
|
|
165
|
+
</Animated.View>
|
|
166
|
+
</GestureDetector>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Layout-only: each toast row spans the region and centres its card.
|
|
171
|
+
const layout = StyleSheet.create({row: {width: '100%', alignItems: 'center'}})
|
|
172
|
+
|
|
173
|
+
export interface ToastProviderProps {
|
|
174
|
+
/** Accessible name of each toast's close button, e.g. from the app's message catalog. */
|
|
175
|
+
dismissLabel?: string
|
|
176
|
+
children: ReactNode
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Native ToastProvider (plan N0, Slice B): the same store and countdown as
|
|
181
|
+
* the web, drawn through the portal host at `LAYERS.toast`, so a toast shows
|
|
182
|
+
* above the app, every open Duro overlay (a sheet included) and the
|
|
183
|
+
* keyboard. A sideways swipe or the close button dismisses it.
|
|
184
|
+
*/
|
|
185
|
+
export function ToastProvider({dismissLabel = 'Dismiss', children}: ToastProviderProps) {
|
|
186
|
+
const storeRef = useRef<ToastStore>(null)
|
|
187
|
+
if (storeRef.current === null) storeRef.current = createToastStore()
|
|
188
|
+
const store = storeRef.current
|
|
189
|
+
const entries = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
190
|
+
const insets = useSafeAreaInsets()
|
|
191
|
+
const keyboard = useKeyboardHeight()
|
|
192
|
+
const screenReader = useScreenReaderEnabled()
|
|
193
|
+
|
|
194
|
+
const value = useMemo<ToastContextValue>(
|
|
195
|
+
() => ({toast: (opts) => store.add(opts), dismiss: (id) => store.remove(id)}),
|
|
196
|
+
[store],
|
|
197
|
+
)
|
|
198
|
+
const dismiss = useCallback((id: string) => store.remove(id), [store])
|
|
199
|
+
const bottom = Math.max(insets.bottom, keyboard) + SPACING_PX.md
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<ToastContext.Provider value={value}>
|
|
203
|
+
{children}
|
|
204
|
+
{entries.length > 0 && (
|
|
205
|
+
<Portal layer={LAYERS.toast}>
|
|
206
|
+
<html.div
|
|
207
|
+
role="region"
|
|
208
|
+
aria-label="Notifications"
|
|
209
|
+
style={[nativeStyles.region, nativeStyles.regionBottom(bottom)]}
|
|
210
|
+
>
|
|
211
|
+
{entries.map((entry) => (
|
|
212
|
+
<ToastItem
|
|
213
|
+
key={entry.id}
|
|
214
|
+
entry={entry}
|
|
215
|
+
store={store}
|
|
216
|
+
onDismiss={dismiss}
|
|
217
|
+
dismissLabel={dismissLabel}
|
|
218
|
+
screenReader={screenReader}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</html.div>
|
|
222
|
+
</Portal>
|
|
223
|
+
)}
|
|
224
|
+
</ToastContext.Provider>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
@@ -14,83 +14,15 @@ import {html} from 'react-strict-dom'
|
|
|
14
14
|
import {Icon, type IconName} from '../Icon'
|
|
15
15
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
16
16
|
import {styles} from './styles.css'
|
|
17
|
+
import {
|
|
18
|
+
createToastStore,
|
|
19
|
+
type ToastContextValue,
|
|
20
|
+
type ToastEntry,
|
|
21
|
+
type ToastStore,
|
|
22
|
+
type ToastVariant,
|
|
23
|
+
} from './store'
|
|
17
24
|
|
|
18
|
-
export type
|
|
19
|
-
|
|
20
|
-
export interface ToastAction {
|
|
21
|
-
label: string
|
|
22
|
-
onClick: () => void
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface ToastOptions {
|
|
26
|
-
/** Visual + semantic tone. Errors announce assertively; errors and
|
|
27
|
-
* warnings linger longer. */
|
|
28
|
-
variant?: ToastVariant
|
|
29
|
-
message: ReactNode
|
|
30
|
-
/** Optional single action, e.g. Undo. Runs, then dismisses the toast. */
|
|
31
|
-
action?: ToastAction
|
|
32
|
-
/** Auto-dismiss delay in ms. `0` (or negative) keeps it until dismissed.
|
|
33
|
-
* The countdown pauses while the toast is hovered or holds focus. */
|
|
34
|
-
duration?: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface ToastContextValue {
|
|
38
|
-
/** Show a toast; returns its id so it can be dismissed programmatically. */
|
|
39
|
-
toast: (opts: ToastOptions) => string
|
|
40
|
-
dismiss: (id: string) => void
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface ToastEntry {
|
|
44
|
-
id: string
|
|
45
|
-
variant: ToastVariant
|
|
46
|
-
message: ReactNode
|
|
47
|
-
action?: ToastAction
|
|
48
|
-
duration: number
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface ToastStore {
|
|
52
|
-
subscribe: (cb: () => void) => () => void
|
|
53
|
-
getSnapshot: () => readonly ToastEntry[]
|
|
54
|
-
add: (opts: ToastOptions) => string
|
|
55
|
-
remove: (id: string) => void
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const DEFAULT_DURATION: Record<ToastVariant, number> = {
|
|
59
|
-
success: 5000,
|
|
60
|
-
info: 5000,
|
|
61
|
-
error: 8000,
|
|
62
|
-
warning: 8000,
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function createToastStore(): ToastStore {
|
|
66
|
-
let entries: ToastEntry[] = []
|
|
67
|
-
let seq = 0
|
|
68
|
-
const listeners = new Set<() => void>()
|
|
69
|
-
const notify = () => listeners.forEach((l) => l())
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
subscribe: (cb) => {
|
|
73
|
-
listeners.add(cb)
|
|
74
|
-
return () => listeners.delete(cb)
|
|
75
|
-
},
|
|
76
|
-
getSnapshot: () => entries,
|
|
77
|
-
add: (opts) => {
|
|
78
|
-
const variant = opts.variant ?? 'info'
|
|
79
|
-
const id = `toast-${++seq}`
|
|
80
|
-
const duration = opts.duration ?? DEFAULT_DURATION[variant]
|
|
81
|
-
entries = [...entries, {id, variant, message: opts.message, action: opts.action, duration}]
|
|
82
|
-
notify()
|
|
83
|
-
return id
|
|
84
|
-
},
|
|
85
|
-
remove: (id) => {
|
|
86
|
-
const next = entries.filter((e) => e.id !== id)
|
|
87
|
-
if (next.length !== entries.length) {
|
|
88
|
-
entries = next
|
|
89
|
-
notify()
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
}
|
|
93
|
-
}
|
|
25
|
+
export type {ToastAction, ToastContextValue, ToastOptions, ToastVariant} from './store'
|
|
94
26
|
|
|
95
27
|
const variantIcon: Record<ToastVariant, IconName> = {
|
|
96
28
|
success: 'check-circle-filled',
|
|
@@ -115,10 +47,12 @@ export function useToast(): ToastContextValue {
|
|
|
115
47
|
|
|
116
48
|
function ToastItem({
|
|
117
49
|
entry,
|
|
50
|
+
store,
|
|
118
51
|
onDismiss,
|
|
119
52
|
dismissLabel,
|
|
120
53
|
}: {
|
|
121
54
|
entry: ToastEntry
|
|
55
|
+
store: ToastStore
|
|
122
56
|
onDismiss: (id: string) => void
|
|
123
57
|
dismissLabel: string
|
|
124
58
|
}) {
|
|
@@ -126,19 +60,17 @@ function ToastItem({
|
|
|
126
60
|
const [hovered, setHovered] = useState(false)
|
|
127
61
|
const [focused, setFocused] = useState(false)
|
|
128
62
|
const paused = hovered || focused
|
|
129
|
-
//
|
|
130
|
-
// timer with what was left (WCAG 2.2.1: the reader controls the timing).
|
|
131
|
-
const remaining = useRef(duration)
|
|
132
|
-
|
|
63
|
+
// The store keeps what is left of the countdown; a pause keeps it, a resume
|
|
64
|
+
// restarts the timer with what was left (WCAG 2.2.1: the reader controls the timing).
|
|
133
65
|
useEffect(() => {
|
|
134
66
|
if (duration <= 0 || paused) return
|
|
135
67
|
const started = Date.now()
|
|
136
|
-
const timer = setTimeout(() => onDismiss(id), Math.max(remaining
|
|
68
|
+
const timer = setTimeout(() => onDismiss(id), Math.max(store.remaining(id), 0))
|
|
137
69
|
return () => {
|
|
138
70
|
clearTimeout(timer)
|
|
139
|
-
|
|
71
|
+
store.spend(id, Date.now() - started)
|
|
140
72
|
}
|
|
141
|
-
}, [id, duration, paused, onDismiss])
|
|
73
|
+
}, [id, duration, paused, onDismiss, store])
|
|
142
74
|
|
|
143
75
|
return (
|
|
144
76
|
<html.div
|
|
@@ -213,7 +145,13 @@ export function ToastProvider({dismissLabel = 'Dismiss', children}: ToastProvide
|
|
|
213
145
|
const region = (
|
|
214
146
|
<html.div role="region" aria-label="Notifications" style={styles.region}>
|
|
215
147
|
{entries.map((entry) => (
|
|
216
|
-
<ToastItem
|
|
148
|
+
<ToastItem
|
|
149
|
+
key={entry.id}
|
|
150
|
+
entry={entry}
|
|
151
|
+
store={store}
|
|
152
|
+
onDismiss={dismiss}
|
|
153
|
+
dismissLabel={dismissLabel}
|
|
154
|
+
/>
|
|
217
155
|
))}
|
|
218
156
|
</html.div>
|
|
219
157
|
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
import {shadows} from '@duro-app/tokens/tokens/shadows.css'
|
|
6
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
7
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The native toast, the picked B0 direction (docs/mockups/native-toast/
|
|
11
|
+
* Tinted.dc.html): a card with a status-coloured edge, one row of message,
|
|
12
|
+
* action and close. Motion is Reanimated's.
|
|
13
|
+
*/
|
|
14
|
+
export const nativeStyles = css.create({
|
|
15
|
+
region: {
|
|
16
|
+
position: 'absolute',
|
|
17
|
+
left: 0,
|
|
18
|
+
right: 0,
|
|
19
|
+
display: 'flex',
|
|
20
|
+
flexDirection: 'column',
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
gap: spacing.sm,
|
|
23
|
+
paddingLeft: spacing.md,
|
|
24
|
+
paddingRight: spacing.md,
|
|
25
|
+
},
|
|
26
|
+
// The bottom safe-area inset and the keyboard, plus the edge spacing.
|
|
27
|
+
regionBottom: (bottom: number) => ({bottom}),
|
|
28
|
+
toast: {
|
|
29
|
+
boxSizing: 'border-box',
|
|
30
|
+
width: '100%',
|
|
31
|
+
maxWidth: sizes.toastMaxW,
|
|
32
|
+
display: 'flex',
|
|
33
|
+
flexDirection: 'row',
|
|
34
|
+
alignItems: 'center',
|
|
35
|
+
gap: spacing.sm,
|
|
36
|
+
paddingTop: spacing.sm,
|
|
37
|
+
paddingBottom: spacing.sm,
|
|
38
|
+
paddingRight: spacing.sm,
|
|
39
|
+
paddingLeft: spacing.md,
|
|
40
|
+
borderRadius: radii.md,
|
|
41
|
+
borderWidth: borders.hairline,
|
|
42
|
+
borderStyle: 'solid',
|
|
43
|
+
borderColor: colors.border,
|
|
44
|
+
borderLeftWidth: borders.accent,
|
|
45
|
+
backgroundColor: colors.bgCard,
|
|
46
|
+
boxShadow: shadows.md,
|
|
47
|
+
},
|
|
48
|
+
success: {borderLeftColor: colors.successBorder},
|
|
49
|
+
error: {borderLeftColor: colors.errorBorder},
|
|
50
|
+
info: {borderLeftColor: colors.infoBorder},
|
|
51
|
+
warning: {borderLeftColor: colors.warningBorder},
|
|
52
|
+
message: {
|
|
53
|
+
flexGrow: 1,
|
|
54
|
+
flexShrink: 1,
|
|
55
|
+
color: colors.text,
|
|
56
|
+
fontSize: typography.fontSizeSm,
|
|
57
|
+
lineHeight: typography.lineHeight,
|
|
58
|
+
},
|
|
59
|
+
action: {
|
|
60
|
+
display: 'flex',
|
|
61
|
+
padding: spacing.sm,
|
|
62
|
+
borderWidth: 0,
|
|
63
|
+
backgroundColor: 'transparent',
|
|
64
|
+
color: colors.accent,
|
|
65
|
+
fontSize: typography.fontSizeSm,
|
|
66
|
+
fontWeight: typography.fontWeightSemibold,
|
|
67
|
+
},
|
|
68
|
+
close: {
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
justifyContent: 'center',
|
|
72
|
+
width: sizes.touchTarget,
|
|
73
|
+
height: sizes.touchTarget,
|
|
74
|
+
borderRadius: radii.sm,
|
|
75
|
+
borderWidth: 0,
|
|
76
|
+
backgroundColor: 'transparent',
|
|
77
|
+
},
|
|
78
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
/** The toast queue both platforms' ToastProvider render (plan N0, Slice B). */
|
|
4
|
+
|
|
5
|
+
export type ToastVariant = 'success' | 'error' | 'info' | 'warning'
|
|
6
|
+
|
|
7
|
+
export interface ToastAction {
|
|
8
|
+
label: string
|
|
9
|
+
onClick: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ToastOptions {
|
|
13
|
+
/** Visual + semantic tone. Errors announce assertively; errors and
|
|
14
|
+
* warnings linger longer. */
|
|
15
|
+
variant?: ToastVariant
|
|
16
|
+
message: ReactNode
|
|
17
|
+
/** Optional single action, e.g. Undo. Runs, then dismisses the toast. */
|
|
18
|
+
action?: ToastAction
|
|
19
|
+
/** Auto-dismiss delay in ms. `0` (or negative) keeps it until dismissed.
|
|
20
|
+
* The countdown pauses while the toast is hovered or holds focus. */
|
|
21
|
+
duration?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ToastContextValue {
|
|
25
|
+
/** Show a toast; returns its id so it can be dismissed programmatically. */
|
|
26
|
+
toast: (opts: ToastOptions) => string
|
|
27
|
+
dismiss: (id: string) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ToastEntry {
|
|
31
|
+
id: string
|
|
32
|
+
variant: ToastVariant
|
|
33
|
+
message: ReactNode
|
|
34
|
+
action?: ToastAction
|
|
35
|
+
duration: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ToastStore {
|
|
39
|
+
subscribe: (cb: () => void) => () => void
|
|
40
|
+
getSnapshot: () => readonly ToastEntry[]
|
|
41
|
+
add: (opts: ToastOptions) => string
|
|
42
|
+
remove: (id: string) => void
|
|
43
|
+
/**
|
|
44
|
+
* What is left of a toast's countdown, in ms. Kept here, not in the toast's
|
|
45
|
+
* component: on native a toast re-renders into another host when a Dialog
|
|
46
|
+
* above it closes, and its countdown carries on.
|
|
47
|
+
*/
|
|
48
|
+
remaining: (id: string) => number
|
|
49
|
+
/** Count `ms` of shown time against a toast's countdown. */
|
|
50
|
+
spend: (id: string, ms: number) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_DURATION: Record<ToastVariant, number> = {
|
|
54
|
+
success: 5000,
|
|
55
|
+
info: 5000,
|
|
56
|
+
error: 8000,
|
|
57
|
+
warning: 8000,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createToastStore(): ToastStore {
|
|
61
|
+
let entries: ToastEntry[] = []
|
|
62
|
+
let seq = 0
|
|
63
|
+
const left = new Map<string, number>()
|
|
64
|
+
const listeners = new Set<() => void>()
|
|
65
|
+
const notify = () => listeners.forEach((l) => l())
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
subscribe: (cb) => {
|
|
69
|
+
listeners.add(cb)
|
|
70
|
+
return () => listeners.delete(cb)
|
|
71
|
+
},
|
|
72
|
+
getSnapshot: () => entries,
|
|
73
|
+
add: (opts) => {
|
|
74
|
+
const variant = opts.variant ?? 'info'
|
|
75
|
+
const id = `toast-${++seq}`
|
|
76
|
+
const duration = opts.duration ?? DEFAULT_DURATION[variant]
|
|
77
|
+
entries = [...entries, {id, variant, message: opts.message, action: opts.action, duration}]
|
|
78
|
+
left.set(id, duration)
|
|
79
|
+
notify()
|
|
80
|
+
return id
|
|
81
|
+
},
|
|
82
|
+
remove: (id) => {
|
|
83
|
+
left.delete(id)
|
|
84
|
+
const next = entries.filter((e) => e.id !== id)
|
|
85
|
+
if (next.length !== entries.length) {
|
|
86
|
+
entries = next
|
|
87
|
+
notify()
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
remaining: (id) => left.get(id) ?? 0,
|
|
91
|
+
spend: (id, ms) => {
|
|
92
|
+
const current = left.get(id)
|
|
93
|
+
if (current !== undefined) left.set(id, current - ms)
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {type ReactNode, useCallback, useLayoutEffect, useRef} from 'react'
|
|
2
|
-
import {html} from 'react-strict-dom'
|
|
3
2
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
4
3
|
import {useToggleGroup} from '../ToggleGroup/ToggleGroupContext'
|
|
5
4
|
import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
|
|
6
5
|
import {mergeRefs} from '../../shared/mergeRefs'
|
|
6
|
+
import {ButtonHost} from '../../shared/ButtonHost'
|
|
7
7
|
import type {ControlSize} from '../../shared/types'
|
|
8
8
|
import {isNative} from '../../platform'
|
|
9
9
|
import {styles} from './styles.css'
|
|
@@ -84,7 +84,7 @@ export function Toggle({
|
|
|
84
84
|
}, [disabled, isGrouped, value, group, pressed, setStandalonePressed])
|
|
85
85
|
|
|
86
86
|
return (
|
|
87
|
-
<
|
|
87
|
+
<ButtonHost
|
|
88
88
|
ref={mergeRefs(ref, grouped.ref)}
|
|
89
89
|
type="button"
|
|
90
90
|
tabIndex={
|
|
@@ -116,6 +116,6 @@ export function Toggle({
|
|
|
116
116
|
]}
|
|
117
117
|
>
|
|
118
118
|
{children}
|
|
119
|
-
</
|
|
119
|
+
</ButtonHost>
|
|
120
120
|
)
|
|
121
121
|
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Hover/focus tooltip that shows supplementary content. Compound component — Root is required (throws without it).',
|
|
5
|
+
'Hover/focus tooltip that shows supplementary content. Compound component — Root is required (throws without it). On touch (React Native) no bubble is drawn: a string content becomes the accessibility hint of the Duro trigger inside (Button, Toggle, Menu.Trigger, Popover.Trigger), read after its name. Keep tooltip text non-essential, and keep aria-label on icon-only triggers.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Providing extra context on hover for icon-only buttons',
|
|
8
8
|
'Explaining abbreviations or truncated text',
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import {TooltipHintContext} from './TooltipHint'
|
|
3
|
+
|
|
4
|
+
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
|
5
|
+
|
|
6
|
+
interface RootProps {
|
|
7
|
+
children: ReactNode
|
|
8
|
+
/** On native, a string becomes the trigger's accessibility hint; anything else is dropped. */
|
|
9
|
+
content: ReactNode
|
|
10
|
+
/** Web only. */
|
|
11
|
+
placement?: Placement
|
|
12
|
+
/** Web only. */
|
|
13
|
+
delay?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Native Tooltip (plan N0, Decision 5): touch has no hover, so no bubble is
|
|
18
|
+
* drawn. A string `content` becomes the accessibility hint of the Duro
|
|
19
|
+
* trigger inside (Button, Toggle, Menu.Trigger, Popover.Trigger), read after
|
|
20
|
+
* its name. Keep tooltip text non-essential: sighted touch users never see
|
|
21
|
+
* it, and an icon-only trigger still needs its `aria-label`.
|
|
22
|
+
*/
|
|
23
|
+
function Root({children, content}: RootProps) {
|
|
24
|
+
const hint = typeof content === 'string' ? content : undefined
|
|
25
|
+
return <TooltipHintContext.Provider value={hint}>{children}</TooltipHintContext.Provider>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Renders its child in place; the hint reaches it through context. */
|
|
29
|
+
function Trigger({children}: {children: ReactNode}) {
|
|
30
|
+
return children
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Tooltip = {
|
|
34
|
+
Root,
|
|
35
|
+
Trigger,
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {createContext, useContext} from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native: a Tooltip's string content, read by the Duro trigger inside it
|
|
5
|
+
* (Button, Toggle, Menu.Trigger, Popover.Trigger) and set as that trigger's
|
|
6
|
+
* own accessibility hint (plan N0, Decision 5). Touch has no hover, so the
|
|
7
|
+
* text is never drawn; screen readers read it after the trigger's name.
|
|
8
|
+
*/
|
|
9
|
+
export const TooltipHintContext = createContext<string | undefined>(undefined)
|
|
10
|
+
|
|
11
|
+
export function useTooltipHint(): string | undefined {
|
|
12
|
+
return useContext(TooltipHintContext)
|
|
13
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required.',
|
|
5
|
+
'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required. On React Native selection, expansion and aria-level are the same; the keyboard model (arrows, Home/End, typeahead) is web only.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Browsing data that nests to any depth — a file tree, zones › services › capabilities, namespace › resource',
|
|
8
8
|
'Picking one node of a hierarchy to show its details beside the tree',
|