@duro-app/ui 5.22.0 → 5.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AppShell/AppShell.d.ts +10 -1
- package/dist/components/AppShell/AppShell.d.ts.map +1 -1
- package/dist/components/AppShell/AppShell.stories.d.ts +8 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
- package/dist/components/AppShell/styles.css.d.ts +7 -0
- package/dist/components/AppShell/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
- package/dist/components/VirtualList/styles.css.d.ts +4 -0
- package/dist/components/VirtualList/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1450 -1423
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +1 -1
- package/src/components/AppShell/AppShell.native.tsx +3 -0
- package/src/components/AppShell/AppShell.stories.tsx +68 -0
- package/src/components/AppShell/AppShell.tsx +16 -5
- package/src/components/AppShell/styles.css.ts +10 -0
- package/src/components/VirtualList/VirtualList.meta.ts +1 -1
- package/src/components/VirtualList/VirtualList.tsx +2 -0
- package/src/components/VirtualList/styles.css.ts +7 -0
- package/src/index.native.ts +1 -0
- 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.
|
|
3
|
+
"version": "5.23.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.
|
|
95
|
+
"@duro-app/tokens": "^5.23.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
|
-
'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',
|
package/src/index.native.ts
CHANGED
package/src/index.ts
CHANGED