@duro-app/ui 5.12.0 → 5.14.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/README.md +49 -0
- package/dist/Table-DnaoH-h5.js +1806 -0
- package/dist/Table-DnaoH-h5.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/Button/Button.d.ts.map +1 -1
- package/dist/components/ColorMode/ColorMode.d.ts +2 -1
- package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
- package/dist/components/ColorMode/storage.d.ts +4 -0
- package/dist/components/ColorMode/storage.d.ts.map +1 -0
- package/dist/components/ColorMode/styles.css.d.ts +4 -0
- package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
- package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
- package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts +7 -2
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/comboboxState.d.ts +29 -0
- package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
- package/dist/components/Combobox/native.css.d.ts +27 -0
- package/dist/components/Combobox/native.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts +12 -37
- 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 +22 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
- package/dist/components/Drawer/native.css.d.ts +59 -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 +16 -57
- 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/Form/FormSubmitContext.d.ts +8 -0
- package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
- package/dist/components/Listbox/native.css.d.ts +84 -0
- package/dist/components/Listbox/native.css.d.ts.map +1 -0
- 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/Select/Select.d.ts +9 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +11 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/native.css.d.ts +11 -0
- package/dist/components/Select/native.css.d.ts.map +1 -0
- package/dist/components/Select/selectState.d.ts +21 -0
- package/dist/components/Select/selectState.d.ts.map +1 -0
- package/dist/components/Select/styles.css.d.ts +10 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts +6 -8
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- 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/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
- package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
- package/dist/components/ThemeProvider/overrides.d.ts +16 -0
- package/dist/components/ThemeProvider/overrides.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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5090 -4381
- 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/shared/textOf.d.ts +7 -0
- package/dist/shared/textOf.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +3 -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 +15 -4
- package/src/components/ColorMode/ColorMode.meta.ts +1 -1
- package/src/components/ColorMode/ColorMode.tsx +16 -37
- package/src/components/ColorMode/storage.native.ts +8 -0
- package/src/components/ColorMode/storage.ts +19 -0
- package/src/components/ColorMode/styles.css.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.ts +19 -0
- package/src/components/Combobox/Combobox.meta.ts +1 -1
- package/src/components/Combobox/Combobox.native.tsx +295 -0
- package/src/components/Combobox/Combobox.tsx +14 -8
- package/src/components/Combobox/comboboxState.ts +107 -0
- package/src/components/Combobox/native.css.ts +33 -0
- package/src/components/Combobox/useComboboxRoot.ts +17 -91
- package/src/components/Drawer/Drawer.meta.ts +2 -1
- package/src/components/Drawer/Drawer.native.tsx +491 -0
- package/src/components/Drawer/Drawer.tsx +65 -143
- package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
- package/src/components/Drawer/DrawerSnap.stories.tsx +225 -0
- package/src/components/Drawer/native.css.ts +62 -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 +18 -73
- 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/Form/Form.meta.ts +1 -1
- package/src/components/Form/Form.native.tsx +120 -0
- package/src/components/Form/FormSubmitContext.ts +12 -0
- package/src/components/Listbox/Listbox.meta.ts +1 -1
- package/src/components/Listbox/Listbox.native.tsx +153 -0
- package/src/components/Listbox/native.css.ts +91 -0
- 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/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +224 -0
- package/src/components/Select/Select.stories.tsx +49 -1
- package/src/components/Select/Select.tsx +20 -3
- package/src/components/Select/native.css.ts +13 -0
- package/src/components/Select/selectState.ts +36 -0
- package/src/components/Select/styles.css.ts +11 -0
- package/src/components/Select/useSelectRoot.ts +9 -30
- 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/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
- package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
- package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
- package/src/components/ThemeProvider/overrideRule.ts +39 -0
- package/src/components/ThemeProvider/overrides.ts +27 -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/form.native.ts +7 -0
- package/src/hooks/useContainerBelow.native.ts +30 -0
- package/src/index.native.ts +48 -0
- package/src/index.ts +9 -1
- package/src/shared/ButtonHost.native.tsx +70 -0
- package/src/shared/ButtonHost.tsx +12 -0
- package/src/shared/focusInput.native.ts +18 -0
- package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
- package/src/shared/pressableChild.ts +33 -0
- package/src/shared/textOf.ts +13 -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
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {useRef, useCallback, useEffect} from 'react'
|
|
2
|
-
import type {DrawerAnchor} from './
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
import type {DrawerAnchor} from './parts'
|
|
3
|
+
import {
|
|
4
|
+
DISMISS_DURATION,
|
|
5
|
+
DRAG_SLOP,
|
|
6
|
+
SNAP_DURATION,
|
|
7
|
+
SNAP_MAX_OVERSHOOT,
|
|
8
|
+
rubberBand,
|
|
9
|
+
settleDetent,
|
|
10
|
+
shouldDismissEdge,
|
|
11
|
+
} from './sheetPhysics'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Max pixels the panel visually "grows" when dragged in the opposite direction.
|
|
@@ -17,6 +17,16 @@ const DISMISS_DURATION = 160
|
|
|
17
17
|
*/
|
|
18
18
|
const OPPOSITE_MAX_OVERSCROLL = 12
|
|
19
19
|
|
|
20
|
+
/** A bottom sheet's detents: the drag moves between them before it dismisses. */
|
|
21
|
+
export interface SwipeSnap {
|
|
22
|
+
/** Each detent's height in px, ascending */
|
|
23
|
+
heights: readonly number[]
|
|
24
|
+
/** The detent the sheet rests at */
|
|
25
|
+
index: number
|
|
26
|
+
/** A drag settled on another detent */
|
|
27
|
+
onSnap: (index: number) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
interface UseSwipeDismissOptions {
|
|
21
31
|
anchor: DrawerAnchor
|
|
22
32
|
enabled: boolean
|
|
@@ -27,6 +37,8 @@ interface UseSwipeDismissOptions {
|
|
|
27
37
|
* exists, and listeners must follow the element that is actually shown.
|
|
28
38
|
*/
|
|
29
39
|
panel: HTMLDivElement | null
|
|
40
|
+
/** Bottom anchor with snapPoints: drag between detents, dismiss below the lowest */
|
|
41
|
+
snap?: SwipeSnap | null
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
interface SwipeState {
|
|
@@ -39,6 +51,8 @@ interface SwipeState {
|
|
|
39
51
|
panelSize: number
|
|
40
52
|
/** Inner Duro scroll viewport the gesture started in; capture is deferred until the axis is decided */
|
|
41
53
|
deferredScroll: HTMLElement | null
|
|
54
|
+
/** Started on a snap sheet's grabber: a tap stays its click, a drag moves the sheet */
|
|
55
|
+
fromGrabber: boolean
|
|
42
56
|
pointerId: number
|
|
43
57
|
}
|
|
44
58
|
|
|
@@ -49,7 +63,13 @@ interface SwipeState {
|
|
|
49
63
|
* Opposite direction: iOS-style — panel stays anchored, shows subtle
|
|
50
64
|
* overscroll feedback (box-shadow intensifies), then snaps back.
|
|
51
65
|
*/
|
|
52
|
-
export function useSwipeDismiss({
|
|
66
|
+
export function useSwipeDismiss({
|
|
67
|
+
anchor,
|
|
68
|
+
enabled,
|
|
69
|
+
onDismiss,
|
|
70
|
+
panel,
|
|
71
|
+
snap = null,
|
|
72
|
+
}: UseSwipeDismissOptions) {
|
|
53
73
|
const stateRef = useRef<SwipeState>({
|
|
54
74
|
startX: 0,
|
|
55
75
|
startY: 0,
|
|
@@ -59,8 +79,13 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
59
79
|
swiping: false,
|
|
60
80
|
panelSize: 0,
|
|
61
81
|
deferredScroll: null,
|
|
82
|
+
fromGrabber: false,
|
|
62
83
|
pointerId: 0,
|
|
63
84
|
})
|
|
85
|
+
// Read at event time, so a settle that re-renders mid-gesture is never stale.
|
|
86
|
+
const snapRef = useRef(snap)
|
|
87
|
+
snapRef.current = snap
|
|
88
|
+
const snapping = snap !== null
|
|
64
89
|
|
|
65
90
|
const isHorizontal = anchor === 'left' || anchor === 'right'
|
|
66
91
|
|
|
@@ -117,6 +142,8 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
117
142
|
const resetStyles = useCallback((panelEl: HTMLDivElement) => {
|
|
118
143
|
panelEl.style.transform = ''
|
|
119
144
|
panelEl.style.boxShadow = ''
|
|
145
|
+
panelEl.style.height = ''
|
|
146
|
+
panelEl.style.transition = ''
|
|
120
147
|
|
|
121
148
|
const backdrop = panelEl.closest('[data-drawer-viewport]')
|
|
122
149
|
?.previousElementSibling as HTMLElement | null
|
|
@@ -178,19 +205,79 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
178
205
|
setTimeout(cleanup, 250)
|
|
179
206
|
}, [])
|
|
180
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Snap sheet drag: the visible height follows the finger. Between the lowest
|
|
210
|
+
* and the largest detent the panel resizes; past the largest it resists;
|
|
211
|
+
* below the lowest it slides down and the backdrop fades.
|
|
212
|
+
*/
|
|
213
|
+
const applySnapDrag = useCallback((movement: number, panelEl: HTMLDivElement) => {
|
|
214
|
+
const current = snapRef.current
|
|
215
|
+
if (!current) return
|
|
216
|
+
const {heights, index} = current
|
|
217
|
+
const low = heights[0]
|
|
218
|
+
const high = heights[heights.length - 1]
|
|
219
|
+
const visible = heights[index] - movement
|
|
220
|
+
const backdrop = backdropOf(panelEl)
|
|
221
|
+
if (visible > high) {
|
|
222
|
+
panelEl.style.height = `${high + rubberBand(visible - high, SNAP_MAX_OVERSHOOT)}px`
|
|
223
|
+
panelEl.style.transform = ''
|
|
224
|
+
} else if (visible >= low) {
|
|
225
|
+
panelEl.style.height = `${visible}px`
|
|
226
|
+
panelEl.style.transform = ''
|
|
227
|
+
} else {
|
|
228
|
+
panelEl.style.height = `${low}px`
|
|
229
|
+
panelEl.style.transform = `translateY(${low - visible}px)`
|
|
230
|
+
}
|
|
231
|
+
if (backdrop) backdrop.style.opacity = String(Math.min(1, Math.max(0, visible / low)))
|
|
232
|
+
}, [])
|
|
233
|
+
|
|
234
|
+
/** Settle a snap sheet on a detent, then hand the height back to React */
|
|
235
|
+
const animateToDetent = useCallback((panelEl: HTMLDivElement, target: number) => {
|
|
236
|
+
const current = snapRef.current
|
|
237
|
+
if (!current) return
|
|
238
|
+
const ease = `${SNAP_DURATION}ms cubic-bezier(0.25, 1, 0.5, 1)`
|
|
239
|
+
panelEl.style.transition = `height ${ease}, transform ${ease}`
|
|
240
|
+
panelEl.style.height = `${current.heights[target]}px`
|
|
241
|
+
panelEl.style.transform = ''
|
|
242
|
+
const backdrop = backdropOf(panelEl)
|
|
243
|
+
if (backdrop) {
|
|
244
|
+
backdrop.style.transition = `opacity ${ease}`
|
|
245
|
+
backdrop.style.opacity = ''
|
|
246
|
+
}
|
|
247
|
+
let settled = false
|
|
248
|
+
const done = () => {
|
|
249
|
+
if (settled) return
|
|
250
|
+
settled = true
|
|
251
|
+
current.onSnap(target)
|
|
252
|
+
// React now sizes the panel at this detent: drop the inline copy.
|
|
253
|
+
requestAnimationFrame(() => {
|
|
254
|
+
panelEl.style.transition = ''
|
|
255
|
+
panelEl.style.height = ''
|
|
256
|
+
if (backdrop) backdrop.style.transition = ''
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
panelEl.addEventListener('transitionend', done, {once: true})
|
|
260
|
+
setTimeout(done, SNAP_DURATION + 50)
|
|
261
|
+
}, [])
|
|
262
|
+
|
|
181
263
|
useEffect(() => {
|
|
182
264
|
if (!enabled || !panel) return
|
|
183
265
|
|
|
184
266
|
function handlePointerDown(e: PointerEvent) {
|
|
185
267
|
if (e.button !== 0) return
|
|
186
268
|
const target = e.target as HTMLElement
|
|
187
|
-
const
|
|
269
|
+
const fromGrabber = snapping && target.closest('[data-drawer-grabber]') !== null
|
|
270
|
+
const scrollEl = fromGrabber ? null : target.closest<HTMLElement>('[data-duro-scroll]')
|
|
188
271
|
// Outside a ScrollArea a control keeps its press. Inside one the gesture
|
|
189
272
|
// is decided on the first move even from a control: a list of toggles is
|
|
190
273
|
// all buttons, and touch adjustment snaps a finger onto one. A tap
|
|
191
274
|
// (under the 8px slop) still presses it; a drag never captures before
|
|
192
275
|
// the decision, so the release lands on the panel, not the control.
|
|
193
|
-
if (
|
|
276
|
+
if (
|
|
277
|
+
!fromGrabber &&
|
|
278
|
+
!scrollEl &&
|
|
279
|
+
target.closest('button, a, input, textarea, select, [role="button"]')
|
|
280
|
+
) {
|
|
194
281
|
return
|
|
195
282
|
}
|
|
196
283
|
|
|
@@ -206,11 +293,13 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
206
293
|
|
|
207
294
|
state.pointerId = e.pointerId
|
|
208
295
|
state.deferredScroll = scrollEl
|
|
296
|
+
state.fromGrabber = fromGrabber
|
|
209
297
|
|
|
210
298
|
panel!.dataset.panelSize = String(state.panelSize)
|
|
211
299
|
|
|
212
|
-
// Inside a Duro ScrollArea the scroll may own the gesture
|
|
213
|
-
|
|
300
|
+
// Inside a Duro ScrollArea the scroll may own the gesture, and a press on
|
|
301
|
+
// the grabber may be a tap: wait for the first move.
|
|
302
|
+
if (!state.deferredScroll && !fromGrabber) beginCapture(e.pointerId)
|
|
214
303
|
}
|
|
215
304
|
|
|
216
305
|
function beginCapture(pointerId: number) {
|
|
@@ -219,6 +308,14 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
219
308
|
// Freeze CSS animation — we take over positioning.
|
|
220
309
|
// Never restore it; the panel is already at its final resting position.
|
|
221
310
|
panel!.style.animation = 'none'
|
|
311
|
+
// A snap sheet animates its height between detents; the finger drives it now.
|
|
312
|
+
if (snapping) panel!.style.transition = 'none'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A snap sheet below its largest detent grows when dragged up from its content */
|
|
316
|
+
function canGrow() {
|
|
317
|
+
const current = snapRef.current
|
|
318
|
+
return current !== null && current.index < current.heights.length - 1
|
|
222
319
|
}
|
|
223
320
|
|
|
224
321
|
/** True when the viewport can't scroll further against the dismiss direction */
|
|
@@ -240,7 +337,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
240
337
|
|
|
241
338
|
if (!state.swiping) {
|
|
242
339
|
const totalMovement = Math.sqrt(dx * dx + dy * dy)
|
|
243
|
-
if (totalMovement <
|
|
340
|
+
if (totalMovement < DRAG_SLOP) return
|
|
244
341
|
|
|
245
342
|
if (isHorizontal && dx > dy) {
|
|
246
343
|
state.swiping = true
|
|
@@ -250,13 +347,19 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
250
347
|
// Wrong axis — abort gesture
|
|
251
348
|
state.startTime = 0
|
|
252
349
|
state.deferredScroll = null
|
|
350
|
+
state.fromGrabber = false
|
|
253
351
|
return
|
|
254
352
|
}
|
|
255
353
|
|
|
256
354
|
const scrollEl = state.deferredScroll
|
|
257
|
-
if (
|
|
355
|
+
if (state.fromGrabber) {
|
|
356
|
+
beginCapture(state.pointerId)
|
|
357
|
+
} else if (scrollEl) {
|
|
258
358
|
state.deferredScroll = null
|
|
259
|
-
|
|
359
|
+
const movement = getMovement(state)
|
|
360
|
+
const sheetOwns =
|
|
361
|
+
movement > 0 ? isAtDismissEdge(scrollEl) : snapping && movement < 0 && canGrow()
|
|
362
|
+
if (!sheetOwns) {
|
|
260
363
|
// The scroll owns this gesture: no swiping, no capture, no preventDefault
|
|
261
364
|
state.swiping = false
|
|
262
365
|
state.startTime = 0
|
|
@@ -269,7 +372,8 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
269
372
|
if (state.swiping) {
|
|
270
373
|
e.preventDefault()
|
|
271
374
|
const movement = getMovement(state)
|
|
272
|
-
|
|
375
|
+
if (snapping) applySnapDrag(movement, panel!)
|
|
376
|
+
else applyTransform(movement, panel!)
|
|
273
377
|
}
|
|
274
378
|
}
|
|
275
379
|
|
|
@@ -283,6 +387,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
283
387
|
|
|
284
388
|
state.startTime = 0
|
|
285
389
|
state.deferredScroll = null
|
|
390
|
+
state.fromGrabber = false
|
|
286
391
|
|
|
287
392
|
if (!state.swiping) {
|
|
288
393
|
return
|
|
@@ -290,16 +395,26 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
290
395
|
|
|
291
396
|
state.swiping = false
|
|
292
397
|
|
|
398
|
+
const current = snapRef.current
|
|
399
|
+
if (current) {
|
|
400
|
+
const settled = settleDetent({
|
|
401
|
+
heights: current.heights,
|
|
402
|
+
from: current.index,
|
|
403
|
+
height: current.heights[current.index] - movement,
|
|
404
|
+
velocity,
|
|
405
|
+
})
|
|
406
|
+
if (settled === 'dismiss') animateDismiss(panel!)
|
|
407
|
+
else animateToDetent(panel!, settled)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
|
|
293
411
|
if (movement <= 0) {
|
|
294
412
|
// Was dragging in opposite direction — just reset (no translate was applied)
|
|
295
413
|
resetStyles(panel!)
|
|
296
414
|
return
|
|
297
415
|
}
|
|
298
416
|
|
|
299
|
-
|
|
300
|
-
const shouldDismiss = fraction > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD
|
|
301
|
-
|
|
302
|
-
if (shouldDismiss) {
|
|
417
|
+
if (shouldDismissEdge(movement, state.panelSize, velocity)) {
|
|
303
418
|
animateDismiss(panel!)
|
|
304
419
|
} else {
|
|
305
420
|
animateSnapBack(panel!)
|
|
@@ -311,6 +426,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
311
426
|
state.startTime = 0
|
|
312
427
|
state.swiping = false
|
|
313
428
|
state.deferredScroll = null
|
|
429
|
+
state.fromGrabber = false
|
|
314
430
|
resetStyles(panel!)
|
|
315
431
|
}
|
|
316
432
|
|
|
@@ -340,8 +456,15 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
|
|
|
340
456
|
isHorizontal,
|
|
341
457
|
getMovement,
|
|
342
458
|
applyTransform,
|
|
459
|
+
applySnapDrag,
|
|
460
|
+
animateToDetent,
|
|
343
461
|
resetStyles,
|
|
344
462
|
animateSnapBack,
|
|
345
463
|
animateDismiss,
|
|
464
|
+
snapping,
|
|
346
465
|
])
|
|
347
466
|
}
|
|
467
|
+
|
|
468
|
+
function backdropOf(panelEl: HTMLDivElement) {
|
|
469
|
+
return panelEl.closest('[data-drawer-viewport]')?.previousElementSibling as HTMLElement | null
|
|
470
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components.',
|
|
5
|
+
'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components. On React Native, @duro-app/ui/form resolves to a native Form (a view, not a <form>): a Button type="submit" inside it runs the validated submit, since there is no DOM submit event.',
|
|
6
6
|
whenToUse: ['Any form that needs validation', 'Multi-field forms with submit handling'],
|
|
7
7
|
whenNotToUse: [
|
|
8
8
|
'Single standalone field without validation — use Field.Root directly',
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'
|
|
2
|
+
import {css, html} from 'react-strict-dom'
|
|
3
|
+
import {
|
|
4
|
+
useForm,
|
|
5
|
+
FormProvider,
|
|
6
|
+
type UseFormReturn,
|
|
7
|
+
type DefaultValues,
|
|
8
|
+
type FieldValues,
|
|
9
|
+
type FieldErrors,
|
|
10
|
+
type Path,
|
|
11
|
+
} from 'react-hook-form'
|
|
12
|
+
import {effectTsResolver} from '@hookform/resolvers/effect-ts'
|
|
13
|
+
import type {Schema} from 'effect'
|
|
14
|
+
import {FormContext, type LabelPosition, type NecessityIndicator} from './FormContext'
|
|
15
|
+
import {FormSubmitContext} from './FormSubmitContext'
|
|
16
|
+
|
|
17
|
+
export interface FormProps<T extends FieldValues> {
|
|
18
|
+
schema: Schema.Schema<T>
|
|
19
|
+
defaultValues: DefaultValues<T>
|
|
20
|
+
onSubmit: (data: T) => void | Promise<void>
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
labelPosition?: LabelPosition
|
|
23
|
+
necessityIndicator?: NecessityIndicator
|
|
24
|
+
validationErrors?: Partial<Record<Path<T>, string>>
|
|
25
|
+
children: ReactNode | ((methods: UseFormReturn<T>) => ReactNode)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const formStyles = css.create({
|
|
29
|
+
disabled: {
|
|
30
|
+
opacity: 0.5,
|
|
31
|
+
pointerEvents: 'none',
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Native Form (plan N0, Slice C): the web Form's validation and server-error
|
|
37
|
+
* sync, in a view rather than a <form>. A `type="submit"` Button inside it
|
|
38
|
+
* submits through FormSubmitContext.
|
|
39
|
+
*/
|
|
40
|
+
export function Form<T extends FieldValues>({
|
|
41
|
+
schema,
|
|
42
|
+
defaultValues,
|
|
43
|
+
onSubmit,
|
|
44
|
+
disabled = false,
|
|
45
|
+
labelPosition = 'top',
|
|
46
|
+
necessityIndicator = false,
|
|
47
|
+
validationErrors,
|
|
48
|
+
children,
|
|
49
|
+
}: FormProps<T>) {
|
|
50
|
+
const methods = useForm<T>({
|
|
51
|
+
resolver: effectTsResolver(schema),
|
|
52
|
+
defaultValues,
|
|
53
|
+
mode: 'onTouched',
|
|
54
|
+
reValidateMode: 'onChange',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Sync server-side validation errors into RHF
|
|
58
|
+
const prevErrorKeysRef = useRef<string[]>([])
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const prevKeys = prevErrorKeysRef.current
|
|
61
|
+
const nextKeys = validationErrors ? Object.keys(validationErrors) : []
|
|
62
|
+
|
|
63
|
+
// Clear errors that were removed
|
|
64
|
+
for (const key of prevKeys) {
|
|
65
|
+
if (!nextKeys.includes(key)) {
|
|
66
|
+
methods.clearErrors(key as Path<T>)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Set new errors
|
|
71
|
+
if (validationErrors) {
|
|
72
|
+
for (const [key, message] of Object.entries(validationErrors)) {
|
|
73
|
+
if (message) {
|
|
74
|
+
methods.setError(key as Path<T>, {type: 'server', message: message as string})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
prevErrorKeysRef.current = nextKeys
|
|
80
|
+
}, [validationErrors, methods])
|
|
81
|
+
|
|
82
|
+
const onInvalid = useCallback(
|
|
83
|
+
(errors: FieldErrors<T>) => {
|
|
84
|
+
const firstField = Object.keys(errors)[0]
|
|
85
|
+
if (firstField) {
|
|
86
|
+
methods.setFocus(firstField as Path<T>)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[methods],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// Native has no submit event: a type="submit" Button calls this instead.
|
|
93
|
+
const submit = useCallback(() => {
|
|
94
|
+
// A rejected onSubmit reaches the app's global error handler, not silence.
|
|
95
|
+
methods
|
|
96
|
+
.handleSubmit(onSubmit, onInvalid)()
|
|
97
|
+
.catch((error: unknown) => {
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
throw error
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}, [methods, onSubmit, onInvalid])
|
|
103
|
+
|
|
104
|
+
const formCtx = useMemo(
|
|
105
|
+
() => ({disabled, labelPosition, necessityIndicator}),
|
|
106
|
+
[disabled, labelPosition, necessityIndicator],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<FormProvider {...methods}>
|
|
111
|
+
<FormContext.Provider value={formCtx}>
|
|
112
|
+
<FormSubmitContext.Provider value={disabled ? null : submit}>
|
|
113
|
+
<html.div aria-disabled={disabled || undefined} style={disabled && formStyles.disabled}>
|
|
114
|
+
{typeof children === 'function' ? children(methods) : children}
|
|
115
|
+
</html.div>
|
|
116
|
+
</FormSubmitContext.Provider>
|
|
117
|
+
</FormContext.Provider>
|
|
118
|
+
</FormProvider>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {createContext, useContext} from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a `type="submit"` Button submits its Form where there is no DOM submit
|
|
5
|
+
* event: on native, Form provides its validated submit here. The web Form
|
|
6
|
+
* provides nothing; the browser's form submission does it.
|
|
7
|
+
*/
|
|
8
|
+
export const FormSubmitContext = createContext<(() => void) | null>(null)
|
|
9
|
+
|
|
10
|
+
export function useFormSubmit() {
|
|
11
|
+
return useContext(FormSubmitContext)
|
|
12
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Popup list of options for an input that keeps focus — an editor typeahead (mentions, slash commands) or a custom combobox. The anchor owns focus and the keyboard: it sets the controlled highlightedId and spreads Listbox.getAnchorProps() for aria-controls / aria-expanded / aria-activedescendant. A press on an option never takes focus from the anchor. Portalled and placed against anchor() (an input rect or a caret rect), capped at a size-token maxHeight (default listMaxH).',
|
|
5
|
+
'Popup list of options for an input that keeps focus — an editor typeahead (mentions, slash commands) or a custom combobox. The anchor owns focus and the keyboard: it sets the controlled highlightedId and spreads Listbox.getAnchorProps() for aria-controls / aria-expanded / aria-activedescendant. A press on an option never takes focus from the anchor. Portalled and placed against anchor() (an input rect or a caret rect), capped at a size-token maxHeight (default listMaxH). On React Native it is a plain list laid out where it is rendered (inside a Select or Combobox sheet): nothing is anchored or highlighted, the selected option is filled with a trailing check, and a press calls onSelect.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Typeahead inside a rich-text editor, where focus must stay in the editor',
|
|
8
8
|
'A custom combobox whose input is not Combobox.Input',
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {createContext, type ReactNode, type Ref, useContext} from 'react'
|
|
2
|
+
import {View} from 'react-native'
|
|
3
|
+
import {Pressable} from 'react-native-gesture-handler'
|
|
4
|
+
import {html} from 'react-strict-dom'
|
|
5
|
+
import {sheetStyles} from './native.css'
|
|
6
|
+
import {textOf} from '../../shared/textOf'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native Listbox (plan N0, Slice C): a plain list of options, laid out where
|
|
10
|
+
* it is rendered — inside a Select or Combobox sheet. Nothing is anchored and
|
|
11
|
+
* nothing is highlighted: native has no arrow keys (Decision 6), so each option
|
|
12
|
+
* is its own screen-reader stop and a press chooses it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ListboxMaxHeight = 'listMaxH' | 'listMaxHSm'
|
|
16
|
+
|
|
17
|
+
/** Default option id: the listbox id plus the value, made safe for an id. */
|
|
18
|
+
export function defaultOptionId(listboxId: string, value: string): string {
|
|
19
|
+
return `${listboxId}-option-${value.replace(/[^\w-]/g, (ch) => `_${ch.charCodeAt(0).toString(16)}`)}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ListboxAnchorProps {
|
|
23
|
+
role?: 'combobox'
|
|
24
|
+
'aria-controls': string | undefined
|
|
25
|
+
'aria-expanded': boolean
|
|
26
|
+
'aria-activedescendant': string | undefined
|
|
27
|
+
'aria-autocomplete': 'list'
|
|
28
|
+
'aria-haspopup': 'listbox'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The web's anchor ARIA, kept for one API: RSD drops these props on native. */
|
|
32
|
+
export function getAnchorProps({
|
|
33
|
+
id,
|
|
34
|
+
open,
|
|
35
|
+
highlightedId,
|
|
36
|
+
}: {
|
|
37
|
+
id: string
|
|
38
|
+
open: boolean
|
|
39
|
+
highlightedId: string | null | undefined
|
|
40
|
+
}): ListboxAnchorProps {
|
|
41
|
+
return {
|
|
42
|
+
'aria-controls': open ? id : undefined,
|
|
43
|
+
'aria-expanded': open,
|
|
44
|
+
'aria-activedescendant': open && highlightedId ? highlightedId : undefined,
|
|
45
|
+
'aria-autocomplete': 'list',
|
|
46
|
+
'aria-haspopup': 'listbox',
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ListboxContextValue {
|
|
51
|
+
onSelect?: (value: string) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ListboxContext = createContext<ListboxContextValue | null>(null)
|
|
55
|
+
|
|
56
|
+
function useListbox(part: string) {
|
|
57
|
+
const ctx = useContext(ListboxContext)
|
|
58
|
+
if (!ctx) throw new Error(`Listbox.${part} must be used within Listbox.Root`)
|
|
59
|
+
return ctx
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RootProps {
|
|
63
|
+
id: string
|
|
64
|
+
open: boolean
|
|
65
|
+
/** A press on an option. */
|
|
66
|
+
onSelect?: (value: string) => void
|
|
67
|
+
/** Accessible name of the list. */
|
|
68
|
+
'aria-label'?: string
|
|
69
|
+
children: ReactNode
|
|
70
|
+
// The web's placement and keyboard props; a native list flows in place.
|
|
71
|
+
anchor?: () => unknown
|
|
72
|
+
matchAnchorWidth?: boolean
|
|
73
|
+
highlightedId?: string | null
|
|
74
|
+
onHighlight?: (id: string | null) => void
|
|
75
|
+
getOptionId?: (value: string) => string
|
|
76
|
+
maxHeight?: ListboxMaxHeight
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Root({id, open, onSelect, 'aria-label': ariaLabel, children}: RootProps) {
|
|
80
|
+
if (!open) return null
|
|
81
|
+
return (
|
|
82
|
+
<ListboxContext.Provider value={{onSelect}}>
|
|
83
|
+
<html.div id={id} role="listbox" aria-label={ariaLabel} style={sheetStyles.list}>
|
|
84
|
+
{children}
|
|
85
|
+
</html.div>
|
|
86
|
+
</ListboxContext.Provider>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface OptionProps {
|
|
91
|
+
value: string
|
|
92
|
+
id?: string
|
|
93
|
+
/** The committed choice: filled, with a trailing check. */
|
|
94
|
+
selected?: boolean
|
|
95
|
+
disabled?: boolean
|
|
96
|
+
children: ReactNode
|
|
97
|
+
ref?: Ref<unknown>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Option({value, selected = false, disabled = false, children, ref}: OptionProps) {
|
|
101
|
+
const {onSelect} = useListbox('Option')
|
|
102
|
+
const choose = () => {
|
|
103
|
+
if (!disabled) onSelect?.(value)
|
|
104
|
+
}
|
|
105
|
+
// Two hosts, one job each. The View is what a screen reader reads: a
|
|
106
|
+
// selected button named by its text alone, activated by a double-tap.
|
|
107
|
+
// Gesture Handler's Pressable takes the touch natively, since an RN
|
|
108
|
+
// Pressable loses the first tap with the keyboard up to any ScrollView
|
|
109
|
+
// above the sheet's Modal (keyboardShouldPersistTaps 'never', the default);
|
|
110
|
+
// its native button does not expose a role or state, so it is hidden.
|
|
111
|
+
return (
|
|
112
|
+
<View
|
|
113
|
+
ref={ref as Ref<never>}
|
|
114
|
+
accessible
|
|
115
|
+
accessibilityRole="button"
|
|
116
|
+
accessibilityLabel={textOf(children) || undefined}
|
|
117
|
+
accessibilityState={{selected, disabled}}
|
|
118
|
+
accessibilityActions={ACTIVATE}
|
|
119
|
+
onAccessibilityAction={(e) => {
|
|
120
|
+
if (e.nativeEvent.actionName === 'activate') choose()
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<Pressable accessible={false} disabled={disabled} onPress={choose}>
|
|
124
|
+
{({pressed}) => (
|
|
125
|
+
<html.div style={[sheetStyles.option, (selected || pressed) && sheetStyles.optionFilled]}>
|
|
126
|
+
<html.span style={[sheetStyles.label, disabled && sheetStyles.labelDisabled]}>
|
|
127
|
+
{children}
|
|
128
|
+
</html.span>
|
|
129
|
+
{selected && (
|
|
130
|
+
<html.span aria-hidden style={sheetStyles.check}>
|
|
131
|
+
✓
|
|
132
|
+
</html.span>
|
|
133
|
+
)}
|
|
134
|
+
</html.div>
|
|
135
|
+
)}
|
|
136
|
+
</Pressable>
|
|
137
|
+
</View>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ACTIVATE = [{name: 'activate'}] as const
|
|
142
|
+
|
|
143
|
+
/** Shown in place of options when there are none, or none match. */
|
|
144
|
+
function Empty({children}: {children: ReactNode}) {
|
|
145
|
+
return <html.div style={sheetStyles.empty}>{children}</html.div>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const Listbox = {
|
|
149
|
+
Root,
|
|
150
|
+
Option,
|
|
151
|
+
Empty,
|
|
152
|
+
getAnchorProps,
|
|
153
|
+
}
|