@duro-app/ui 5.24.0 → 5.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.24.0",
3
+ "version": "5.25.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.24.0"
95
+ "@duro-app/tokens": "^5.25.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
- 'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. A click or Enter on a branch row with no href selects it and toggles it open or closed (a folder); a branch with href navigates, and its chevron toggles. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required. On React Native each row is a pressable link (href) or button, and a branch has its own chevron control that expands it (named by the optional Root prop expandLabel(text), default the item text); selection, expansion and aria-level are the same, the keyboard model (arrows, Home/End, typeahead) is web only. On native onNavigate is required for in-app routes: without it a relative href goes to the system (Linking.openURL). On web a tree with more items than Root virtualizeThreshold (default 150, open or not) is windowed: only the rows in view mount, as flat treeitems with aria-level, aria-setsize and aria-posinset; maxHeight caps its scroll viewport. Items must be JSX descendants of Root (fragments and arrays are fine, an Item hidden inside another component is not seen by the window). Native renders every row.',
5
+ 'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. A click or Enter on a branch row with no href selects it and toggles it open or closed (a folder); a branch with href navigates, and its chevron toggles. An Item with expandable is a branch before its children exist (a directory listed only when opened): it opens like any branch and onExpandedChange tells the app to load them. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required. On React Native each row is a pressable link (href) or button, and a branch has its own chevron control that expands it (named by the optional Root prop expandLabel(text), default the item text); selection, expansion and aria-level are the same, the keyboard model (arrows, Home/End, typeahead) is web only. On native onNavigate is required for in-app routes: without it a relative href goes to the system (Linking.openURL). On web a tree with more items than Root virtualizeThreshold (default 150, open or not) is windowed: only the rows in view mount, as flat treeitems with aria-level, aria-setsize and aria-posinset; maxHeight caps its scroll viewport. Items must be JSX descendants of Root (fragments and arrays are fine, an Item hidden inside another component is not seen by the window). Native renders every row.',
6
6
  whenToUse: [
7
7
  'Browsing data that nests to any depth — a file tree, zones › services › capabilities, namespace › resource',
8
8
  'Picking one node of a hierarchy to show its details beside the tree',
@@ -616,3 +616,42 @@ export const BranchRowToggles: Story = {
616
616
  await expect(branch).toHaveAttribute('aria-expanded', 'false')
617
617
  },
618
618
  }
619
+
620
+ /** A file tree that lists a directory only when it is opened (`expandable`). */
621
+ function LazyFolders() {
622
+ const [expanded, setExpanded] = useState<string[]>([])
623
+ const [loaded, setLoaded] = useState<ReadonlySet<string>>(() => new Set())
624
+ return (
625
+ <Tree.Root
626
+ aria-label="Folders"
627
+ expanded={expanded}
628
+ onExpandedChange={(next) => {
629
+ setExpanded(next)
630
+ // The app lists a directory the first time it opens.
631
+ setLoaded((prev) => new Set([...prev, ...next]))
632
+ }}
633
+ >
634
+ <Tree.Item value="src" label="src" expandable>
635
+ {loaded.has('src') ? <Tree.Item value="app" label="app.ts" /> : null}
636
+ </Tree.Item>
637
+ <Tree.Item value="readme" label="README.md" />
638
+ </Tree.Root>
639
+ )
640
+ }
641
+
642
+ export const ExpandableBranchLoadsOnOpen: Story = {
643
+ name: 'Expandable branch loads on open',
644
+ render: () => <LazyFolders />,
645
+ play: async ({canvas, userEvent}) => {
646
+ const branch = canvas.getByRole('treeitem', {name: 'src'})
647
+ // A branch before its children exist: it can open, a leaf cannot.
648
+ await expect(branch).toHaveAttribute('aria-expanded', 'false')
649
+ await expect(canvas.getByRole('treeitem', {name: 'README.md'})).not.toHaveAttribute(
650
+ 'aria-expanded',
651
+ )
652
+ branch.focus()
653
+ await userEvent.keyboard('{ArrowRight}')
654
+ await expect(branch).toHaveAttribute('aria-expanded', 'true')
655
+ await expect(await canvas.findByRole('treeitem', {name: 'app.ts'})).toBeVisible()
656
+ },
657
+ }
@@ -164,10 +164,19 @@ function Root({
164
164
 
165
165
  // --- Item ---
166
166
 
167
- function Item({value, label, textValue, children, meta, href, onNavigate}: TreeItemProps) {
167
+ function Item({
168
+ value,
169
+ label,
170
+ textValue,
171
+ children,
172
+ meta,
173
+ href,
174
+ onNavigate,
175
+ expandable,
176
+ }: TreeItemProps) {
168
177
  const {isExpanded} = useTree()
169
178
  const level = useContext(TreeLevelContext)
170
- const hasChildren = Children.count(children) > 0
179
+ const hasChildren = Children.count(children) > 0 || expandable === true
171
180
 
172
181
  return (
173
182
  <TreeItemRow
@@ -9,8 +9,14 @@ export interface TreeItemProps {
9
9
  label: ReactNode
10
10
  /** plain text for typeahead when `label` isn't a string */
11
11
  textValue?: string
12
- /** nested items: the branch's children (none = a leaf) */
12
+ /** nested items: the branch's children (none = a leaf, unless `expandable`) */
13
13
  children?: ReactNode
14
+ /**
15
+ * A branch before its children exist (a directory not listed yet): it shows the
16
+ * chevron and opens like any branch, and `onExpandedChange` tells the app to load
17
+ * them. Pass the children once they are there (or a loading row meanwhile).
18
+ */
19
+ expandable?: boolean
14
20
  /** trailing content on the row (a count, a status) */
15
21
  meta?: ReactNode
16
22
  /**
@@ -97,8 +103,17 @@ export function collectRows(
97
103
  const visit = (nodes: ReactNode, level: number, parent: string | null, visible: boolean) => {
98
104
  const items = itemsOf(nodes, isItem)
99
105
  items.forEach((item, index) => {
100
- const {value, label, textValue, children: nested, meta, href, onNavigate} = item.props
101
- const hasChildren = Children.count(nested) > 0
106
+ const {
107
+ value,
108
+ label,
109
+ textValue,
110
+ children: nested,
111
+ meta,
112
+ href,
113
+ onNavigate,
114
+ expandable,
115
+ } = item.props
116
+ const hasChildren = Children.count(nested) > 0 || expandable === true
102
117
  if (hasChildren && countLeaves(nested) !== itemsOf(nested, isItem).length) complete = false
103
118
  const open = hasChildren && isOpen(value)
104
119
  total += 1