@duro-app/ui 5.11.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/compiler-runtime-BuugS4D0.js.map +1 -1
- package/dist/components/Alert/Alert.d.ts.map +1 -1
- 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 +2 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/styles.css.d.ts +1 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/Callout/Callout.d.ts.map +1 -1
- package/dist/components/Callout/styles.css.d.ts +3 -0
- package/dist/components/Callout/styles.css.d.ts.map +1 -1
- package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +2 -17
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
- package/dist/components/Dialog/native.css.d.ts +82 -0
- package/dist/components/Dialog/native.css.d.ts.map +1 -0
- package/dist/components/Dialog/parts.css.d.ts +82 -0
- package/dist/components/Dialog/parts.css.d.ts.map +1 -0
- package/dist/components/Dialog/parts.d.ts +35 -0
- package/dist/components/Dialog/parts.d.ts.map +1 -0
- package/dist/components/Dialog/styles.css.d.ts +1 -74
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- 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 +15 -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/Grid/styles.css.d.ts +1 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Icon/Icon.d.ts +3 -9
- package/dist/components/Icon/Icon.d.ts.map +1 -1
- package/dist/components/Icon/geometry.d.ts +60 -0
- package/dist/components/Icon/geometry.d.ts.map +1 -0
- package/dist/components/Icon/types.d.ts +15 -0
- package/dist/components/Icon/types.d.ts.map +1 -0
- package/dist/components/Input/Input.d.ts +5 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/styles.css.d.ts +1 -0
- package/dist/components/Input/styles.css.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +1 -0
- package/dist/components/LinkButton/styles.css.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/Meter/styles.css.d.ts +1 -0
- package/dist/components/Meter/styles.css.d.ts.map +1 -1
- package/dist/components/PageShell/styles.css.d.ts +2 -0
- package/dist/components/PageShell/styles.css.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/RadioGroup/RadioGroup.d.ts +1 -17
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.stories.d.ts +3 -0
- package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -1
- package/dist/components/RadioGroup/group.d.ts +31 -0
- package/dist/components/RadioGroup/group.d.ts.map +1 -0
- package/dist/components/RadioGroup/styles.css.d.ts +10 -0
- package/dist/components/RadioGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts +1 -0
- package/dist/components/ScrollArea/styles.css.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/Spinner/Spinner.d.ts +2 -6
- package/dist/components/Spinner/Spinner.d.ts.map +1 -1
- package/dist/components/Spinner/Spinner.types.d.ts +6 -0
- package/dist/components/Spinner/Spinner.types.d.ts.map +1 -0
- package/dist/components/Spinner/ring.css.d.ts +39 -0
- package/dist/components/Spinner/ring.css.d.ts.map +1 -0
- package/dist/components/Spinner/styles.css.d.ts +1 -28
- package/dist/components/Spinner/styles.css.d.ts.map +1 -1
- 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/StatusIcon/StatusIcon.d.ts.map +1 -1
- package/dist/components/StatusIcon/styles.css.d.ts +3 -0
- package/dist/components/StatusIcon/styles.css.d.ts.map +1 -1
- package/dist/components/Switch/Switch.d.ts +2 -18
- package/dist/components/Switch/Switch.d.ts.map +1 -1
- package/dist/components/Switch/Switch.types.d.ts +18 -0
- package/dist/components/Switch/Switch.types.d.ts.map +1 -0
- package/dist/components/TableOfContents/styles.css.d.ts +1 -0
- package/dist/components/TableOfContents/styles.css.d.ts.map +1 -1
- 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/Textarea/styles.css.d.ts +1 -0
- package/dist/components/Textarea/styles.css.d.ts.map +1 -1
- 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/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +11 -2
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- 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/components/VirtualTable/styles.css.d.ts +6 -0
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
- 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 +6202 -4794
- 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/portal/hostRegistry.d.ts +33 -0
- package/dist/shared/portal/hostRegistry.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/useOverlayOpenState.d.ts +40 -0
- package/dist/shared/useOverlayOpenState.d.ts.map +1 -0
- package/dist/table.js +141 -135
- package/dist/table.js.map +1 -1
- package/package.json +34 -4
- package/src/components/Alert/Alert.tsx +14 -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 +6 -5
- package/src/components/Button/styles.css.ts +2 -0
- package/src/components/Callout/Callout.tsx +17 -3
- package/src/components/Callout/styles.css.ts +5 -0
- package/src/components/Checkbox/Checkbox.native.tsx +67 -0
- package/src/components/Checkbox/Checkbox.tsx +1 -50
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +3 -0
- package/src/components/DetailPanel/DetailPanel.tsx +9 -45
- package/src/components/Dialog/Dialog.native.tsx +296 -0
- package/src/components/Dialog/Dialog.stories.tsx +71 -0
- package/src/components/Dialog/Dialog.tsx +41 -130
- package/src/components/Dialog/native.css.ts +78 -0
- package/src/components/Dialog/parts.css.ts +101 -0
- package/src/components/Dialog/parts.tsx +61 -0
- package/src/components/Dialog/styles.css.ts +2 -89
- package/src/components/Drawer/Drawer.meta.ts +2 -1
- package/src/components/Drawer/Drawer.native.tsx +469 -0
- package/src/components/Drawer/Drawer.tsx +63 -179
- 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 +19 -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/Grid/styles.css.ts +2 -0
- package/src/components/Icon/Icon.native.tsx +54 -0
- package/src/components/Icon/Icon.tsx +19 -430
- package/src/components/Icon/geometry.ts +456 -0
- package/src/components/Icon/types.ts +74 -0
- package/src/components/Input/Input.tsx +8 -0
- package/src/components/Input/styles.css.ts +2 -0
- package/src/components/LinkButton/styles.css.ts +2 -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/Meter/styles.css.ts +2 -0
- package/src/components/PageShell/styles.css.ts +4 -0
- 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/RadioGroup/RadioGroup.meta.ts +1 -1
- package/src/components/RadioGroup/RadioGroup.native.tsx +48 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +64 -1
- package/src/components/RadioGroup/RadioGroup.tsx +8 -83
- package/src/components/RadioGroup/group.tsx +85 -0
- package/src/components/RadioGroup/styles.css.ts +13 -0
- package/src/components/ScrollArea/styles.css.ts +2 -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/Spinner/Spinner.native.tsx +37 -0
- package/src/components/Spinner/Spinner.tsx +6 -15
- package/src/components/Spinner/Spinner.types.ts +6 -0
- package/src/components/Spinner/ring.css.ts +45 -0
- package/src/components/Spinner/styles.css.ts +2 -32
- 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/StatusIcon/StatusIcon.tsx +13 -2
- package/src/components/StatusIcon/styles.css.ts +5 -0
- package/src/components/Switch/Switch.native.tsx +45 -0
- package/src/components/Switch/Switch.tsx +22 -44
- package/src/components/Switch/Switch.types.ts +18 -0
- package/src/components/TableOfContents/styles.css.ts +2 -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/Textarea/styles.css.ts +2 -0
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +70 -10
- 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 +5 -3
- package/src/components/Toggle/styles.css.ts +14 -2
- 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/components/VirtualTable/styles.css.ts +12 -0
- package/src/hooks/useContainerBelow.native.ts +30 -0
- package/src/index.native.ts +61 -1
- package/src/index.ts +8 -1
- package/src/native-guard/globals.d.ts +10 -0
- package/src/native-guard/react-dom.d.ts +3 -0
- package/src/shared/ButtonHost.native.tsx +70 -0
- package/src/shared/ButtonHost.tsx +12 -0
- package/src/shared/accessibilityFocus.native.ts +23 -0
- package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
- package/src/shared/portal/OverlayModal.native.tsx +51 -0
- package/src/shared/portal/PortalHost.native.tsx +102 -0
- package/src/shared/portal/hostRegistry.ts +76 -0
- package/src/shared/portal/themeScope.native.ts +18 -0
- package/src/shared/pressableChild.ts +33 -0
- package/src/shared/safeArea.native.ts +22 -0
- package/src/shared/useKeyboardHeight.native.ts +18 -0
- package/src/shared/useOverlayOpenState.ts +128 -0
- package/src/shared/useScreenReaderEnabled.native.ts +19 -0
- package/dist/Table-ColfpgKV.js +0 -1552
- package/dist/Table-ColfpgKV.js.map +0 -1
- package/dist/components/ThemeProvider/ThemeProvider.native.d.ts +0 -10
- package/dist/components/ThemeProvider/ThemeProvider.native.d.ts.map +0 -1
- package/dist/index.native.d.ts +0 -33
- package/dist/index.native.d.ts.map +0 -1
- package/dist/platform.native.d.ts +0 -2
- package/dist/platform.native.d.ts.map +0 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {Children, type ComponentProps, type ReactNode, type Ref} from 'react'
|
|
2
|
+
import {Pressable} from 'react-native'
|
|
3
|
+
import {html} from 'react-strict-dom'
|
|
4
|
+
import {useTooltipHint} from '../components/Tooltip/TooltipHint'
|
|
5
|
+
|
|
6
|
+
export type ButtonHostProps = ComponentProps<typeof html.button>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The host element of Button and Toggle on native. Inside a Tooltip it is an
|
|
10
|
+
* RN Pressable, which carries the Tooltip's text as its own accessibility hint
|
|
11
|
+
* (plan N0, Decision 5: RSD drops `accessibilityHint` on html.*); the visual
|
|
12
|
+
* styles move to a div inside it. Elsewhere it is RSD's button, as before.
|
|
13
|
+
*
|
|
14
|
+
* holds-until: react-strict-dom maps aria-description to accessibilityHint
|
|
15
|
+
* (https://github.com/react/react-strict-dom/issues/524); then html.button
|
|
16
|
+
* carries the hint itself and this swap goes.
|
|
17
|
+
*/
|
|
18
|
+
export function ButtonHost(props: ButtonHostProps) {
|
|
19
|
+
const hint = useTooltipHint()
|
|
20
|
+
const content = textInSpans(props.children)
|
|
21
|
+
if (!hint) {
|
|
22
|
+
return (
|
|
23
|
+
<html.button {...props} style={props.style}>
|
|
24
|
+
{content}
|
|
25
|
+
</html.button>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
const {
|
|
29
|
+
ref,
|
|
30
|
+
style,
|
|
31
|
+
disabled,
|
|
32
|
+
onClick,
|
|
33
|
+
'aria-label': label,
|
|
34
|
+
'aria-pressed': pressed,
|
|
35
|
+
'aria-expanded': expanded,
|
|
36
|
+
} = props
|
|
37
|
+
return (
|
|
38
|
+
<Pressable
|
|
39
|
+
ref={ref as Ref<never>}
|
|
40
|
+
onPress={disabled ? undefined : () => onClick?.(undefined as never)}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
accessibilityRole="button"
|
|
43
|
+
accessibilityLabel={label ?? undefined}
|
|
44
|
+
accessibilityHint={hint}
|
|
45
|
+
accessibilityState={{
|
|
46
|
+
disabled: !!disabled,
|
|
47
|
+
...(pressed !== undefined ? {checked: !!pressed} : {}),
|
|
48
|
+
...(expanded !== undefined ? {expanded: !!expanded} : {}),
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<html.div style={style}>{content}</html.div>
|
|
52
|
+
</Pressable>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A label beside an icon (`<Icon/> Menu`): RSD wraps a button's text only
|
|
58
|
+
* when it is the sole child, and React Native refuses bare text in a view.
|
|
59
|
+
*/
|
|
60
|
+
function textInSpans(children: ReactNode): ReactNode {
|
|
61
|
+
const items = Children.toArray(children)
|
|
62
|
+
if (items.length < 2) return children
|
|
63
|
+
return items.map((child, i) =>
|
|
64
|
+
typeof child === 'string' || typeof child === 'number' ? (
|
|
65
|
+
<html.span key={i}>{child}</html.span>
|
|
66
|
+
) : (
|
|
67
|
+
child
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type {ComponentProps} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
|
|
4
|
+
export type ButtonHostProps = ComponentProps<typeof html.button>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The host element of Button and Toggle. On the web it is the <button>
|
|
8
|
+
* itself, with no component in between (a wrapper changed what a Form's
|
|
9
|
+
* submit focuses); the native variant (ButtonHost.native.tsx) can swap in an
|
|
10
|
+
* RN Pressable to carry a Tooltip's accessibility hint.
|
|
11
|
+
*/
|
|
12
|
+
export const ButtonHost: typeof html.button = html.button
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {AccessibilityInfo} from 'react-native'
|
|
2
|
+
|
|
3
|
+
const reported = new Set<string>()
|
|
4
|
+
|
|
5
|
+
type FocusTarget = Parameters<typeof AccessibilityInfo.sendAccessibilityEvent>[0]
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Moves the screen reader's focus to `target` (a host view, or a
|
|
9
|
+
* react-strict-dom element ref). Native overlays call it on open and, for the
|
|
10
|
+
* trigger, on close: the screen-reader contract that stands in for the web's
|
|
11
|
+
* DOM focus (plan N0, Decision 6). A target the platform refuses is logged
|
|
12
|
+
* once per caller, in release builds too, rather than dropped.
|
|
13
|
+
*/
|
|
14
|
+
export function focusForAccessibility(target: unknown, from: string): void {
|
|
15
|
+
if (target == null) return
|
|
16
|
+
try {
|
|
17
|
+
AccessibilityInfo.sendAccessibilityEvent(target as FocusTarget, 'focus')
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (reported.has(from)) return
|
|
20
|
+
reported.add(from)
|
|
21
|
+
console.warn(`[duro] ${from}: could not move accessibility focus: ${String(error)}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {type ReactNode, type RefObject, useEffect, useState} from 'react'
|
|
2
|
+
import {
|
|
3
|
+
type LayoutChangeEvent,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
useWindowDimensions,
|
|
7
|
+
View,
|
|
8
|
+
} from 'react-native'
|
|
9
|
+
import {SPACING_PX} from '@duro-app/tokens/keys'
|
|
10
|
+
import {
|
|
11
|
+
computePopoverPosition,
|
|
12
|
+
type PopoverAlign,
|
|
13
|
+
type PopoverRect,
|
|
14
|
+
type PopoverSide,
|
|
15
|
+
type PopoverSize,
|
|
16
|
+
} from '../../components/Popover/position'
|
|
17
|
+
import {OverlayModal} from './OverlayModal'
|
|
18
|
+
|
|
19
|
+
interface Measurable {
|
|
20
|
+
measureInWindow?: (cb: (x: number, y: number, width: number, height: number) => void) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AnchoredOverlayProps {
|
|
24
|
+
visible: boolean
|
|
25
|
+
/** The trigger's host: it is measured in window coordinates when the overlay opens */
|
|
26
|
+
anchorRef: RefObject<unknown>
|
|
27
|
+
side: PopoverSide
|
|
28
|
+
align: PopoverAlign
|
|
29
|
+
/** A backdrop press or Android's back button */
|
|
30
|
+
onRequestClose: () => void
|
|
31
|
+
/** The Modal has been presented: move accessibility focus in */
|
|
32
|
+
onShow?: () => void
|
|
33
|
+
/** iOS: the Modal has finished dismissing */
|
|
34
|
+
onDismiss?: () => void
|
|
35
|
+
children: ReactNode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A popup placed against its trigger on native (plan N0, Decision 3): the
|
|
40
|
+
* trigger is measured with `measureInWindow`, the popup with `onLayout`, and
|
|
41
|
+
* `computePopoverPosition` places it exactly as on the web. The OverlayModal
|
|
42
|
+
* is transparent and translucent, so its coordinates are window coordinates.
|
|
43
|
+
* Until both are measured the popup is laid out but not shown.
|
|
44
|
+
*/
|
|
45
|
+
export function AnchoredOverlay({
|
|
46
|
+
visible,
|
|
47
|
+
anchorRef,
|
|
48
|
+
side,
|
|
49
|
+
align,
|
|
50
|
+
onRequestClose,
|
|
51
|
+
onShow,
|
|
52
|
+
onDismiss,
|
|
53
|
+
children,
|
|
54
|
+
}: AnchoredOverlayProps) {
|
|
55
|
+
const window = useWindowDimensions()
|
|
56
|
+
const [anchor, setAnchor] = useState<PopoverRect | null>(null)
|
|
57
|
+
const [size, setSize] = useState<PopoverSize | null>(null)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!visible) {
|
|
61
|
+
setAnchor(null)
|
|
62
|
+
setSize(null)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const host = anchorRef.current as Measurable | null
|
|
66
|
+
host?.measureInWindow?.((left, top, width, height) => setAnchor({top, left, width, height}))
|
|
67
|
+
}, [visible, anchorRef, window.width, window.height])
|
|
68
|
+
|
|
69
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
70
|
+
const {width, height} = e.nativeEvent.layout
|
|
71
|
+
setSize((prev) => (prev?.width === width && prev.height === height ? prev : {width, height}))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const position =
|
|
75
|
+
anchor && size
|
|
76
|
+
? computePopoverPosition({
|
|
77
|
+
anchor,
|
|
78
|
+
popup: size,
|
|
79
|
+
viewport: {width: window.width, height: window.height},
|
|
80
|
+
side,
|
|
81
|
+
align,
|
|
82
|
+
offset: SPACING_PX.xs,
|
|
83
|
+
})
|
|
84
|
+
: null
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<OverlayModal
|
|
88
|
+
visible={visible}
|
|
89
|
+
onRequestClose={onRequestClose}
|
|
90
|
+
onShow={onShow}
|
|
91
|
+
onDismiss={onDismiss}
|
|
92
|
+
>
|
|
93
|
+
<Pressable
|
|
94
|
+
style={StyleSheet.absoluteFill}
|
|
95
|
+
onPress={onRequestClose}
|
|
96
|
+
accessible={false}
|
|
97
|
+
importantForAccessibility="no"
|
|
98
|
+
/>
|
|
99
|
+
<View
|
|
100
|
+
onLayout={onLayout}
|
|
101
|
+
style={[
|
|
102
|
+
layout.popup,
|
|
103
|
+
{maxWidth: window.width - 2 * SPACING_PX.sm},
|
|
104
|
+
position ? {top: position.top, left: position.left} : layout.unplaced,
|
|
105
|
+
]}
|
|
106
|
+
>
|
|
107
|
+
{children}
|
|
108
|
+
</View>
|
|
109
|
+
</OverlayModal>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Layout-only: where the popup sits, and hidden until it has been placed.
|
|
114
|
+
const layout = StyleSheet.create({
|
|
115
|
+
popup: {position: 'absolute'},
|
|
116
|
+
unplaced: {top: 0, left: 0, opacity: 0},
|
|
117
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import {Modal, StyleSheet} from 'react-native'
|
|
3
|
+
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
|
4
|
+
import {PortalHost} from './PortalHost'
|
|
5
|
+
|
|
6
|
+
interface OverlayModalProps {
|
|
7
|
+
visible: boolean
|
|
8
|
+
/** Android back button: the dismissal Escape is on the web. */
|
|
9
|
+
onRequestClose: () => void
|
|
10
|
+
/** The Modal has been presented: move accessibility focus in. */
|
|
11
|
+
onShow?: () => void
|
|
12
|
+
/** iOS: the Modal has finished dismissing (presenting another one is safe). */
|
|
13
|
+
onDismiss?: () => void
|
|
14
|
+
children: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The window every interactive native overlay renders in (plan N0,
|
|
19
|
+
* Decision 2). RN `Modal` is a React portal, so the app's contexts reach the
|
|
20
|
+
* content, and it presents above natively presented routes. Duro draws its own
|
|
21
|
+
* backdrop and motion, so the Modal's own animation is off.
|
|
22
|
+
*
|
|
23
|
+
* Content gets its own GestureHandlerRootView: on Android a Modal is a
|
|
24
|
+
* separate window that the app's root view does not cover. A PortalHost on
|
|
25
|
+
* top lets toasts raised while it is open draw above it.
|
|
26
|
+
*/
|
|
27
|
+
export function OverlayModal({
|
|
28
|
+
visible,
|
|
29
|
+
onRequestClose,
|
|
30
|
+
onShow,
|
|
31
|
+
onDismiss,
|
|
32
|
+
children,
|
|
33
|
+
}: OverlayModalProps) {
|
|
34
|
+
return (
|
|
35
|
+
<Modal
|
|
36
|
+
visible={visible}
|
|
37
|
+
transparent
|
|
38
|
+
statusBarTranslucent
|
|
39
|
+
navigationBarTranslucent
|
|
40
|
+
animationType="none"
|
|
41
|
+
onRequestClose={onRequestClose}
|
|
42
|
+
onShow={onShow}
|
|
43
|
+
onDismiss={onDismiss}
|
|
44
|
+
>
|
|
45
|
+
<GestureHandlerRootView style={StyleSheet.absoluteFill}>
|
|
46
|
+
{children}
|
|
47
|
+
<PortalHost />
|
|
48
|
+
</GestureHandlerRootView>
|
|
49
|
+
</Modal>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {StyleSheet, View} from 'react-native'
|
|
11
|
+
import {css, html} from 'react-strict-dom'
|
|
12
|
+
import type {HostRegistry} from './hostRegistry'
|
|
13
|
+
import {ResolvedColorsContext, ThemeScopeContext} from './themeScope'
|
|
14
|
+
|
|
15
|
+
/** The registry the outermost ThemeProvider owns; null outside every provider. */
|
|
16
|
+
export const HostRegistryContext = createContext<HostRegistry | null>(null)
|
|
17
|
+
|
|
18
|
+
function useHostRegistry(component: string): HostRegistry {
|
|
19
|
+
const registry = useContext(HostRegistryContext)
|
|
20
|
+
if (!registry) throw new Error(`${component} must be rendered inside a Duro ThemeProvider`)
|
|
21
|
+
return registry
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const styles = css.create({
|
|
25
|
+
// An entry covers its host without taking touches the entry does not draw on.
|
|
26
|
+
entry: {
|
|
27
|
+
position: 'absolute',
|
|
28
|
+
top: 0,
|
|
29
|
+
left: 0,
|
|
30
|
+
right: 0,
|
|
31
|
+
bottom: 0,
|
|
32
|
+
pointerEvents: 'box-none',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
type DivStyle = Parameters<typeof html.div>[0]['style']
|
|
37
|
+
|
|
38
|
+
interface PortalHostProps {
|
|
39
|
+
/**
|
|
40
|
+
* `base`: the app root, always below every other host (the ThemeProvider
|
|
41
|
+
* draws it). `presented` (the default): a natively presented route, which
|
|
42
|
+
* a consumer mounts in that route's layout so toasts draw above it.
|
|
43
|
+
*/
|
|
44
|
+
placement?: 'base' | 'presented'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where passive overlays (toasts) draw on native. The ThemeProvider draws the
|
|
49
|
+
* root host and each open Duro Modal draws its own; mount one inside a
|
|
50
|
+
* natively presented route (`presentation: 'modal'`), or toasts raised there
|
|
51
|
+
* draw behind it.
|
|
52
|
+
*/
|
|
53
|
+
export function PortalHost({placement = 'presented'}: PortalHostProps) {
|
|
54
|
+
const registry = useHostRegistry('PortalHost')
|
|
55
|
+
const id = useId()
|
|
56
|
+
useLayoutEffect(() => registry.addHost(id, placement), [registry, id, placement])
|
|
57
|
+
const {topHost, entries} = useSyncExternalStore(registry.subscribe, registry.getSnapshot)
|
|
58
|
+
if (topHost !== id) return null
|
|
59
|
+
return (
|
|
60
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
|
61
|
+
{entries.map((entry) => (
|
|
62
|
+
<View key={entry.key} style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
|
63
|
+
{entry.node}
|
|
64
|
+
</View>
|
|
65
|
+
))}
|
|
66
|
+
</View>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PortalProps {
|
|
71
|
+
/** Numeric layer from `LAYERS` in `@duro-app/tokens/keys`. */
|
|
72
|
+
layer: number
|
|
73
|
+
children: ReactNode
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Draws its children in the top host, above the app and every open Duro
|
|
78
|
+
* Modal. The children keep the source's theme and resolved colours; no other
|
|
79
|
+
* context crosses, so pass data, not components that read the app's contexts.
|
|
80
|
+
*/
|
|
81
|
+
export function Portal({layer, children}: PortalProps) {
|
|
82
|
+
const registry = useHostRegistry('Portal')
|
|
83
|
+
const key = useId()
|
|
84
|
+
const scope = useContext(ThemeScopeContext)
|
|
85
|
+
const resolvedColors = useContext(ResolvedColorsContext)
|
|
86
|
+
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
registry.setEntry({
|
|
89
|
+
key,
|
|
90
|
+
layer,
|
|
91
|
+
node: (
|
|
92
|
+
<ThemeScopeContext.Provider value={scope}>
|
|
93
|
+
<ResolvedColorsContext.Provider value={resolvedColors}>
|
|
94
|
+
<html.div style={[...scope.styles, styles.entry] as DivStyle}>{children}</html.div>
|
|
95
|
+
</ResolvedColorsContext.Provider>
|
|
96
|
+
</ThemeScopeContext.Provider>
|
|
97
|
+
),
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
useEffect(() => () => registry.removeEntry(key), [registry, key])
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
/** One passive surface (a toast region) waiting for a host to draw it. */
|
|
4
|
+
export interface PortalEntry {
|
|
5
|
+
readonly key: string
|
|
6
|
+
/** Numeric layer from `LAYERS` in `@duro-app/tokens/keys`; higher draws on top. */
|
|
7
|
+
readonly layer: number
|
|
8
|
+
readonly node: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HostRegistrySnapshot {
|
|
12
|
+
/** The host that draws every entry: the last one presented, else the root. */
|
|
13
|
+
readonly topHost: string | undefined
|
|
14
|
+
/** Entries ordered by layer, lowest first (equal layers keep insertion order). */
|
|
15
|
+
readonly entries: readonly PortalEntry[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The hosts that passive surfaces draw into, on native: the app root, each
|
|
20
|
+
* open Duro Modal and each `PortalHost` a consumer mounts in a natively
|
|
21
|
+
* presented route. Entries render in the top host only, so a toast raised
|
|
22
|
+
* under an open Dialog draws above it.
|
|
23
|
+
*
|
|
24
|
+
* A plain store (subscribe / getSnapshot) for `useSyncExternalStore`; it holds
|
|
25
|
+
* no React state and imports nothing from React Native.
|
|
26
|
+
*/
|
|
27
|
+
export interface HostRegistry {
|
|
28
|
+
/** Adds a host. A `base` host (the app root) always sits below the others. */
|
|
29
|
+
addHost(id: string, placement: 'base' | 'presented'): () => void
|
|
30
|
+
setEntry(entry: PortalEntry): void
|
|
31
|
+
removeEntry(key: string): void
|
|
32
|
+
getSnapshot(): HostRegistrySnapshot
|
|
33
|
+
subscribe(listener: () => void): () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createHostRegistry(): HostRegistry {
|
|
37
|
+
let hosts: readonly string[] = []
|
|
38
|
+
let entries: readonly PortalEntry[] = []
|
|
39
|
+
let snapshot: HostRegistrySnapshot = {topHost: undefined, entries}
|
|
40
|
+
const listeners = new Set<() => void>()
|
|
41
|
+
|
|
42
|
+
const publish = () => {
|
|
43
|
+
snapshot = {topHost: hosts[hosts.length - 1], entries}
|
|
44
|
+
for (const listener of listeners) listener()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
addHost(id, placement) {
|
|
49
|
+
hosts = placement === 'base' ? [id, ...hosts] : [...hosts, id]
|
|
50
|
+
publish()
|
|
51
|
+
return () => {
|
|
52
|
+
hosts = hosts.filter((host) => host !== id)
|
|
53
|
+
publish()
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
setEntry(entry) {
|
|
57
|
+
const others = entries.filter((existing) => existing.key !== entry.key)
|
|
58
|
+
const at = others.findIndex((existing) => existing.layer > entry.layer)
|
|
59
|
+
entries =
|
|
60
|
+
at === -1 ? [...others, entry] : [...others.slice(0, at), entry, ...others.slice(at)]
|
|
61
|
+
publish()
|
|
62
|
+
},
|
|
63
|
+
removeEntry(key) {
|
|
64
|
+
if (!entries.some((entry) => entry.key === key)) return
|
|
65
|
+
entries = entries.filter((entry) => entry.key !== key)
|
|
66
|
+
publish()
|
|
67
|
+
},
|
|
68
|
+
getSnapshot: () => snapshot,
|
|
69
|
+
subscribe(listener) {
|
|
70
|
+
listeners.add(listener)
|
|
71
|
+
return () => {
|
|
72
|
+
listeners.delete(listener)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {createContext} from 'react'
|
|
2
|
+
import type {ColorToken} from '@duro-app/tokens/keys'
|
|
3
|
+
import {darkColors} from '@duro-app/tokens/raw'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The theme styles in force where a component renders: every enclosing
|
|
7
|
+
* ThemeProvider's createTheme objects, outermost first. A native Portal
|
|
8
|
+
* re-applies them around its entry, because the host it draws into sits
|
|
9
|
+
* outside the source's ThemeProviders.
|
|
10
|
+
*/
|
|
11
|
+
export interface ThemeScope {
|
|
12
|
+
readonly styles: readonly unknown[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ThemeScopeContext = createContext<ThemeScope>({styles: []})
|
|
16
|
+
|
|
17
|
+
/** The nearest ThemeProvider's palette as colour strings (native). */
|
|
18
|
+
export const ResolvedColorsContext = createContext<Record<ColorToken, string>>(darkColors)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type Ref,
|
|
8
|
+
} from 'react'
|
|
9
|
+
import {mergeRefs} from './mergeRefs'
|
|
10
|
+
|
|
11
|
+
interface PressableChildProps {
|
|
12
|
+
onClick?: () => void
|
|
13
|
+
ref?: Ref<unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Native overlay triggers and closes: native presses do not bubble to a
|
|
18
|
+
* wrapper as web clicks do, so the one child (a Button) gets the handler
|
|
19
|
+
* itself, after its own. `ref` also receives the child's host, where focus
|
|
20
|
+
* returns when the overlay closes.
|
|
21
|
+
*/
|
|
22
|
+
export function withPress(children: ReactNode, onPress: () => void, ref?: Ref<unknown>) {
|
|
23
|
+
const child = Children.only(children)
|
|
24
|
+
if (!isValidElement<PressableChildProps>(child)) return null
|
|
25
|
+
const own = child.props.onClick
|
|
26
|
+
return cloneElement(child as ReactElement<PressableChildProps>, {
|
|
27
|
+
...(ref ? {ref: mergeRefs(child.props.ref, ref)} : {}),
|
|
28
|
+
onClick: () => {
|
|
29
|
+
own?.()
|
|
30
|
+
onPress()
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {useContext} from 'react'
|
|
2
|
+
import {SafeAreaInsetsContext, type EdgeInsets} from 'react-native-safe-area-context'
|
|
3
|
+
import {devWarnOnce} from './devWarnOnce'
|
|
4
|
+
|
|
5
|
+
const NO_INSETS: EdgeInsets = {top: 0, right: 0, bottom: 0, left: 0}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The window's safe-area insets from the app's SafeAreaProvider (expo-router
|
|
9
|
+
* mounts one). Modal content is a React portal, so overlays inside an
|
|
10
|
+
* OverlayModal read the same insets. Without a provider, no insets, and a
|
|
11
|
+
* development warning names the missing provider.
|
|
12
|
+
*/
|
|
13
|
+
export function useSafeAreaInsets(): EdgeInsets {
|
|
14
|
+
const insets = useContext(SafeAreaInsetsContext)
|
|
15
|
+
if (insets) return insets
|
|
16
|
+
devWarnOnce(
|
|
17
|
+
'safeArea',
|
|
18
|
+
'no-provider',
|
|
19
|
+
'No SafeAreaProvider above this Duro overlay: it draws without safe-area insets. Wrap the app in react-native-safe-area-context’s SafeAreaProvider.',
|
|
20
|
+
)
|
|
21
|
+
return NO_INSETS
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {useEffect, useState} from 'react'
|
|
2
|
+
import {Keyboard, Platform} from 'react-native'
|
|
3
|
+
|
|
4
|
+
/** The on-screen keyboard's height while it is shown, else 0. */
|
|
5
|
+
export function useKeyboardHeight(): number {
|
|
6
|
+
const [height, setHeight] = useState(0)
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
// iOS announces the keyboard before it moves, Android only after.
|
|
9
|
+
const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
|
|
10
|
+
const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
|
|
11
|
+
const subs = [
|
|
12
|
+
Keyboard.addListener(show, (e) => setHeight(e.endCoordinates.height)),
|
|
13
|
+
Keyboard.addListener(hide, () => setHeight(0)),
|
|
14
|
+
]
|
|
15
|
+
return () => subs.forEach((s) => s.remove())
|
|
16
|
+
}, [])
|
|
17
|
+
return height
|
|
18
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
2
|
+
import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* When the owner hears that the overlay closed.
|
|
6
|
+
* - `after-exit`: once the exit animation has played (Dialog, Drawer): the
|
|
7
|
+
* overlay stays logically open while it animates out.
|
|
8
|
+
* - `at-once`: as soon as the close is asked for (DetailPanel); the panel
|
|
9
|
+
* still animates out while `present`.
|
|
10
|
+
*/
|
|
11
|
+
export type CloseNotice = 'after-exit' | 'at-once'
|
|
12
|
+
|
|
13
|
+
interface Options {
|
|
14
|
+
/** Controlled open state. */
|
|
15
|
+
open: boolean | undefined
|
|
16
|
+
/** Initial open state when uncontrolled. */
|
|
17
|
+
defaultOpen: boolean
|
|
18
|
+
onOpenChange: ((open: boolean) => void) | undefined
|
|
19
|
+
/** Motion token the exit animation lasts. */
|
|
20
|
+
exitDuration: DurationToken
|
|
21
|
+
closeNotice: CloseNotice
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OverlayOpenState {
|
|
25
|
+
/** Logically open (what `open` / the uncontrolled state says). */
|
|
26
|
+
open: boolean
|
|
27
|
+
/** Rendered: open, or animating out. */
|
|
28
|
+
present: boolean
|
|
29
|
+
/** Playing the exit animation. */
|
|
30
|
+
closing: boolean
|
|
31
|
+
requestOpen: () => void
|
|
32
|
+
/** Animated close. Does nothing while a close is already playing. */
|
|
33
|
+
requestClose: () => void
|
|
34
|
+
/** Close without the exit animation (a swipe that already moved the panel out). */
|
|
35
|
+
closeNow: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Open, presence and exit-animation state shared by Dialog, Drawer and
|
|
40
|
+
* DetailPanel on both platforms. A controlled `open` that turns false from
|
|
41
|
+
* outside plays the exit animation too.
|
|
42
|
+
*/
|
|
43
|
+
export function useOverlayOpenState({
|
|
44
|
+
open: controlledOpen,
|
|
45
|
+
defaultOpen,
|
|
46
|
+
onOpenChange,
|
|
47
|
+
exitDuration,
|
|
48
|
+
closeNotice,
|
|
49
|
+
}: Options): OverlayOpenState {
|
|
50
|
+
const isControlled = controlledOpen !== undefined
|
|
51
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
52
|
+
const open = isControlled ? controlledOpen : internalOpen
|
|
53
|
+
const [present, setPresent] = useState(open)
|
|
54
|
+
const [closing, setClosing] = useState(false)
|
|
55
|
+
const closingRef = useRef(false)
|
|
56
|
+
// The playing exit was started by the owner's `open` turning false (not by
|
|
57
|
+
// requestClose), so an `open` turning true again cancels it.
|
|
58
|
+
const outsideExitRef = useRef(false)
|
|
59
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
60
|
+
|
|
61
|
+
const cancelExit = useCallback(() => {
|
|
62
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
63
|
+
timerRef.current = null
|
|
64
|
+
closingRef.current = false
|
|
65
|
+
outsideExitRef.current = false
|
|
66
|
+
setClosing(false)
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
const playExit = useCallback(
|
|
70
|
+
(afterExit?: () => void) => {
|
|
71
|
+
closingRef.current = true
|
|
72
|
+
outsideExitRef.current = afterExit === undefined
|
|
73
|
+
setClosing(true)
|
|
74
|
+
timerRef.current = setTimeout(() => {
|
|
75
|
+
timerRef.current = null
|
|
76
|
+
closingRef.current = false
|
|
77
|
+
outsideExitRef.current = false
|
|
78
|
+
setClosing(false)
|
|
79
|
+
setPresent(false)
|
|
80
|
+
afterExit?.()
|
|
81
|
+
}, DURATION_MS[exitDuration])
|
|
82
|
+
},
|
|
83
|
+
[exitDuration],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const notifyClosed = useCallback(() => {
|
|
87
|
+
if (!isControlled) setInternalOpen(false)
|
|
88
|
+
onOpenChange?.(false)
|
|
89
|
+
}, [isControlled, onOpenChange])
|
|
90
|
+
|
|
91
|
+
const requestOpen = useCallback(() => {
|
|
92
|
+
cancelExit()
|
|
93
|
+
setPresent(true)
|
|
94
|
+
if (!isControlled) setInternalOpen(true)
|
|
95
|
+
onOpenChange?.(true)
|
|
96
|
+
}, [cancelExit, isControlled, onOpenChange])
|
|
97
|
+
|
|
98
|
+
const requestClose = useCallback(() => {
|
|
99
|
+
if (closingRef.current) return
|
|
100
|
+
if (closeNotice === 'after-exit') playExit(notifyClosed)
|
|
101
|
+
else notifyClosed()
|
|
102
|
+
}, [closeNotice, playExit, notifyClosed])
|
|
103
|
+
|
|
104
|
+
const closeNow = useCallback(() => {
|
|
105
|
+
cancelExit()
|
|
106
|
+
setPresent(false)
|
|
107
|
+
notifyClosed()
|
|
108
|
+
}, [cancelExit, notifyClosed])
|
|
109
|
+
|
|
110
|
+
// Presence follows the logical state: an open from outside shows the
|
|
111
|
+
// overlay, a close from outside (or an `at-once` close) animates it out.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (open && outsideExitRef.current) cancelExit()
|
|
114
|
+
else if (open && !present && !closingRef.current) setPresent(true)
|
|
115
|
+
else if (!open && present && !closingRef.current) playExit()
|
|
116
|
+
}, [open, present, playExit, cancelExit])
|
|
117
|
+
|
|
118
|
+
useEffect(
|
|
119
|
+
() => () => {
|
|
120
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
121
|
+
},
|
|
122
|
+
[],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// An open is rendered in the same commit (a consumer's effect on `open` can
|
|
126
|
+
// already reach the overlay's content); the state catches up in the effect.
|
|
127
|
+
return {open, present: open || present, closing, requestOpen, requestClose, closeNow}
|
|
128
|
+
}
|