@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.
Files changed (188) hide show
  1. package/dist/Table-Bms2ftt3.js +1694 -0
  2. package/dist/Table-Bms2ftt3.js.map +1 -0
  3. package/dist/components/AppShell/native.css.d.ts +53 -0
  4. package/dist/components/AppShell/native.css.d.ts.map +1 -0
  5. package/dist/components/Drawer/Drawer.d.ts +7 -35
  6. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  7. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  8. package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
  9. package/dist/components/Drawer/DrawerSnap.stories.d.ts +20 -0
  10. package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
  11. package/dist/components/Drawer/native.css.d.ts +55 -0
  12. package/dist/components/Drawer/native.css.d.ts.map +1 -0
  13. package/dist/components/Drawer/parts.css.d.ts +96 -0
  14. package/dist/components/Drawer/parts.css.d.ts.map +1 -0
  15. package/dist/components/Drawer/parts.d.ts +56 -0
  16. package/dist/components/Drawer/parts.d.ts.map +1 -0
  17. package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
  18. package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
  19. package/dist/components/Drawer/styles.css.d.ts +13 -58
  20. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  21. package/dist/components/Drawer/touchInput.d.ts +35 -0
  22. package/dist/components/Drawer/touchInput.d.ts.map +1 -0
  23. package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
  24. package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
  25. package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
  26. package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
  27. package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
  28. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
  29. package/dist/components/Menu/Menu.d.ts +7 -1
  30. package/dist/components/Menu/Menu.d.ts.map +1 -1
  31. package/dist/components/Menu/native.css.d.ts +14 -0
  32. package/dist/components/Menu/native.css.d.ts.map +1 -0
  33. package/dist/components/Menu/useMenuOpen.d.ts +16 -0
  34. package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
  35. package/dist/components/Menu/useMenuRoot.d.ts +3 -6
  36. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  37. package/dist/components/Popover/native.css.d.ts +11 -0
  38. package/dist/components/Popover/native.css.d.ts.map +1 -0
  39. package/dist/components/SideNav/Chevron.d.ts +4 -0
  40. package/dist/components/SideNav/Chevron.d.ts.map +1 -0
  41. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/components/SideNav/styles.css.d.ts +6 -0
  43. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  44. package/dist/components/SideNav/types.d.ts +6 -0
  45. package/dist/components/SideNav/types.d.ts.map +1 -0
  46. package/dist/components/SplitPane/SplitPane.d.ts +38 -0
  47. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
  48. package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
  49. package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
  50. package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
  51. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
  52. package/dist/components/SplitPane/styles.css.d.ts +78 -0
  53. package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
  54. package/dist/components/Tabs/TabLayout.d.ts +7 -0
  55. package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
  56. package/dist/components/Tabs/Tabs.d.ts.map +1 -1
  57. package/dist/components/Tabs/indicator.d.ts +28 -0
  58. package/dist/components/Tabs/indicator.d.ts.map +1 -0
  59. package/dist/components/Tabs/styles.css.d.ts +4 -0
  60. package/dist/components/Tabs/styles.css.d.ts.map +1 -1
  61. package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
  62. package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
  63. package/dist/components/Tabs/useTabKeys.d.ts +13 -0
  64. package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
  65. package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
  66. package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
  67. package/dist/components/Tag/Tag.d.ts +2 -24
  68. package/dist/components/Tag/Tag.d.ts.map +1 -1
  69. package/dist/components/Tag/styles.css.d.ts +8 -0
  70. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  71. package/dist/components/Tag/types.d.ts +39 -0
  72. package/dist/components/Tag/types.d.ts.map +1 -0
  73. package/dist/components/Toast/ToastProvider.d.ts +2 -21
  74. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  75. package/dist/components/Toast/native.css.d.ts +81 -0
  76. package/dist/components/Toast/native.css.d.ts.map +1 -0
  77. package/dist/components/Toast/store.d.ts +47 -0
  78. package/dist/components/Toast/store.d.ts.map +1 -0
  79. package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
  80. package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
  81. package/dist/components/Tree/Tree.d.ts.map +1 -1
  82. package/dist/components/Tree/styles.css.d.ts +6 -0
  83. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  84. package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
  85. package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
  86. package/dist/components/VirtualList/VirtualList.d.ts +33 -0
  87. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
  88. package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
  89. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
  90. package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
  91. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
  92. package/dist/components/VirtualList/styles.css.d.ts +28 -0
  93. package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
  94. package/dist/index.css +1 -1
  95. package/dist/index.d.ts +3 -1
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +4935 -4260
  98. package/dist/index.js.map +1 -1
  99. package/dist/shared/ButtonHost.d.ts +11 -0
  100. package/dist/shared/ButtonHost.d.ts.map +1 -0
  101. package/dist/shared/pressableChild.d.ts +14 -0
  102. package/dist/shared/pressableChild.d.ts.map +1 -0
  103. package/dist/table.js +1 -1
  104. package/package.json +2 -2
  105. package/src/components/AppShell/AppShell.meta.ts +1 -1
  106. package/src/components/AppShell/AppShell.native.tsx +174 -0
  107. package/src/components/AppShell/native.css.ts +56 -0
  108. package/src/components/Button/Button.tsx +3 -3
  109. package/src/components/Drawer/Drawer.meta.ts +2 -1
  110. package/src/components/Drawer/Drawer.native.tsx +469 -0
  111. package/src/components/Drawer/Drawer.tsx +58 -145
  112. package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
  113. package/src/components/Drawer/DrawerSnap.stories.tsx +205 -0
  114. package/src/components/Drawer/native.css.ts +58 -0
  115. package/src/components/Drawer/parts.css.ts +125 -0
  116. package/src/components/Drawer/parts.tsx +110 -0
  117. package/src/components/Drawer/sheetPhysics.ts +103 -0
  118. package/src/components/Drawer/styles.css.ts +15 -74
  119. package/src/components/Drawer/touchInput.ts +81 -0
  120. package/src/components/Drawer/useDrawerRoot.ts +90 -0
  121. package/src/components/Drawer/useSheetHeights.ts +42 -0
  122. package/src/components/Drawer/useSwipeDismiss.ts +146 -23
  123. package/src/components/Menu/Menu.meta.ts +1 -1
  124. package/src/components/Menu/Menu.native.tsx +268 -0
  125. package/src/components/Menu/Menu.tsx +11 -2
  126. package/src/components/Menu/native.css.ts +18 -0
  127. package/src/components/Menu/useMenuOpen.ts +33 -0
  128. package/src/components/Menu/useMenuRoot.ts +7 -26
  129. package/src/components/Popover/Popover.meta.ts +1 -1
  130. package/src/components/Popover/Popover.native.tsx +205 -0
  131. package/src/components/Popover/native.css.ts +13 -0
  132. package/src/components/SideNav/Chevron.native.tsx +20 -0
  133. package/src/components/SideNav/Chevron.tsx +20 -0
  134. package/src/components/SideNav/SideNav.meta.ts +1 -1
  135. package/src/components/SideNav/SideNav.tsx +57 -41
  136. package/src/components/SideNav/styles.css.ts +8 -0
  137. package/src/components/SideNav/types.ts +6 -0
  138. package/src/components/SplitPane/SplitPane.meta.ts +40 -0
  139. package/src/components/SplitPane/SplitPane.native.tsx +47 -0
  140. package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
  141. package/src/components/SplitPane/SplitPane.tsx +218 -0
  142. package/src/components/SplitPane/styles.css.ts +81 -0
  143. package/src/components/Tabs/TabLayout.native.tsx +9 -0
  144. package/src/components/Tabs/TabLayout.tsx +6 -0
  145. package/src/components/Tabs/Tabs.meta.ts +1 -1
  146. package/src/components/Tabs/Tabs.tsx +38 -136
  147. package/src/components/Tabs/indicator.ts +36 -0
  148. package/src/components/Tabs/styles.css.ts +7 -0
  149. package/src/components/Tabs/useTabIndicator.native.ts +35 -0
  150. package/src/components/Tabs/useTabIndicator.ts +49 -0
  151. package/src/components/Tabs/useTabKeys.native.ts +2 -0
  152. package/src/components/Tabs/useTabKeys.ts +99 -0
  153. package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
  154. package/src/components/Tag/RemoveGlyph.tsx +10 -0
  155. package/src/components/Tag/Tag.meta.ts +1 -1
  156. package/src/components/Tag/Tag.native.tsx +70 -0
  157. package/src/components/Tag/Tag.tsx +9 -37
  158. package/src/components/Tag/styles.css.ts +9 -0
  159. package/src/components/Tag/types.ts +43 -0
  160. package/src/components/Toast/Toast.meta.ts +1 -1
  161. package/src/components/Toast/ToastProvider.native.tsx +226 -0
  162. package/src/components/Toast/ToastProvider.tsx +22 -84
  163. package/src/components/Toast/native.css.ts +78 -0
  164. package/src/components/Toast/store.ts +96 -0
  165. package/src/components/Toggle/Toggle.tsx +3 -3
  166. package/src/components/Tooltip/Tooltip.meta.ts +1 -1
  167. package/src/components/Tooltip/Tooltip.native.tsx +36 -0
  168. package/src/components/Tooltip/TooltipHint.ts +13 -0
  169. package/src/components/Tree/Tree.meta.ts +1 -1
  170. package/src/components/Tree/Tree.tsx +23 -135
  171. package/src/components/Tree/styles.css.ts +6 -0
  172. package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
  173. package/src/components/Tree/useTreeKeyboard.ts +152 -0
  174. package/src/components/VirtualList/VirtualList.meta.ts +39 -0
  175. package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
  176. package/src/components/VirtualList/VirtualList.tsx +103 -0
  177. package/src/components/VirtualList/styles.css.ts +31 -0
  178. package/src/hooks/useContainerBelow.native.ts +30 -0
  179. package/src/index.native.ts +33 -0
  180. package/src/index.ts +8 -1
  181. package/src/shared/ButtonHost.native.tsx +70 -0
  182. package/src/shared/ButtonHost.tsx +12 -0
  183. package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
  184. package/src/shared/pressableChild.ts +33 -0
  185. package/src/shared/useKeyboardHeight.native.ts +18 -0
  186. package/src/shared/useScreenReaderEnabled.native.ts +19 -0
  187. package/dist/Table-ColfpgKV.js +0 -1552
  188. package/dist/Table-ColfpgKV.js.map +0 -1
@@ -1,12 +1,9 @@
1
1
  import {useState, useCallback, useRef, useId, useEffect, useLayoutEffect} from 'react'
2
2
  import type {MenuContextValue} from './MenuContext'
3
3
  import {isOutsidePress} from '../Popover/outside'
4
+ import {useMenuOpen, type MenuOpenOptions} from './useMenuOpen'
4
5
 
5
- export interface MenuRootOptions {
6
- open?: boolean
7
- defaultOpen?: boolean
8
- onOpenChange?: (open: boolean) => void
9
- }
6
+ export type MenuRootOptions = MenuOpenOptions
10
7
 
11
8
  /**
12
9
  * The menu-button pattern (WAI-ARIA APG): on open, focus moves to the
@@ -16,10 +13,8 @@ export interface MenuRootOptions {
16
13
  * elsewhere (an editor) are never intercepted, and Escape stops there so an
17
14
  * enclosing Popover or Dialog stays open.
18
15
  */
19
- export function useMenuRoot({open: openProp, defaultOpen = false, onOpenChange}: MenuRootOptions) {
20
- const [inner, setInner] = useState(defaultOpen)
21
- const controlled = openProp !== undefined
22
- const open = controlled ? openProp : inner
16
+ export function useMenuRoot(options: MenuRootOptions) {
17
+ const {open, openRef, setOpen} = useMenuOpen(options)
23
18
  const [highlightedId, setHighlightedId] = useState<string | null>(null)
24
19
  // Read by the keydown handler, so activating an item is not a side effect
25
20
  // inside a state updater (React may run updaters twice).
@@ -34,38 +29,24 @@ export function useMenuRoot({open: openProp, defaultOpen = false, onOpenChange}:
34
29
  const popupRef = useRef<HTMLDivElement | null>(null)
35
30
  const itemsRef = useRef(new Map<string, HTMLElement>())
36
31
  const orderRef = useRef<string[]>([])
37
- const openRef = useRef(open)
38
- openRef.current = open
39
- const onOpenChangeRef = useRef(onOpenChange)
40
- onOpenChangeRef.current = onOpenChange
41
-
42
- const setOpen = useCallback(
43
- (next: boolean) => {
44
- if (next === openRef.current) return
45
- if (!controlled) setInner(next)
46
- onOpenChangeRef.current?.(next)
47
- },
48
- [controlled],
49
- )
50
-
51
32
  // Closes and leaves focus where it is (a press elsewhere put it there).
52
33
  const dismiss = useCallback(() => {
53
34
  if (!openRef.current) return
54
35
  setOpen(false)
55
36
  setHighlightedId(null)
56
- }, [setOpen])
37
+ }, [openRef, setOpen])
57
38
 
58
39
  // Closes and puts focus back on the trigger.
59
40
  const close = useCallback(() => {
60
41
  if (!openRef.current) return
61
42
  dismiss()
62
43
  triggerRef.current?.focus()
63
- }, [dismiss])
44
+ }, [openRef, dismiss])
64
45
 
65
46
  const toggle = useCallback(() => {
66
47
  if (openRef.current) close()
67
48
  else setOpen(true)
68
- }, [close, setOpen])
49
+ }, [openRef, close, setOpen])
69
50
 
70
51
  // On open: focus the menu and highlight the first item. Child effects (item
71
52
  // registration) run before this parent effect, so orderRef is populated.
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves. Popover.Trigger takes size="small" beside small Toggles. Popover.Popup raised draws it over another floating surface (floatingRaised, popoverRaised in a modal), never over a modal.',
5
+ 'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves. Popover.Trigger takes size="small" beside small Toggles. Popover.Popup raised draws it over another floating surface (floatingRaised, popoverRaised in a modal), never over a modal. On React Native the popup is modal: it is placed against Popover.Trigger in an overlay, a press outside or the Android back button closes it, and screen-reader focus moves in on open and back to the trigger on close; anchor, ignore and raised are web only.',
6
6
  whenToUse: [
7
7
  'A small form or controls tied to one element that stay open while you work in them',
8
8
  'Anchoring to a spot that is not a button (a canvas point, a text selection) with a virtual anchor',
@@ -0,0 +1,205 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ type Ref,
5
+ type RefObject,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useId,
10
+ useImperativeHandle,
11
+ useRef,
12
+ useState,
13
+ } from 'react'
14
+ import {Pressable} from 'react-native'
15
+ import {html} from 'react-strict-dom'
16
+ import {styles} from './styles.css'
17
+ import {nativeStyles} from './native.css'
18
+ import type {PopoverAlign, PopoverSide} from './position'
19
+ import {AnchoredOverlay} from '../../shared/portal/AnchoredOverlay'
20
+ import {focusForAccessibility} from '../../shared/accessibilityFocus'
21
+ import {mergeRefs} from '../../shared/mergeRefs'
22
+ import {ModalContext} from '../../shared/ModalContext'
23
+ import type {ControlSize} from '../../shared/types'
24
+ import {useTooltipHint} from '../Tooltip/TooltipHint'
25
+ import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
26
+
27
+ interface PopoverContextValue {
28
+ open: boolean
29
+ setOpen: (open: boolean) => void
30
+ popoverId: string
31
+ side: PopoverSide
32
+ align: PopoverAlign
33
+ triggerRef: RefObject<unknown>
34
+ /** Bumped by `reposition()`: the popup measures its trigger again */
35
+ placement: number
36
+ }
37
+
38
+ const PopoverContext = createContext<PopoverContextValue | null>(null)
39
+
40
+ function usePopover(part: string) {
41
+ const ctx = useContext(PopoverContext)
42
+ if (!ctx) throw new Error(`Popover.${part} must be used within Popover.Root`)
43
+ return ctx
44
+ }
45
+
46
+ export interface PopoverHandle {
47
+ /** Place the popup against its trigger again. */
48
+ reposition: () => void
49
+ }
50
+
51
+ interface RootProps {
52
+ children: ReactNode
53
+ open?: boolean
54
+ defaultOpen?: boolean
55
+ onOpenChange?: (open: boolean) => void
56
+ side?: PopoverSide
57
+ align?: PopoverAlign
58
+ /** Web only: native places the popup against Popover.Trigger. */
59
+ anchor?: () => DOMRect
60
+ /** Web only: on native the popup is modal, so nothing outside it is pressed. */
61
+ ignore?: ReadonlyArray<unknown>
62
+ ref?: Ref<PopoverHandle>
63
+ }
64
+
65
+ /**
66
+ * Native Popover (plan N0, Slice B): the popup is placed against the trigger
67
+ * in an OverlayModal, so it is modal on native where the web's is not. A
68
+ * press outside or Android's back button closes it; the screen reader's
69
+ * focus moves into it on open and back to the trigger on close.
70
+ */
71
+ function Root({
72
+ children,
73
+ open: openProp,
74
+ defaultOpen = false,
75
+ onOpenChange,
76
+ side = 'bottom',
77
+ align = 'start',
78
+ ref,
79
+ }: RootProps) {
80
+ const [inner, setInner] = useState(defaultOpen)
81
+ const controlled = openProp !== undefined
82
+ const open = controlled ? openProp : inner
83
+ const popoverId = useId()
84
+ const triggerRef = useRef<unknown>(null)
85
+ const [placement, setPlacement] = useState(0)
86
+ const openRef = useRef(open)
87
+ openRef.current = open
88
+
89
+ const setOpen = useCallback(
90
+ (next: boolean) => {
91
+ if (!controlled) setInner(next)
92
+ if (next !== openRef.current) onOpenChange?.(next)
93
+ },
94
+ [controlled, onOpenChange],
95
+ )
96
+
97
+ useImperativeHandle(ref, () => ({reposition: () => setPlacement((n) => n + 1)}), [])
98
+
99
+ const wasOpen = useRef(open)
100
+ useEffect(() => {
101
+ if (wasOpen.current && !open) focusForAccessibility(triggerRef.current, 'Popover')
102
+ wasOpen.current = open
103
+ }, [open])
104
+
105
+ return (
106
+ <PopoverContext.Provider value={{open, setOpen, popoverId, side, align, triggerRef, placement}}>
107
+ {children}
108
+ </PopoverContext.Provider>
109
+ )
110
+ }
111
+
112
+ interface TriggerProps {
113
+ children: ReactNode
114
+ /** Accessible name: required when the label is an icon only. */
115
+ 'aria-label'?: string
116
+ /** `small` matches a small Toggle or Button beside it. Default: `default`. */
117
+ size?: ControlSize
118
+ /** The trigger's host, e.g. to measure it. */
119
+ ref?: Ref<unknown>
120
+ }
121
+
122
+ /**
123
+ * An RN Pressable host, so it carries an enclosing Tooltip's text as its hint.
124
+ *
125
+ * holds-until: react-strict-dom maps aria-description to accessibilityHint
126
+ * (https://github.com/react/react-strict-dom/issues/524).
127
+ */
128
+ function Trigger({children, 'aria-label': ariaLabel, size = 'default', ref}: TriggerProps) {
129
+ const {open, setOpen, triggerRef} = usePopover('Trigger')
130
+ const hint = useTooltipHint()
131
+ return (
132
+ <Pressable
133
+ ref={mergeRefs(triggerRef, ref) as Ref<never>}
134
+ onPress={() => setOpen(!open)}
135
+ accessibilityRole="button"
136
+ accessibilityLabel={ariaLabel}
137
+ accessibilityHint={hint}
138
+ accessibilityState={{expanded: open}}
139
+ >
140
+ <html.div
141
+ style={[styles.trigger, nativeStyles.nativeFlex, size === 'small' && styles.triggerSmall]}
142
+ >
143
+ {children}
144
+ </html.div>
145
+ </Pressable>
146
+ )
147
+ }
148
+
149
+ interface PopupProps {
150
+ children: ReactNode
151
+ /** Accessible name for the dialog. */
152
+ label: string
153
+ /** Web only: on native every popup is its own Modal, above what opened it. */
154
+ raised?: boolean
155
+ }
156
+
157
+ function Popup({children, label}: PopupProps) {
158
+ const {open, setOpen, popoverId, side, align, triggerRef, placement} = usePopover('Popup')
159
+ const popupRef = useRef<HTMLDivElement>(null)
160
+ return (
161
+ <AnchoredOverlay
162
+ key={placement}
163
+ visible={open}
164
+ anchorRef={triggerRef}
165
+ side={side}
166
+ align={align}
167
+ onRequestClose={() => setOpen(false)}
168
+ onShow={() => focusForAccessibility(popupRef.current, 'Popover')}
169
+ >
170
+ <html.div
171
+ ref={popupRef}
172
+ id={popoverId}
173
+ role="dialog"
174
+ aria-modal
175
+ aria-label={label}
176
+ style={[styles.popup, nativeStyles.popupInPlace]}
177
+ >
178
+ {/* Modal on native: a Popover or Menu opened inside it stacks above it. */}
179
+ <ModalContext.Provider value={true}>
180
+ <ControlContextBoundary>{children}</ControlContextBoundary>
181
+ </ModalContext.Provider>
182
+ </html.div>
183
+ </AnchoredOverlay>
184
+ )
185
+ }
186
+
187
+ function Close({children}: {children: ReactNode}) {
188
+ const {setOpen} = usePopover('Close')
189
+ return (
190
+ <html.button
191
+ type="button"
192
+ onClick={() => setOpen(false)}
193
+ style={[styles.close, nativeStyles.nativeFlex]}
194
+ >
195
+ {children}
196
+ </html.button>
197
+ )
198
+ }
199
+
200
+ export const Popover = {
201
+ Root,
202
+ Trigger,
203
+ Popup,
204
+ Close,
205
+ }
@@ -0,0 +1,13 @@
1
+ import {css} from 'react-strict-dom'
2
+
3
+ /** Native Popover additions to the shared styles. */
4
+ export const nativeStyles = css.create({
5
+ // The AnchoredOverlay's view places the popup; it flows inside it.
6
+ // RSD on native lays out only display: flex, not the web's inline-flex.
7
+ nativeFlex: {
8
+ display: 'flex',
9
+ },
10
+ popupInPlace: {
11
+ position: 'relative',
12
+ },
13
+ })
@@ -0,0 +1,20 @@
1
+ import Svg, {Path} from 'react-native-svg'
2
+ import {SIZES_PX} from '@duro-app/tokens/keys'
3
+ import {useResolvedColors} from '../ThemeProvider/ThemeProvider'
4
+ import type {ChevronProps} from './types'
5
+
6
+ /** The web's chevron, drawn with react-native-svg in the trigger's resolved colour. */
7
+ export function Chevron({color}: ChevronProps) {
8
+ const stroke = useResolvedColors()[color]
9
+ return (
10
+ <Svg width={SIZES_PX.glyphXs} height={SIZES_PX.glyphXs} viewBox="0 0 24 24" fill="none">
11
+ <Path
12
+ d="M9 18l6-6-6-6"
13
+ stroke={stroke}
14
+ strokeWidth={2.5}
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ />
18
+ </Svg>
19
+ )
20
+ }
@@ -0,0 +1,20 @@
1
+ import {SIZES_PX} from '@duro-app/tokens/keys'
2
+ import type {ChevronProps} from './types'
3
+
4
+ /** The group disclosure's chevron, drawn in the trigger's text colour. */
5
+ export function Chevron(_props: ChevronProps) {
6
+ return (
7
+ <svg
8
+ width={SIZES_PX.glyphXs}
9
+ height={SIZES_PX.glyphXs}
10
+ viewBox="0 0 24 24"
11
+ fill="none"
12
+ stroke="currentColor"
13
+ strokeWidth={2.5}
14
+ strokeLinecap="round"
15
+ strokeLinejoin="round"
16
+ >
17
+ <path d="M9 18l6-6-6-6" />
18
+ </svg>
19
+ )
20
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Vertical side navigation. `Section` is a labelled block that is always open — the default for primary navigation. `Group` is the same block behind a chevron (a disclosure) — reach for it only for a rare/advanced region, an unbounded data-driven list, or a rail long enough that flat stops reading as an overview; mixing the two in one rail is the healthy shape. `Item`s take an optional `icon` and show an active left-marker. Supports controlled and uncontrolled active item. Compound component — Root is required.',
5
+ 'Vertical side navigation. `Section` is a labelled block that is always open — the default for primary navigation. `Group` is the same block behind a chevron (a disclosure) — reach for it only for a rare/advanced region, an unbounded data-driven list, or a rail long enough that flat stops reading as an overview; mixing the two in one rail is the healthy shape. `Item`s take an optional `icon` and show an active left-marker. Supports controlled and uncontrolled active item. Compound component — Root is required. On React Native the same parts render, and a Group knows whether it holds the active item from its Items, not from the DOM.',
6
6
  whenToUse: [
7
7
  'App-level sidebar navigation — flat `Section`s, every destination visible',
8
8
  'Settings navigation with labelled regions',
@@ -1,8 +1,22 @@
1
- import {type ReactNode, useState, useCallback, useRef, useEffect, useId} from 'react'
1
+ import {
2
+ type ReactNode,
3
+ createContext,
4
+ useContext,
5
+ useState,
6
+ useCallback,
7
+ useRef,
8
+ useEffect,
9
+ useId,
10
+ } from 'react'
2
11
  import {html} from 'react-strict-dom'
3
12
  import {styles} from './styles.css'
4
13
  import {useControllableValue} from '../../hooks/useControllableValue'
5
14
  import {SideNavContext, useSideNav} from './SideNavContext'
15
+ import {isNative} from '../../platform'
16
+ import {Chevron} from './Chevron'
17
+
18
+ /** A Group's own items: each Item inside it registers its value here. */
19
+ const GroupItemsContext = createContext<((value: string) => () => void) | null>(null)
6
20
 
7
21
  /**
8
22
  * SideNav has two ways to chunk a rail, and they are NOT interchangeable.
@@ -130,39 +144,40 @@ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
130
144
  const key = groupKey ?? label
131
145
  const {expandedGroups, toggleGroup, expandGroup, activeValue} = useSideNav()
132
146
  const isExpanded = expandedGroups.has(key)
133
- const groupRef = useRef<HTMLDivElement>(null)
134
147
  const triggerId = useId()
135
148
  const panelId = useId()
149
+ // The values of the Items inside, from props and state rather than a DOM
150
+ // query, so both platforms know whether the active item is in here.
151
+ const [values, setValues] = useState<ReadonlySet<string>>(() => new Set())
152
+ const registerValue = useCallback((value: string) => {
153
+ setValues((prev) => (prev.has(value) ? prev : new Set(prev).add(value)))
154
+ return () =>
155
+ setValues((prev) => {
156
+ if (!prev.has(value)) return prev
157
+ const next = new Set(prev)
158
+ next.delete(value)
159
+ return next
160
+ })
161
+ }, [])
162
+ const hasActiveChild = activeValue !== null && values.has(activeValue)
136
163
 
137
164
  // Auto-expand if this group contains the active item. Uses expandGroup (not
138
165
  // toggleGroup) so it never races the defaultExpanded effect into a closed
139
166
  // state when the active item lives in a defaultExpanded group.
140
167
  useEffect(() => {
141
- if (!activeValue || expandedGroups.has(key)) return
142
- const el = groupRef.current
143
- if (!el) return
144
- const activeBtn = el.querySelector(`[data-nav-value="${activeValue}"]`)
145
- if (activeBtn) {
146
- expandGroup(key)
147
- }
148
- }, [activeValue, key, expandedGroups, expandGroup])
168
+ if (hasActiveChild && !expandedGroups.has(key)) expandGroup(key)
169
+ }, [hasActiveChild, key, expandedGroups, expandGroup])
149
170
 
150
- // Expand on first render if defaultExpanded (idempotent add, not a toggle).
171
+ // Expand once, on mount, if defaultExpanded (idempotent add, not a toggle).
172
+ const appliedDefault = useRef(false)
151
173
  useEffect(() => {
152
- if (defaultExpanded) {
153
- expandGroup(key)
154
- }
155
- // Only run on mount
156
- // eslint-disable-next-line react-hooks/exhaustive-deps
157
- }, [])
158
-
159
- const hasActiveChild = (() => {
160
- if (!activeValue || !groupRef.current) return false
161
- return !!groupRef.current.querySelector(`[data-nav-value="${activeValue}"]`)
162
- })()
174
+ if (appliedDefault.current) return
175
+ appliedDefault.current = true
176
+ if (defaultExpanded) expandGroup(key)
177
+ }, [defaultExpanded, expandGroup, key])
163
178
 
164
179
  return (
165
- <html.div ref={groupRef} style={styles.group}>
180
+ <html.div style={styles.group}>
166
181
  <html.button
167
182
  type="button"
168
183
  id={triggerId}
@@ -171,27 +186,26 @@ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
171
186
  aria-expanded={isExpanded}
172
187
  aria-controls={isExpanded ? panelId : undefined}
173
188
  >
174
- <html.span style={[styles.chevron, isExpanded && styles.chevronOpen]}>
175
- <svg
176
- width={10}
177
- height={10}
178
- viewBox="0 0 24 24"
179
- fill="none"
180
- stroke="currentColor"
181
- strokeWidth={2.5}
182
- strokeLinecap="round"
183
- strokeLinejoin="round"
184
- >
185
- <path d="M9 18l6-6-6-6" />
186
- </svg>
189
+ <html.span
190
+ style={[styles.chevron, isNative && styles.nativeFlex, isExpanded && styles.chevronOpen]}
191
+ >
192
+ <Chevron color={hasActiveChild ? 'text' : 'textMuted'} />
187
193
  </html.span>
188
- {label}
194
+ {/* Native refuses bare text beside the chevron's view; the web keeps it bare. */}
195
+ {isNative ? <html.span>{label}</html.span> : label}
189
196
  </html.button>
190
- {isExpanded ? (
191
- <html.div id={panelId} role="group" aria-labelledby={triggerId} style={styles.items}>
197
+ {/* Kept mounted while collapsed, so its Items stay registered with the group. */}
198
+ <GroupItemsContext.Provider value={registerValue}>
199
+ <html.div
200
+ id={panelId}
201
+ role="group"
202
+ aria-labelledby={triggerId}
203
+ hidden={isExpanded ? undefined : true}
204
+ style={[styles.items, !isExpanded && styles.itemsHidden]}
205
+ >
192
206
  {children}
193
207
  </html.div>
194
- ) : null}
208
+ </GroupItemsContext.Provider>
195
209
  </html.div>
196
210
  )
197
211
  }
@@ -239,11 +253,13 @@ interface ItemProps {
239
253
 
240
254
  function Item({value, children, icon}: ItemProps) {
241
255
  const {activeValue, onSelect, registerItem} = useSideNav()
256
+ const registerInGroup = useContext(GroupItemsContext)
242
257
  const isActive = activeValue === value
243
258
 
244
259
  useEffect(() => {
245
260
  return registerItem(value)
246
261
  }, [value, registerItem])
262
+ useEffect(() => registerInGroup?.(value), [value, registerInGroup])
247
263
 
248
264
  return (
249
265
  <html.button
@@ -255,7 +271,7 @@ function Item({value, children, icon}: ItemProps) {
255
271
  >
256
272
  <html.span aria-hidden style={[styles.marker, isActive && styles.markerActive]} />
257
273
  {icon ? (
258
- <html.span aria-hidden style={styles.itemIcon}>
274
+ <html.span aria-hidden style={[styles.itemIcon, isNative && styles.nativeFlex]}>
259
275
  {icon}
260
276
  </html.span>
261
277
  ) : null}
@@ -137,6 +137,14 @@ export const styles = css.create({
137
137
  transitionDuration: duration.fast,
138
138
  transitionTimingFunction: easing.standard,
139
139
  },
140
+ // A collapsed group's items stay mounted (they tell the group what it holds), unseen.
141
+ itemsHidden: {
142
+ display: 'none',
143
+ },
144
+ // RSD on native lays out only display: flex, not the web's inline-flex.
145
+ nativeFlex: {
146
+ display: 'flex',
147
+ },
140
148
  markerActive: {
141
149
  height: sizes.navMarkerH,
142
150
  },
@@ -0,0 +1,6 @@
1
+ import type {ColorToken} from '@duro-app/tokens/keys'
2
+
3
+ export interface ChevronProps {
4
+ /** Native only: the web inherits the trigger's text colour. */
5
+ color: ColorToken
6
+ }
@@ -0,0 +1,40 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Two panes side by side with a resizable separator: the first Pane is sized by the Handle (pointer drag, arrow keys, Shift for larger steps, Home and End), the second takes the rest. Min, max and starting widths are size tokens. Below collapseBelow, measured on its own width, the panes stack in one column and the handle goes; on native they always stack. The Handle is a hairline that thickens to the accent on hover, keyboard focus or drag.',
6
+ whenToUse: [
7
+ 'A list beside its detail where people need more room on either side (a session list and its transcript)',
8
+ 'An editor beside its preview',
9
+ ],
10
+ whenNotToUse: [
11
+ 'A fixed list/detail layout nobody resizes — use Grid layout="split"',
12
+ 'Navigation beside content — use AppShell or the page-with-sidenav recipe',
13
+ 'A short-lived inspection over a list — use DetailPanel',
14
+ ],
15
+ anatomy: {
16
+ required: ['Root', 'Pane'],
17
+ optional: ['Handle'],
18
+ },
19
+ relatedTo: [
20
+ {
21
+ component: 'Grid',
22
+ kind: 'contrast',
23
+ relationship: 'Grid layout="split" is a fixed split; SplitPane lets people resize it',
24
+ },
25
+ {
26
+ component: 'DetailPanel',
27
+ kind: 'contrast',
28
+ relationship: 'DetailPanel overlays the list; SplitPane keeps both side by side',
29
+ },
30
+ ],
31
+ example: `<SplitPane.Root aria-label="Resize the session list" minSize="sidebarW" maxSize="panelLg">
32
+ <SplitPane.Pane>
33
+ <List.Root aria-label="Sessions">…</List.Root>
34
+ </SplitPane.Pane>
35
+ <SplitPane.Handle />
36
+ <SplitPane.Pane>
37
+ <Panel.Root>…</Panel.Root>
38
+ </SplitPane.Pane>
39
+ </SplitPane.Root>`,
40
+ }
@@ -0,0 +1,47 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import type {SizeToken} from '@duro-app/tokens/keys'
4
+ import type {Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
5
+ import {styles} from './styles.css'
6
+
7
+ interface RootProps {
8
+ children: ReactNode
9
+ /** Web only: on a phone the panes stack. */
10
+ defaultSize?: SizeToken
11
+ /** Web only. */
12
+ size?: number
13
+ /** Web only. */
14
+ onSizeChange?: (px: number) => void
15
+ /** Web only. */
16
+ minSize?: SizeToken
17
+ /** Web only. */
18
+ maxSize?: SizeToken
19
+ /** Web only: a phone is below every breakpoint, so native always stacks. */
20
+ collapseBelow?: Breakpoint
21
+ /** The handle's accessible name (web only: native has no handle). */
22
+ 'aria-label': string
23
+ }
24
+
25
+ /**
26
+ * Native SplitPane (plan N0, Slice B): a phone shows one pane at a time's
27
+ * worth of width, so the panes stack in one column and there is no handle —
28
+ * the shape the web takes below `collapseBelow`.
29
+ */
30
+ function Root({children}: RootProps) {
31
+ return <html.div style={[styles.root, styles.rootStacked]}>{children}</html.div>
32
+ }
33
+
34
+ function Pane({children}: {children: ReactNode}) {
35
+ return <html.div style={styles.stackedPane}>{children}</html.div>
36
+ }
37
+
38
+ /** No handle on native. */
39
+ function Handle() {
40
+ return null
41
+ }
42
+
43
+ export const SplitPane = {
44
+ Root,
45
+ Pane,
46
+ Handle,
47
+ }