@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
@@ -0,0 +1,148 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, userEvent, waitFor} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {SIZES_PX, SPACING_PX, type SizeToken} from '@duro-app/tokens/keys'
5
+ import type {Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
6
+ import {SplitPane} from './SplitPane'
7
+ import {setViewport} from '../Drawer/touchInput'
8
+ import {List} from '../List/List'
9
+ import {Panel} from '../Panel/Panel'
10
+ import {Heading} from '../Heading/Heading'
11
+ import {Text} from '../Text/Text'
12
+
13
+ const meta: Meta = {
14
+ title: 'Components/SplitPane',
15
+ parameters: {layout: 'fullscreen'},
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj
20
+
21
+ const SESSIONS = ['Refactor auth', 'Flaky e2e', 'Release notes', 'Docs pass', 'Perf trace']
22
+
23
+ // A SplitPane fills its parent: the story gives it the viewport's height.
24
+ const frame = css.create({fill: {height: '100vh'}})
25
+
26
+ interface SplitArgs {
27
+ defaultSize: SizeToken
28
+ minSize: SizeToken
29
+ maxSize: SizeToken
30
+ collapseBelow: Breakpoint
31
+ onSizeChange?: (px: number) => void
32
+ }
33
+
34
+ const DEFAULT_ARGS: SplitArgs = {
35
+ defaultSize: 'sidebarW',
36
+ minSize: 'sidebarW',
37
+ maxSize: 'panelLg',
38
+ collapseBelow: 'sm',
39
+ }
40
+
41
+ // Width tokens that make sense for a pane, smallest first.
42
+ const PANE_SIZES: SizeToken[] = ['sidebarW', 'panelSm', 'panelMd', 'panelLg']
43
+
44
+ /** The picked B0 direction (docs/mockups/split-pane/Hairline.dc.html). */
45
+ function Split(props: Partial<SplitArgs>) {
46
+ const {defaultSize, minSize, maxSize, collapseBelow, onSizeChange} = {...DEFAULT_ARGS, ...props}
47
+ return (
48
+ <html.div style={frame.fill}>
49
+ <SplitPane.Root
50
+ aria-label="Resize the session list"
51
+ defaultSize={defaultSize}
52
+ minSize={minSize}
53
+ maxSize={maxSize}
54
+ collapseBelow={collapseBelow}
55
+ onSizeChange={onSizeChange}
56
+ >
57
+ <SplitPane.Pane>
58
+ <List.Root aria-label="Sessions">
59
+ {SESSIONS.map((s) => (
60
+ <List.Item key={s}>
61
+ <List.Text>{s}</List.Text>
62
+ </List.Item>
63
+ ))}
64
+ </List.Root>
65
+ </SplitPane.Pane>
66
+ <SplitPane.Handle />
67
+ <SplitPane.Pane>
68
+ <Panel.Root>
69
+ <Panel.Header>
70
+ <Heading level={2}>Refactor auth</Heading>
71
+ </Panel.Header>
72
+ <Panel.Body>
73
+ <Text>The transcript of the selected session.</Text>
74
+ </Panel.Body>
75
+ </Panel.Root>
76
+ </SplitPane.Pane>
77
+ </SplitPane.Root>
78
+ </html.div>
79
+ )
80
+ }
81
+
82
+ export const Default: StoryObj<SplitArgs> = {
83
+ args: {...DEFAULT_ARGS, onSizeChange: fn()},
84
+ argTypes: {
85
+ defaultSize: {
86
+ control: 'select',
87
+ options: PANE_SIZES,
88
+ description: 'Starting width of the first pane',
89
+ },
90
+ minSize: {control: 'select', options: PANE_SIZES, description: 'Narrowest the first pane goes'},
91
+ maxSize: {control: 'select', options: PANE_SIZES, description: 'Widest the first pane goes'},
92
+ collapseBelow: {
93
+ control: 'select',
94
+ options: ['xs', 'sm', 'md', 'lg', 'xl'],
95
+ description: "Below this breakpoint (the split's own width) the panes stack",
96
+ },
97
+ },
98
+ // Keyed by the starting width: it seeds state on mount, so a change remounts the split.
99
+ render: (args) => <Split key={args.defaultSize} {...args} />,
100
+ }
101
+
102
+ const separator = () => document.querySelector<HTMLElement>('[role="separator"]')
103
+
104
+ export const KeyboardMovesTheSeparator: Story = {
105
+ render: () => <Split />,
106
+ beforeEach: async () => {
107
+ await setViewport(1200, 800)
108
+ },
109
+ play: async () => {
110
+ await waitFor(() => expect(separator()).not.toBeNull())
111
+ const handle = separator()!
112
+ const primary = document.getElementById(handle.getAttribute('aria-controls')!)!
113
+ await expect(handle).toHaveAttribute('aria-valuenow', String(SIZES_PX.sidebarW))
114
+ await expect(handle).toHaveAttribute('aria-valuemin', String(SIZES_PX.sidebarW))
115
+ await expect(handle).toHaveAttribute('aria-valuemax', String(SIZES_PX.panelLg))
116
+
117
+ handle.focus()
118
+ await userEvent.keyboard('{ArrowRight}')
119
+ const after = SIZES_PX.sidebarW + SPACING_PX.md
120
+ await expect(handle).toHaveAttribute('aria-valuenow', String(after))
121
+ await waitFor(() => expect(primary.getBoundingClientRect().width).toBeCloseTo(after, 0))
122
+
123
+ await userEvent.keyboard('{End}')
124
+ await expect(handle).toHaveAttribute('aria-valuenow', String(SIZES_PX.panelLg))
125
+ // the bound holds
126
+ await userEvent.keyboard('{ArrowRight}')
127
+ await expect(handle).toHaveAttribute('aria-valuenow', String(SIZES_PX.panelLg))
128
+
129
+ await userEvent.keyboard('{Home}')
130
+ await expect(handle).toHaveAttribute('aria-valuenow', String(SIZES_PX.sidebarW))
131
+ },
132
+ }
133
+
134
+ export const NarrowStacksInOneColumn: Story = {
135
+ render: () => <Split />,
136
+ beforeEach: async () => {
137
+ await setViewport(390, 844)
138
+ },
139
+ play: async ({canvas}) => {
140
+ await waitFor(() => expect(separator()).toBeNull())
141
+ const list = canvas.getByRole('list', {name: 'Sessions'})
142
+ const heading = canvas.getByRole('heading', {name: 'Refactor auth'})
143
+ // one column: the detail sits below the list
144
+ await expect(heading.getBoundingClientRect().top).toBeGreaterThan(
145
+ list.getBoundingClientRect().bottom - 1,
146
+ )
147
+ },
148
+ }
@@ -0,0 +1,218 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useId,
10
+ useRef,
11
+ useState,
12
+ } from 'react'
13
+ import {html} from 'react-strict-dom'
14
+ import {SIZES_PX, SPACING_PX, type SizeToken} from '@duro-app/tokens/keys'
15
+ import type {Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
16
+ import {useContainerBelow} from '../../hooks/useContainerBelow'
17
+ import {styles} from './styles.css'
18
+
19
+ interface SplitPaneContextValue {
20
+ size: number
21
+ min: number
22
+ max: number
23
+ setSize: (px: number) => void
24
+ stacked: boolean
25
+ primaryId: string
26
+ label: string
27
+ }
28
+
29
+ const SplitPaneContext = createContext<SplitPaneContextValue | null>(null)
30
+
31
+ function useSplitPane(part: string) {
32
+ const ctx = useContext(SplitPaneContext)
33
+ if (!ctx) throw new Error(`SplitPane.${part} must be used within SplitPane.Root`)
34
+ return ctx
35
+ }
36
+
37
+ /** Which Pane this is, in order: the first is the one the handle sizes. */
38
+ const PaneIndexContext = createContext(0)
39
+
40
+ /** One arrow press moves the handle this far; with Shift, `STEP_LARGE`. */
41
+ const STEP = SPACING_PX.md
42
+ const STEP_LARGE = SPACING_PX.xxl
43
+
44
+ const clamp = (px: number, min: number, max: number) => Math.min(max, Math.max(min, px))
45
+
46
+ interface RootProps {
47
+ children: ReactNode
48
+ /** The primary (first) pane's starting width, a size token. Default: `sidebarW` */
49
+ defaultSize?: SizeToken
50
+ /** Controlled width of the primary pane, in px (from `onSizeChange`) */
51
+ size?: number
52
+ /** Called with the primary pane's new width while the handle moves */
53
+ onSizeChange?: (px: number) => void
54
+ /** The primary pane's narrowest width, a size token. Default: `sidebarW` */
55
+ minSize?: SizeToken
56
+ /** The primary pane's widest width, a size token. Default: `panelLg` */
57
+ maxSize?: SizeToken
58
+ /** Below this breakpoint, measured on the split's own width, the panes stack. Default: `sm` */
59
+ collapseBelow?: Breakpoint
60
+ /** The handle's accessible name ("Resize list"). */
61
+ 'aria-label': string
62
+ }
63
+
64
+ /**
65
+ * Two panes side by side with a resizable separator between them: a list
66
+ * and its detail, an editor and its preview. The first Pane is sized by the
67
+ * handle (pointer drag, arrow keys, Home and End); the second takes the rest.
68
+ * Below `collapseBelow` the panes stack in one column and the handle goes.
69
+ */
70
+ function Root({
71
+ children,
72
+ defaultSize = 'sidebarW',
73
+ size: controlledSize,
74
+ onSizeChange,
75
+ minSize = 'sidebarW',
76
+ maxSize = 'panelLg',
77
+ collapseBelow = 'sm',
78
+ 'aria-label': label,
79
+ }: RootProps) {
80
+ const min = SIZES_PX[minSize]
81
+ const max = Math.max(min, SIZES_PX[maxSize])
82
+ const [inner, setInner] = useState(() => clamp(SIZES_PX[defaultSize], min, max))
83
+ const size = clamp(controlledSize ?? inner, min, max)
84
+ const setSize = useCallback(
85
+ (px: number) => {
86
+ const next = clamp(Math.round(px), min, max)
87
+ if (controlledSize === undefined) setInner(next)
88
+ onSizeChange?.(next)
89
+ },
90
+ [controlledSize, onSizeChange, min, max],
91
+ )
92
+ const rootRef = useRef<HTMLDivElement>(null)
93
+ const stacked = useContainerBelow(rootRef, collapseBelow)
94
+ const primaryId = useId()
95
+
96
+ let paneIndex = 0
97
+ const items = Children.map(children, (child) => {
98
+ if (isValidElement(child) && child.type === Pane) {
99
+ const index = paneIndex++
100
+ return <PaneIndexContext.Provider value={index}>{child}</PaneIndexContext.Provider>
101
+ }
102
+ return child
103
+ })
104
+
105
+ return (
106
+ <SplitPaneContext.Provider value={{size, min, max, setSize, stacked, primaryId, label}}>
107
+ <html.div ref={rootRef} style={[styles.root, stacked && styles.rootStacked]}>
108
+ {items}
109
+ </html.div>
110
+ </SplitPaneContext.Provider>
111
+ )
112
+ }
113
+
114
+ function Pane({children}: {children: ReactNode}) {
115
+ const {size, stacked, primaryId} = useSplitPane('Pane')
116
+ const primary = useContext(PaneIndexContext) === 0
117
+ return (
118
+ <html.div
119
+ id={primary ? primaryId : undefined}
120
+ style={[
121
+ primary ? [styles.primary, !stacked && styles.primarySize(size)] : styles.secondary,
122
+ stacked && styles.stackedPane,
123
+ ]}
124
+ >
125
+ {children}
126
+ </html.div>
127
+ )
128
+ }
129
+
130
+ function Handle() {
131
+ const {size, min, max, setSize, stacked, primaryId, label} = useSplitPane('Handle')
132
+ const [hovered, setHovered] = useState(false)
133
+ const [keyboardFocus, setKeyboardFocus] = useState(false)
134
+ const [dragging, setDragging] = useState(false)
135
+ const drag = useRef<{startX: number; startSize: number} | null>(null)
136
+ const handleRef = useRef<HTMLDivElement>(null)
137
+
138
+ // Arrow keys move the handle, Shift by larger steps; Home and End go to
139
+ // the bounds. A DOM listener, for the full KeyboardEvent.
140
+ useEffect(() => {
141
+ const el = handleRef.current
142
+ if (!el) return
143
+ function onKeyDown(e: KeyboardEvent) {
144
+ const step = e.shiftKey ? STEP_LARGE : STEP
145
+ const next =
146
+ e.key === 'ArrowLeft'
147
+ ? size - step
148
+ : e.key === 'ArrowRight'
149
+ ? size + step
150
+ : e.key === 'Home'
151
+ ? min
152
+ : e.key === 'End'
153
+ ? max
154
+ : null
155
+ if (next === null) return
156
+ e.preventDefault()
157
+ setSize(next)
158
+ }
159
+ el.addEventListener('keydown', onKeyDown)
160
+ return () => el.removeEventListener('keydown', onKeyDown)
161
+ }, [size, min, max, setSize, stacked])
162
+
163
+ if (stacked) return null
164
+ const active = hovered || keyboardFocus || dragging
165
+
166
+ return (
167
+ <html.div
168
+ ref={handleRef}
169
+ role="separator"
170
+ tabIndex={0}
171
+ aria-label={label}
172
+ aria-orientation="vertical"
173
+ aria-controls={primaryId}
174
+ aria-valuenow={size}
175
+ aria-valuemin={min}
176
+ aria-valuemax={max}
177
+ style={styles.handle}
178
+ onFocus={(e: {currentTarget: HTMLElement}) =>
179
+ setKeyboardFocus(e.currentTarget.matches(':focus-visible'))
180
+ }
181
+ onBlur={() => setKeyboardFocus(false)}
182
+ onPointerEnter={() => setHovered(true)}
183
+ onPointerLeave={() => setHovered(false)}
184
+ onPointerDown={(e: {
185
+ button: number
186
+ clientX: number
187
+ pointerId: number
188
+ currentTarget: HTMLElement
189
+ }) => {
190
+ if (e.button !== 0) return
191
+ e.currentTarget.setPointerCapture(e.pointerId)
192
+ drag.current = {startX: e.clientX, startSize: size}
193
+ setDragging(true)
194
+ }}
195
+ onPointerMove={(e: {clientX: number}) => {
196
+ if (!drag.current) return
197
+ setSize(drag.current.startSize + e.clientX - drag.current.startX)
198
+ }}
199
+ onPointerUp={() => {
200
+ drag.current = null
201
+ setDragging(false)
202
+ }}
203
+ onPointerCancel={() => {
204
+ drag.current = null
205
+ setDragging(false)
206
+ }}
207
+ >
208
+ <html.span aria-hidden style={[styles.line, active && styles.lineActive]} />
209
+ <html.span aria-hidden style={[styles.grip, active && styles.gripShown]} />
210
+ </html.div>
211
+ )
212
+ }
213
+
214
+ export const SplitPane = {
215
+ Root,
216
+ Pane,
217
+ Handle,
218
+ }
@@ -0,0 +1,81 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
5
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
6
+
7
+ /**
8
+ * SplitPane, the picked B0 direction (docs/mockups/split-pane/Hairline.dc.html):
9
+ * a hairline between the panes inside a wider hit area; hover, keyboard
10
+ * focus or a drag thicken it to the accent and show a grip.
11
+ */
12
+ export const styles = css.create({
13
+ root: {
14
+ display: 'flex',
15
+ flexDirection: 'row',
16
+ alignItems: 'stretch',
17
+ minWidth: 0,
18
+ minHeight: 0,
19
+ height: '100%',
20
+ },
21
+ // Below `collapseBelow`: one column, the panes stacked, no handle.
22
+ rootStacked: {
23
+ flexDirection: 'column',
24
+ height: 'auto',
25
+ },
26
+ primary: {
27
+ flexShrink: 0,
28
+ minWidth: 0,
29
+ overflow: 'auto',
30
+ },
31
+ primarySize: (px: number) => ({width: px}),
32
+ secondary: {
33
+ flexGrow: 1,
34
+ flexBasis: 0,
35
+ minWidth: 0,
36
+ overflow: 'auto',
37
+ },
38
+ stackedPane: {
39
+ width: '100%',
40
+ overflow: 'visible',
41
+ },
42
+ handle: {
43
+ position: 'relative',
44
+ flexShrink: 0,
45
+ display: 'flex',
46
+ justifyContent: 'center',
47
+ width: spacing.sm,
48
+ cursor: 'col-resize',
49
+ touchAction: 'none',
50
+ userSelect: 'none',
51
+ outlineStyle: 'none',
52
+ },
53
+ line: {
54
+ width: borders.hairline,
55
+ height: '100%',
56
+ backgroundColor: colors.border,
57
+ transitionProperty: 'background-color, width',
58
+ transitionDuration: duration.fast,
59
+ transitionTimingFunction: easing.standard,
60
+ },
61
+ lineActive: {
62
+ width: borders.strong,
63
+ backgroundColor: colors.accent,
64
+ },
65
+ grip: {
66
+ position: 'absolute',
67
+ top: '50%',
68
+ left: '50%',
69
+ transform: 'translate(-50%, -50%)',
70
+ width: spacing.xs,
71
+ height: spacing.xl,
72
+ borderRadius: radii.full,
73
+ backgroundColor: colors.textMuted,
74
+ opacity: 0,
75
+ transitionProperty: 'opacity',
76
+ transitionDuration: duration.fast,
77
+ },
78
+ gripShown: {
79
+ opacity: 1,
80
+ },
81
+ })
@@ -0,0 +1,9 @@
1
+ import type {ReactNode} from 'react'
2
+ import {View} from 'react-native'
3
+ import {useReportTabLayout} from './indicator'
4
+
5
+ /** Native: reports the tab's box to its List, which places the sliding indicator. */
6
+ export function TabLayout({value, children}: {value: string; children: ReactNode}) {
7
+ const report = useReportTabLayout()
8
+ return <View onLayout={(e) => report?.(value, e.nativeEvent.layout)}>{children}</View>
9
+ }
@@ -0,0 +1,6 @@
1
+ import type {ReactNode} from 'react'
2
+
3
+ /** Web: the tab itself; the List measures it in the DOM (TabLayout.native.tsx reports it). */
4
+ export function TabLayout({children}: {value: string; children: ReactNode}) {
5
+ return children
6
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Tabbed interface with keyboard navigation. Supports horizontal and vertical orientation. Compound component — Root is required.',
5
+ 'Tabbed interface with keyboard navigation. Supports horizontal and vertical orientation. Compound component — Root is required. On React Native the indicator follows each tab layout; there is no arrow-key navigation there, each tab is its own screen-reader stop.',
6
6
  whenToUse: [
7
7
  'Switching between content panels in the same view',
8
8
  'Settings pages with multiple sections',
@@ -1,8 +1,13 @@
1
- import {type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
1
+ import {type ReactNode, useRef, useId, useEffect} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
+ import {isNative} from '../../platform'
4
5
  import {TabsContext, useTabs} from './TabsContext'
5
6
  import {useTabsRoot} from './useTabsRoot'
7
+ import {useTabIndicator} from './useTabIndicator'
8
+ import {useTabKeys} from './useTabKeys'
9
+ import {TabLayoutContext} from './indicator'
10
+ import {TabLayout} from './TabLayout'
6
11
 
7
12
  // --- Root ---
8
13
 
@@ -41,120 +46,14 @@ interface ListProps {
41
46
  function List({children}: ListProps) {
42
47
  const {orientation, activeValue, onSelect, tabsRef, orderRef, setIndicatorActive} = useTabs()
43
48
  const listRef = useRef<HTMLDivElement>(null)
44
- const [indicator, setIndicator] = useState<{offset: number; size: number} | null>(null)
45
49
 
46
- // Measure the active tab and drive the sliding indicator. useLayoutEffect so
47
- // the first position is set before paint (the indicator element mounts already
48
- // in place — no slide-in from zero; only later tab changes animate). Observes
49
- // the active tab (its own size, e.g. a changing "(3)" count) and the list
50
- // (container resize / reflow). A non-active sibling resizing while you're on
51
- // another tab is the one case not caught; it self-corrects on the next
52
- // selection or resize.
53
- useLayoutEffect(() => {
54
- const list = listRef.current
55
- if (!list || !activeValue) {
56
- setIndicator(null)
57
- setIndicatorActive(false)
58
- return
59
- }
60
- const el = list.querySelector<HTMLElement>(`[data-tab-value="${CSS.escape(activeValue)}"]`)
61
- if (!el) {
62
- setIndicator(null)
63
- setIndicatorActive(false)
64
- return
65
- }
66
- const measure = () => {
67
- setIndicator(
68
- orientation === 'horizontal'
69
- ? {offset: el.offsetLeft, size: el.offsetWidth}
70
- : {offset: el.offsetTop, size: el.offsetHeight},
71
- )
72
- setIndicatorActive(true)
73
- }
74
- measure()
75
- const ro = new ResizeObserver(measure)
76
- ro.observe(el)
77
- ro.observe(list)
78
- return () => ro.disconnect()
79
- }, [activeValue, orientation, setIndicatorActive])
80
-
81
- useEffect(() => {
82
- const el = listRef.current
83
- if (!el) return
84
- const listEl = el
85
-
86
- function handleKeyDown(e: KeyboardEvent) {
87
- const order = orderRef.current
88
- const tabs = tabsRef.current
89
- if (order.length === 0) return
90
-
91
- const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
92
- const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
93
-
94
- let targetValue: string | null = null
95
-
96
- switch (e.key) {
97
- case nextKey: {
98
- e.preventDefault()
99
- const currentIdx = activeValue ? order.indexOf(activeValue) : -1
100
- for (let i = 1; i <= order.length; i++) {
101
- const idx = (currentIdx + i) % order.length
102
- const val = order[idx]
103
- if (!tabs.get(val)) {
104
- targetValue = val
105
- break
106
- }
107
- }
108
- break
109
- }
110
- case prevKey: {
111
- e.preventDefault()
112
- const currentIdx = activeValue ? order.indexOf(activeValue) : 0
113
- for (let i = 1; i <= order.length; i++) {
114
- const idx = (currentIdx - i + order.length) % order.length
115
- const val = order[idx]
116
- if (!tabs.get(val)) {
117
- targetValue = val
118
- break
119
- }
120
- }
121
- break
122
- }
123
- case 'Home': {
124
- e.preventDefault()
125
- for (const val of order) {
126
- if (!tabs.get(val)) {
127
- targetValue = val
128
- break
129
- }
130
- }
131
- break
132
- }
133
- case 'End': {
134
- e.preventDefault()
135
- for (let i = order.length - 1; i >= 0; i--) {
136
- if (!tabs.get(order[i])) {
137
- targetValue = order[i]
138
- break
139
- }
140
- }
141
- break
142
- }
143
- }
144
-
145
- if (targetValue) {
146
- onSelect(targetValue)
147
- // Focus the newly activated tab button
148
- const tabEl = listEl.querySelector(
149
- `[data-tab-value="${targetValue}"]`,
150
- ) as HTMLElement | null
151
- tabEl?.focus()
152
- }
153
- }
154
-
155
- el.addEventListener('keydown', handleKeyDown)
156
- return () => el.removeEventListener('keydown', handleKeyDown)
157
- }, [orientation, activeValue, onSelect, tabsRef, orderRef])
50
+ const {indicator, reportTabLayout} = useTabIndicator({
51
+ listRef,
52
+ activeValue,
53
+ orientation,
54
+ setIndicatorActive,
55
+ })
56
+ useTabKeys({listRef, orientation, activeValue, onSelect, tabsRef, orderRef})
158
57
 
159
58
  return (
160
59
  <html.div
@@ -163,7 +62,7 @@ function List({children}: ListProps) {
163
62
  aria-orientation={orientation}
164
63
  style={[styles.list, orientation === 'vertical' && styles.listVertical]}
165
64
  >
166
- {children}
65
+ <TabLayoutContext.Provider value={reportTabLayout}>{children}</TabLayoutContext.Provider>
167
66
  {indicator && (
168
67
  <html.div
169
68
  aria-hidden
@@ -205,27 +104,30 @@ function Tab({value, disabled = false, children}: TabProps) {
205
104
  }
206
105
 
207
106
  return (
208
- <html.button
209
- type="button"
210
- role="tab"
211
- id={tabId}
212
- aria-selected={isActive}
213
- aria-controls={panelId}
214
- aria-disabled={disabled || undefined}
215
- data-tab-value={value}
216
- tabIndex={isActive ? 0 : -1}
217
- onClick={handleClick}
218
- style={[
219
- styles.tab,
220
- orientation === 'vertical' && styles.tabVertical,
221
- isActive &&
222
- (orientation === 'vertical' ? styles.tabActiveVertical : styles.tabActiveHorizontal),
223
- isActive && indicatorActive && styles.tabIndicatorActive,
224
- disabled && styles.tabDisabled,
225
- ]}
226
- >
227
- {children}
228
- </html.button>
107
+ <TabLayout value={value}>
108
+ <html.button
109
+ type="button"
110
+ role="tab"
111
+ id={tabId}
112
+ aria-selected={isActive}
113
+ aria-controls={panelId}
114
+ aria-disabled={disabled || undefined}
115
+ data-tab-value={value}
116
+ tabIndex={isActive ? 0 : -1}
117
+ onClick={handleClick}
118
+ style={[
119
+ styles.tab,
120
+ isNative && styles.nativeFlex,
121
+ orientation === 'vertical' && styles.tabVertical,
122
+ isActive &&
123
+ (orientation === 'vertical' ? styles.tabActiveVertical : styles.tabActiveHorizontal),
124
+ isActive && indicatorActive && styles.tabIndicatorActive,
125
+ disabled && styles.tabDisabled,
126
+ ]}
127
+ >
128
+ {children}
129
+ </html.button>
130
+ </TabLayout>
229
131
  )
230
132
  }
231
133