@duro-app/ui 5.22.0 → 5.24.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 (42) hide show
  1. package/dist/components/AppShell/AppShell.d.ts +10 -1
  2. package/dist/components/AppShell/AppShell.d.ts.map +1 -1
  3. package/dist/components/AppShell/AppShell.stories.d.ts +8 -0
  4. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
  5. package/dist/components/AppShell/styles.css.d.ts +7 -0
  6. package/dist/components/AppShell/styles.css.d.ts.map +1 -1
  7. package/dist/components/Select/Select.d.ts.map +1 -1
  8. package/dist/components/Select/Select.stories.d.ts +1 -0
  9. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  10. package/dist/components/Select/styles.css.d.ts +3 -0
  11. package/dist/components/Select/styles.css.d.ts.map +1 -1
  12. package/dist/components/SplitPane/SplitPane.d.ts +7 -1
  13. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
  14. package/dist/components/SplitPane/SplitPane.stories.d.ts +2 -0
  15. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
  16. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  17. package/dist/components/VirtualList/styles.css.d.ts +4 -0
  18. package/dist/components/VirtualList/styles.css.d.ts.map +1 -1
  19. package/dist/index.css +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2434 -2382
  23. package/dist/index.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/components/AppShell/AppShell.meta.ts +1 -1
  26. package/src/components/AppShell/AppShell.native.tsx +3 -0
  27. package/src/components/AppShell/AppShell.stories.tsx +68 -0
  28. package/src/components/AppShell/AppShell.tsx +16 -5
  29. package/src/components/AppShell/styles.css.ts +10 -0
  30. package/src/components/Select/Select.meta.ts +1 -1
  31. package/src/components/Select/Select.stories.tsx +37 -1
  32. package/src/components/Select/Select.tsx +26 -10
  33. package/src/components/Select/styles.css.ts +4 -0
  34. package/src/components/SplitPane/SplitPane.meta.ts +1 -1
  35. package/src/components/SplitPane/SplitPane.native.tsx +2 -0
  36. package/src/components/SplitPane/SplitPane.stories.tsx +47 -5
  37. package/src/components/SplitPane/SplitPane.tsx +32 -3
  38. package/src/components/VirtualList/VirtualList.meta.ts +1 -1
  39. package/src/components/VirtualList/VirtualList.tsx +2 -0
  40. package/src/components/VirtualList/styles.css.ts +7 -0
  41. package/src/index.native.ts +1 -0
  42. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.22.0",
3
+ "version": "5.24.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -92,7 +92,7 @@
92
92
  "dependencies": {
93
93
  "@tanstack/react-virtual": "^3.14.13",
94
94
  "qrcode": "^1.5.4",
95
- "@duro-app/tokens": "^5.22.0"
95
+ "@duro-app/tokens": "^5.24.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@babel/preset-typescript": "^7.28.0",
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- "An application's frame: a navigation rail (a labelled nav, the brand at its top, an optional footer at its foot) beside the main content (main), with an optional Header. Below collapseBelow (sm default, md or lg), measured on the shell's own width, the rail is hidden and one sticky bar (a banner, appBarH tall) holds a Menu button that opens the same navigation in a left Drawer, the brand, and the Header's content when there is one — a shell without a Header still has its navigation on a phone. Above it the Header is a row that scrolls with the page and the rail stays sticky. CSS makes the switch: the server renders one HTML for every width with the brand in both places, and display: none hides the inactive copy, so nothing moves after hydration. A skip link (the first tab stop) focuses Main; an Aside inside Main sticks below the bar. Labels come from props. Compound component — Root is required. On React Native it is always the narrow shape: the bar below the status bar, Main under it, and the Rail in a left Drawer; there is no skip link.",
5
+ "An application's frame: a navigation rail (a labelled nav, the brand at its top, an optional footer at its foot) beside the main content (main), with an optional Header. Below collapseBelow (sm default, md or lg), measured on the shell's own width, the rail is hidden and one sticky bar (a banner, appBarH tall) holds a Menu button that opens the same navigation in a left Drawer, the brand, and the Header's content when there is one — a shell without a Header still has its navigation on a phone. Above it the Header is a row that scrolls with the page and the rail stays sticky. CSS makes the switch: the server renders one HTML for every width with the brand in both places, and display: none hides the inactive copy, so nothing moves after hydration. A skip link (the first tab stop) focuses Main; an Aside inside Main sticks below the bar. Labels come from props. Compound component — Root is required. On React Native it is always the narrow shape: the bar below the status bar, Main under it, and the Rail in a left Drawer; there is no skip link. scroll=\"main\" makes it an app frame rather than a page: the shell is the viewport's height and Main scrolls on its own, so a SplitPane or VirtualList inside it fills Main and scrolls its own panes.",
6
6
  whenToUse: [
7
7
  'The frame of an app with a persistent navigation rail: an admin, a docs site, a dashboard',
8
8
  'A layout whose navigation must collapse into a drawer on a phone',
@@ -18,6 +18,7 @@ import {InLandmarkContext} from '../../shared/InLandmarkContext'
18
18
  import {nativeStyles} from './native.css'
19
19
 
20
20
  export type AppShellCollapse = 'sm' | 'md' | 'lg'
21
+ export type AppShellScroll = 'page' | 'main'
21
22
 
22
23
  interface AppShellContextValue {
23
24
  open: boolean
@@ -47,6 +48,8 @@ interface RootProps {
47
48
  brand?: ReactNode
48
49
  /** Web only: a phone is below every breakpoint, so native always shows the bar and the drawer. */
49
50
  collapseBelow?: AppShellCollapse
51
+ /** Web only: native Main always fills the screen under the bar and its content scrolls itself. */
52
+ scroll?: AppShellScroll
50
53
  /** `AppShell.Rail`, `AppShell.Main` and an optional `AppShell.Header`, as direct children. */
51
54
  children: ReactNode
52
55
  }
@@ -20,6 +20,8 @@ import {TextLink} from '../TextLink/TextLink'
20
20
  import {Aside} from '../Aside/Aside'
21
21
  import {Stack} from '../Stack/Stack'
22
22
  import {Prose} from '../Prose/Prose'
23
+ import {SplitPane} from '../SplitPane/SplitPane'
24
+ import {VirtualList} from '../VirtualList/VirtualList'
23
25
 
24
26
  // --- the story's frame -----------------------------------------------------
25
27
 
@@ -853,3 +855,69 @@ export const MenuButtonTouchTarget: Story = {
853
855
  })
854
856
  },
855
857
  }
858
+
859
+ const ROWS = Array.from({length: 500}, (_, i) => ({id: i, text: `Message ${i + 1}`}))
860
+
861
+ /**
862
+ * `scroll="main"`: an app, not a document. The shell is the viewport's height and Main scrolls on
863
+ * its own, so a SplitPane inside it fills Main and each pane scrolls by itself — here a 500-row
864
+ * VirtualList that mounts only the rows in view.
865
+ */
866
+ export const ScrollMain: Story = {
867
+ render: () => (
868
+ <AppShell.Root
869
+ menuLabel="Menu"
870
+ closeLabel="Close navigation"
871
+ skipLabel="Skip to content"
872
+ brand={BRAND}
873
+ scroll="main"
874
+ >
875
+ <AppShell.Rail aria-label="Navigation">
876
+ <SideNav.Root value="/sessions">
877
+ <SideNav.Item value="/sessions">Sessions</SideNav.Item>
878
+ </SideNav.Root>
879
+ </AppShell.Rail>
880
+ <AppShell.Main>
881
+ <SplitPane.Root
882
+ aria-label="Resize the session list"
883
+ defaultSize="sidebarW"
884
+ minSize="sidebarW"
885
+ maxSize="panelLg"
886
+ >
887
+ <SplitPane.Pane>
888
+ <Text>Sessions</Text>
889
+ </SplitPane.Pane>
890
+ <SplitPane.Handle />
891
+ <SplitPane.Pane>
892
+ <VirtualList
893
+ aria-label="Transcript"
894
+ items={ROWS}
895
+ getKey={(row) => row.id}
896
+ estimateSize="controlLg"
897
+ anchor="end"
898
+ renderItem={(row) => <Text>{row.text}</Text>}
899
+ />
900
+ </SplitPane.Pane>
901
+ </SplitPane.Root>
902
+ </AppShell.Main>
903
+ </AppShell.Root>
904
+ ),
905
+ }
906
+
907
+ /** ScrollMain's checks: Main stays within the viewport and the list windows its rows. */
908
+ export const ScrollMainBoundsMain: Story = {
909
+ ...ScrollMain,
910
+ tags: TEST_ONLY,
911
+ play: async () => {
912
+ const main = document.querySelector('main')!
913
+ const list = await waitFor(() => page().getByRole('list', {name: 'Transcript'}))
914
+ await waitFor(() =>
915
+ expect(list.querySelectorAll('[role="listitem"]').length).toBeGreaterThan(0),
916
+ )
917
+ await expect(main.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight)
918
+ await expect(list.querySelectorAll('[role="listitem"]').length).toBeLessThan(100)
919
+ await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight)
920
+ // The document itself does not scroll: Main does.
921
+ await expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(window.innerHeight)
922
+ },
923
+ }
@@ -39,6 +39,8 @@ import {styles} from './styles.css'
39
39
  * stop) focuses. */
40
40
 
41
41
  export type AppShellCollapse = 'sm' | 'md' | 'lg'
42
+ /** `page`: the document scrolls (a docs site). `main`: the shell is the viewport, Main scrolls (an app). */
43
+ export type AppShellScroll = 'page' | 'main'
42
44
 
43
45
  interface AppShellContextValue {
44
46
  open: boolean
@@ -46,6 +48,7 @@ interface AppShellContextValue {
46
48
  closeLabel: string
47
49
  brand: ReactNode
48
50
  collapseBelow: AppShellCollapse
51
+ scroll: AppShellScroll
49
52
  mainId: string
50
53
  mainRef: RefObject<HTMLElement | null>
51
54
  }
@@ -109,6 +112,13 @@ interface RootProps {
109
112
  * becomes a Drawer behind the Menu button. Default `sm`.
110
113
  */
111
114
  collapseBelow?: AppShellCollapse
115
+ /**
116
+ * What scrolls. `page` (default): the document, a page that grows with its
117
+ * content. `main`: the shell is exactly the viewport's height and Main
118
+ * scrolls on its own, so a SplitPane or VirtualList inside it can fill Main
119
+ * and scroll its own panes (an app such as a chat or a mail client).
120
+ */
121
+ scroll?: AppShellScroll
112
122
  /**
113
123
  * `AppShell.Rail`, `AppShell.Main` and an optional `AppShell.Header`, as
114
124
  * direct children (Root places the Header's content in its bar).
@@ -122,6 +132,7 @@ function Root({
122
132
  skipLabel,
123
133
  brand,
124
134
  collapseBelow = 'sm',
135
+ scroll = 'page',
125
136
  children,
126
137
  }: RootProps) {
127
138
  const [open, setOpen] = useState(false)
@@ -165,8 +176,8 @@ function Root({
165
176
  }, [open])
166
177
 
167
178
  const value = useMemo(
168
- () => ({open, setOpen, closeLabel, brand, collapseBelow, mainId, mainRef}),
169
- [open, closeLabel, brand, collapseBelow, mainId],
179
+ () => ({open, setOpen, closeLabel, brand, collapseBelow, scroll, mainId, mainRef}),
180
+ [open, closeLabel, brand, collapseBelow, scroll, mainId],
170
181
  )
171
182
 
172
183
  // Focus Main itself (tabIndex -1), not just scroll to it, and leave the
@@ -179,7 +190,7 @@ function Root({
179
190
  return (
180
191
  <AppShellContext.Provider value={value}>
181
192
  <html.div ref={containerRef} style={styles.container}>
182
- <html.div style={[styles.grid, variant.grid]}>
193
+ <html.div style={[styles.grid, variant.grid, scroll === 'main' && styles.gridViewport]}>
183
194
  <html.div style={styles.skip}>
184
195
  <TextLink href={`#${mainId}`} onNavigate={skip}>
185
196
  {skipLabel}
@@ -318,7 +329,7 @@ interface MainProps {
318
329
  }
319
330
 
320
331
  function Main({children, id, ref}: MainProps) {
321
- const {mainId, mainRef, collapseBelow} = useAppShell()
332
+ const {mainId, mainRef, collapseBelow, scroll} = useAppShell()
322
333
  const setRef = useCallback(
323
334
  (node: HTMLElement | null) => {
324
335
  mainRef.current = node
@@ -332,7 +343,7 @@ function Main({children, id, ref}: MainProps) {
332
343
  ref={setRef}
333
344
  id={id ?? mainId}
334
345
  tabIndex={-1}
335
- style={[styles.main, variants[collapseBelow].main]}
346
+ style={[styles.main, variants[collapseBelow].main, scroll === 'main' && styles.mainScrolls]}
336
347
  >
337
348
  {children}
338
349
  </html.main>
@@ -47,6 +47,10 @@ export const styles = css.create({
47
47
  backgroundColor: colors.bg,
48
48
  gridTemplateRows: 'auto 1fr',
49
49
  },
50
+ // scroll="main": the shell is the viewport, so Main's row has a definite height.
51
+ gridViewport: {
52
+ height: '100dvh',
53
+ },
50
54
  gridSm: {
51
55
  gridTemplateColumns: {default: 'minmax(0, 1fr)', [WIDE_SM]: RAIL_COLUMNS},
52
56
  gridTemplateAreas: {default: NARROW_AREAS, [WIDE_SM]: WIDE_AREAS},
@@ -204,6 +208,12 @@ export const styles = css.create({
204
208
  // Focused by the skip link: no ring on a region that is not a control.
205
209
  outlineStyle: 'none',
206
210
  },
211
+ // scroll="main": Main is a bounded region that scrolls by itself, and a
212
+ // child that fills it (height 100%) gets that bound.
213
+ mainScrolls: {
214
+ minHeight: 0,
215
+ overflowY: 'auto',
216
+ },
207
217
  mainSm: {
208
218
  paddingLeft: {default: spacing.md, [WIDE_SM]: spacing.lg},
209
219
  paddingRight: {default: spacing.md, [WIDE_SM]: spacing.lg},
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop.',
5
+ 'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop.',
6
6
  whenToUse: [
7
7
  'User must pick one option from a predefined list',
8
8
  'Form field for country, category, role selection',
@@ -1,5 +1,6 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect, fn, screen} from 'storybook/test'
2
+ import {expect, fn, screen, waitFor} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
3
4
  import {Dialog} from '../Dialog/Dialog'
4
5
  import {Select} from './Select'
5
6
 
@@ -261,3 +262,38 @@ export const LabelledAndEmpty: StoryObj<LabelledArgs> = {
261
262
  await expect(screen.queryAllByRole('option')).toHaveLength(0)
262
263
  },
263
264
  }
265
+
266
+ // A composer's mode Select sits at the bottom of the window: its list opens above
267
+ // the trigger, as Menu and Popover do, instead of running off the screen.
268
+ const bottom = css.create({
269
+ frame: {height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end'},
270
+ })
271
+
272
+ export const OpensAboveNearTheBottomEdge: Story = {
273
+ parameters: {layout: 'fullscreen'},
274
+ render: () => (
275
+ <html.div style={bottom.frame}>
276
+ <Select.Root defaultValue="default">
277
+ <Select.Trigger aria-label="Permission mode">
278
+ <Select.Value />
279
+ <Select.Icon />
280
+ </Select.Trigger>
281
+ <Select.Popup label="Permission mode">
282
+ <Select.Item value="default">Default</Select.Item>
283
+ <Select.Item value="read-only">Read only</Select.Item>
284
+ <Select.Item value="auto">Auto</Select.Item>
285
+ <Select.Item value="yolo">YOLO</Select.Item>
286
+ </Select.Popup>
287
+ </Select.Root>
288
+ </html.div>
289
+ ),
290
+ play: async ({canvas, userEvent}) => {
291
+ const trigger = canvas.getByRole('combobox', {name: 'Permission mode'})
292
+ await userEvent.click(trigger)
293
+ const list = await screen.findByRole('listbox', {name: 'Permission mode'})
294
+ await waitFor(() => expect(list).toBeVisible())
295
+ const listBox = list.getBoundingClientRect()
296
+ await expect(listBox.bottom).toBeLessThanOrEqual(trigger.getBoundingClientRect().top)
297
+ await expect(listBox.top).toBeGreaterThanOrEqual(0)
298
+ },
299
+ }
@@ -1,11 +1,13 @@
1
1
  import {Children, type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
2
2
  import {createPortal} from 'react-dom'
3
3
  import {html} from 'react-strict-dom'
4
+ import {SPACING_PX} from '@duro-app/tokens/keys'
4
5
  import {styles} from './styles.css'
5
6
  import {SelectContext, useSelect} from './SelectContext'
6
7
  import {useSelectRoot} from './useSelectRoot'
7
8
  import {useFieldContext} from '../Field/FieldContext'
8
9
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
10
+ import {computePopoverPosition} from '../Popover/position'
9
11
  import {usePopoverLayer} from '../Popover/PopoverLayerContext'
10
12
  import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
11
13
  import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
@@ -140,18 +142,32 @@ function Popup({children, label, empty}: PopupProps) {
140
142
  const {open, close, listboxId, triggerRef} = useSelect()
141
143
  const mount = usePortalMount()
142
144
  const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
145
+ const listboxRef = useRef<HTMLDivElement>(null)
143
146
 
144
- // Measure the trigger when the popup opens, and re-measure on scroll/resize so
145
- // the dropdown follows it. Capture-phase scroll catches scroll on any ancestor
146
- // (e.g. a Dialog body).
147
+ // Place the list against the trigger when it opens, below it unless it would
148
+ // overflow the viewport there and fits above (as Menu and Popover do), and
149
+ // re-place it on scroll/resize so it follows the trigger. Capture-phase scroll
150
+ // catches scroll on any ancestor (e.g. a Dialog body). The list is laid out,
151
+ // invisible, before its first placement so its height can be measured.
147
152
  useLayoutEffect(() => {
148
153
  if (!open) return
149
154
  const update = () => {
150
- const el = triggerRef.current
151
- if (!el) return
152
- const rect = el.getBoundingClientRect()
153
- // `xs` token = 4px gap below the trigger.
154
- setCoords({top: rect.bottom + 4, left: rect.left, width: rect.width})
155
+ const rect = triggerRef.current?.getBoundingClientRect()
156
+ const list = listboxRef.current
157
+ if (!rect || !list) return
158
+ const next = computePopoverPosition({
159
+ anchor: {top: rect.top, left: rect.left, width: rect.width, height: rect.height},
160
+ popup: {width: Math.max(list.offsetWidth, rect.width), height: list.offsetHeight},
161
+ viewport: {width: window.innerWidth, height: window.innerHeight},
162
+ side: 'bottom',
163
+ align: 'start',
164
+ offset: SPACING_PX.xs,
165
+ })
166
+ setCoords((prev) =>
167
+ prev && prev.top === next.top && prev.left === next.left && prev.width === rect.width
168
+ ? prev
169
+ : {top: next.top, left: next.left, width: rect.width},
170
+ )
155
171
  }
156
172
  update()
157
173
  window.addEventListener('resize', update)
@@ -162,7 +178,6 @@ function Popup({children, label, empty}: PopupProps) {
162
178
  }
163
179
  }, [open, triggerRef])
164
180
 
165
- const listboxRef = useRef<HTMLDivElement>(null)
166
181
  const backdropRef = useRef<HTMLDivElement>(null)
167
182
  usePopoverLayer(listboxRef, {active: open})
168
183
  usePopoverLayer(backdropRef, {active: open})
@@ -178,7 +193,8 @@ function Popup({children, label, empty}: PopupProps) {
178
193
  aria-hidden={!open}
179
194
  style={[
180
195
  styles.popup,
181
- (!open || !coords) && styles.hidden,
196
+ !open && styles.hidden,
197
+ open && !coords && styles.measuring,
182
198
  coords && styles.popupPosition(coords.top, coords.left, coords.width),
183
199
  ]}
184
200
  >
@@ -124,6 +124,10 @@ export const styles = css.create({
124
124
  hidden: {
125
125
  display: 'none',
126
126
  },
127
+ // Open but not yet placed: laid out (so its height can be measured) and unseen.
128
+ measuring: {
129
+ visibility: 'hidden',
130
+ },
127
131
  // Popup's `empty`: no items to choose from.
128
132
  empty: {
129
133
  paddingTop: spacing.md,
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
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. A controlled collapsed prop shrinks the first pane to a strip (the iconButton size) that shows collapsedContent, removes the Handle, and keeps the first pane’s own children mounted but inert and hidden (state kept, no tab stops, out of the accessibility tree); the prior width returns when it turns false. Native ignores both. The Handle is a hairline that thickens to the accent on hover, keyboard focus or drag.',
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. onSizeChange reports every width while the handle moves; onSizeCommit reports the width once a resize ends (a released drag, each keyboard step), the moment to persist it. Below collapseBelow, measured on its own width, the panes stack in one column and the handle goes; on native they always stack. A controlled collapsed prop shrinks the first pane to a strip (the iconButton size) that shows collapsedContent, removes the Handle, and keeps the first pane’s own children mounted but inert and hidden (state kept, no tab stops, out of the accessibility tree); the prior width returns when it turns false. Native ignores both. The Handle is a hairline that thickens to the accent on hover, keyboard focus or drag.',
6
6
  whenToUse: [
7
7
  'A list beside its detail where people need more room on either side (a session list and its transcript)',
8
8
  'An editor beside its preview',
@@ -12,6 +12,8 @@ interface RootProps {
12
12
  size?: number
13
13
  /** Web only. */
14
14
  onSizeChange?: (px: number) => void
15
+ /** Web only: called once a resize ends (native always stacks). */
16
+ onSizeCommit?: (px: number) => void
15
17
  /** Web only. */
16
18
  minSize?: SizeToken
17
19
  /** Web only. */
@@ -34,6 +34,7 @@ interface SplitArgs {
34
34
  maxSize: SizeToken
35
35
  collapseBelow: Breakpoint
36
36
  onSizeChange?: (px: number) => void
37
+ onSizeCommit?: (px: number) => void
37
38
  collapsed?: boolean
38
39
  collapsedContent?: ReactNode
39
40
  }
@@ -50,11 +51,19 @@ const PANE_SIZES: SizeToken[] = ['sidebarW', 'panelSm', 'panelMd', 'panelLg']
50
51
 
51
52
  /** The picked B0 direction (docs/mockups/split-pane/Hairline.dc.html). */
52
53
  function Split(props: Partial<SplitArgs>) {
53
- const {defaultSize, minSize, maxSize, collapseBelow, onSizeChange, collapsed, collapsedContent} =
54
- {
55
- ...DEFAULT_ARGS,
56
- ...props,
57
- }
54
+ const {
55
+ defaultSize,
56
+ minSize,
57
+ maxSize,
58
+ collapseBelow,
59
+ onSizeChange,
60
+ onSizeCommit,
61
+ collapsed,
62
+ collapsedContent,
63
+ } = {
64
+ ...DEFAULT_ARGS,
65
+ ...props,
66
+ }
58
67
  return (
59
68
  <html.div style={frame.fill}>
60
69
  <SplitPane.Root
@@ -64,6 +73,7 @@ function Split(props: Partial<SplitArgs>) {
64
73
  maxSize={maxSize}
65
74
  collapseBelow={collapseBelow}
66
75
  onSizeChange={onSizeChange}
76
+ onSizeCommit={onSizeCommit}
67
77
  collapsed={collapsed}
68
78
  collapsedContent={collapsedContent}
69
79
  >
@@ -145,6 +155,38 @@ export const KeyboardMovesTheSeparator: Story = {
145
155
  },
146
156
  }
147
157
 
158
+ // A drag reports every width while it moves and the final one once, on release;
159
+ // a keyboard step is its own commit. An app persists the width on commit.
160
+ export const ResizeCommitsOnceWhenItEnds: StoryObj<SplitArgs> = {
161
+ args: {onSizeChange: fn(), onSizeCommit: fn()},
162
+ render: (args) => <Split {...args} />,
163
+ beforeEach: async () => {
164
+ await setViewport(1200, 800)
165
+ },
166
+ play: async ({args}) => {
167
+ await waitFor(() => expect(separator()).not.toBeNull())
168
+ const handle = separator()!
169
+ const box = handle.getBoundingClientRect()
170
+ const x = box.left + box.width / 2
171
+ const y = box.top + box.height / 2
172
+ await userEvent.pointer([
173
+ {keys: '[MouseLeft>]', target: handle, coords: {clientX: x, clientY: y}},
174
+ {target: handle, coords: {clientX: x + 20, clientY: y}},
175
+ {target: handle, coords: {clientX: x + 40, clientY: y}},
176
+ {keys: '[/MouseLeft]', target: handle, coords: {clientX: x + 40, clientY: y}},
177
+ ])
178
+ // two widths while it moved, one commit when it was released
179
+ await expect(args.onSizeChange).toHaveBeenCalledTimes(2)
180
+ await expect(args.onSizeCommit).toHaveBeenCalledTimes(1)
181
+ await expect(args.onSizeCommit).toHaveBeenLastCalledWith(SIZES_PX.sidebarW + 40)
182
+
183
+ handle.focus()
184
+ await userEvent.keyboard('{ArrowLeft}')
185
+ await expect(args.onSizeCommit).toHaveBeenCalledTimes(2)
186
+ await expect(args.onSizeCommit).toHaveBeenLastCalledWith(SIZES_PX.sidebarW + 40 - SPACING_PX.md)
187
+ },
188
+ }
189
+
148
190
  export const NarrowStacksInOneColumn: Story = {
149
191
  render: () => <Split />,
150
192
  beforeEach: async () => {
@@ -21,6 +21,7 @@ interface SplitPaneContextValue {
21
21
  min: number
22
22
  max: number
23
23
  setSize: (px: number) => void
24
+ commitSize: (px: number) => void
24
25
  stacked: boolean
25
26
  collapsed: boolean
26
27
  collapsedContent: ReactNode
@@ -53,6 +54,12 @@ interface RootProps {
53
54
  size?: number
54
55
  /** Called with the primary pane's new width while the handle moves */
55
56
  onSizeChange?: (px: number) => void
57
+ /**
58
+ * Called once a resize ends, with the width it ended at: when a drag is
59
+ * released (or cancelled) and after each keyboard step. Persist the width
60
+ * here rather than on every `onSizeChange`.
61
+ */
62
+ onSizeCommit?: (px: number) => void
56
63
  /** The primary pane's narrowest width, a size token. Default: `sidebarW` */
57
64
  minSize?: SizeToken
58
65
  /** The primary pane's widest width, a size token. Default: `panelLg` */
@@ -83,6 +90,7 @@ function Root({
83
90
  defaultSize = 'sidebarW',
84
91
  size: controlledSize,
85
92
  onSizeChange,
93
+ onSizeCommit,
86
94
  minSize = 'sidebarW',
87
95
  maxSize = 'panelLg',
88
96
  collapseBelow = 'sm',
@@ -102,6 +110,10 @@ function Root({
102
110
  },
103
111
  [controlledSize, onSizeChange, min, max],
104
112
  )
113
+ const commitSize = useCallback(
114
+ (px: number) => onSizeCommit?.(clamp(Math.round(px), min, max)),
115
+ [onSizeCommit, min, max],
116
+ )
105
117
  const rootRef = useRef<HTMLDivElement>(null)
106
118
  const stacked = useContainerBelow(rootRef, collapseBelow)
107
119
  const primaryId = useId()
@@ -117,7 +129,18 @@ function Root({
117
129
 
118
130
  return (
119
131
  <SplitPaneContext.Provider
120
- value={{size, min, max, setSize, stacked, collapsed, collapsedContent, primaryId, label}}
132
+ value={{
133
+ size,
134
+ min,
135
+ max,
136
+ setSize,
137
+ commitSize,
138
+ stacked,
139
+ collapsed,
140
+ collapsedContent,
141
+ primaryId,
142
+ label,
143
+ }}
121
144
  >
122
145
  <html.div ref={rootRef} style={[styles.root, stacked && styles.rootStacked]}>
123
146
  {items}
@@ -171,7 +194,8 @@ function Pane({children}: {children: ReactNode}) {
171
194
  }
172
195
 
173
196
  function Handle() {
174
- const {size, min, max, setSize, stacked, collapsed, primaryId, label} = useSplitPane('Handle')
197
+ const {size, min, max, setSize, commitSize, stacked, collapsed, primaryId, label} =
198
+ useSplitPane('Handle')
175
199
  const [hovered, setHovered] = useState(false)
176
200
  const [keyboardFocus, setKeyboardFocus] = useState(false)
177
201
  const [dragging, setDragging] = useState(false)
@@ -198,10 +222,11 @@ function Handle() {
198
222
  if (next === null) return
199
223
  e.preventDefault()
200
224
  setSize(next)
225
+ commitSize(next)
201
226
  }
202
227
  el.addEventListener('keydown', onKeyDown)
203
228
  return () => el.removeEventListener('keydown', onKeyDown)
204
- }, [size, min, max, setSize, stacked, collapsed])
229
+ }, [size, min, max, setSize, commitSize, stacked, collapsed])
205
230
 
206
231
  if (stacked || collapsed) return null
207
232
  const active = hovered || keyboardFocus || dragging
@@ -240,12 +265,16 @@ function Handle() {
240
265
  setSize(drag.current.startSize + e.clientX - drag.current.startX)
241
266
  }}
242
267
  onPointerUp={() => {
268
+ if (!drag.current) return
243
269
  drag.current = null
244
270
  setDragging(false)
271
+ commitSize(size)
245
272
  }}
246
273
  onPointerCancel={() => {
274
+ if (!drag.current) return
247
275
  drag.current = null
248
276
  setDragging(false)
277
+ commitSize(size)
249
278
  }}
250
279
  >
251
280
  <html.span aria-hidden style={[styles.line, active && styles.lineActive]} />
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'A windowed list on @tanstack/react-virtual: only the rows in view (plus overscan) are mounted, and rows are measured so they may differ in height. role="list" with listitem rows carrying aria-setsize and aria-posinset. anchor="end" opens at the last item and stays pinned to the bottom while new items arrive and the reader is at the bottom (a transcript, a log). It fills its parent, or stops at maxHeight. onStartReached fires once each time the reader scrolls to the start (load older history), and prepending items keeps the first visible row in place. On native it is a FlatList with the same props (role list; rows carry no listitem role, which React Native lacks) (maintainVisibleContentPosition for prepend).',
5
+ 'A windowed list on @tanstack/react-virtual: only the rows in view (plus overscan) are mounted, and rows are measured so they may differ in height. role="list" with listitem rows carrying aria-setsize and aria-posinset. anchor="end" opens at the last item and stays pinned to the bottom while new items arrive and the reader is at the bottom (a transcript, a log). It fills its parent, or stops at maxHeight. onStartReached fires once each time the reader scrolls to the start (load older history), and prepending items keeps the first visible row in place. On native it is a FlatList with the same props (role list; rows carry no listitem role, which React Native lacks) (maintainVisibleContentPosition for prepend). The scroll region is a tab stop with a focus ring, so the keyboard can scroll rows that hold no control.',
6
6
  whenToUse: [
7
7
  'Hundreds to tens of thousands of rows: sessions, a chat transcript, log lines',
8
8
  'A list that grows at the bottom while the reader follows it (anchor="end")',
@@ -167,6 +167,8 @@ export function VirtualList<T>({
167
167
  ref={scrollRef}
168
168
  role="list"
169
169
  aria-label={ariaLabel}
170
+ // A scroll region the keyboard reaches even when no row holds a control.
171
+ tabIndex={0}
170
172
  style={[
171
173
  styles.scroller,
172
174
  maxHeight !== undefined && styles.scrollMax(resolveLength(maxHeight)),
@@ -1,4 +1,6 @@
1
1
  import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
2
4
 
3
5
  export const styles = css.create({
4
6
  // The scroll container: it fills its parent (a pane, a flex column) by
@@ -10,6 +12,11 @@ export const styles = css.create({
10
12
  minHeight: 0,
11
13
  overflowY: 'auto',
12
14
  overscrollBehavior: 'contain',
15
+ // Focusable so the keyboard can scroll rows that hold no control (WCAG 2.1.1).
16
+ outlineWidth: {default: 0, ':focus-visible': borders.focusRing},
17
+ outlineStyle: {default: 'none', ':focus-visible': 'solid'},
18
+ outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
19
+ outlineOffset: {default: 0, ':focus-visible': `calc(-1 * ${borders.focusRing})`},
13
20
  },
14
21
  scrollMax: (maxHeight: number | string) => ({
15
22
  height: 'auto',
@@ -73,6 +73,7 @@ export {SplitPane} from './components/SplitPane/SplitPane'
73
73
  export {
74
74
  AppShell,
75
75
  type AppShellCollapse,
76
+ type AppShellScroll,
76
77
  type AppShellRailState,
77
78
  } from './components/AppShell/AppShell'
78
79
 
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {Alert, type AlertVariant} from './components/Alert/Alert'
13
13
  export {
14
14
  AppShell,
15
15
  type AppShellCollapse,
16
+ type AppShellScroll,
16
17
  type AppShellRailState,
17
18
  } from './components/AppShell/AppShell'
18
19
  export {Aside} from './components/Aside/Aside'