@w5-ui/react 0.5.1 → 0.8.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": "@w5-ui/react",
3
- "version": "0.5.1",
3
+ "version": "0.8.0",
4
4
  "description": "React 18 components for the Dash UI design system - ~170 dashboard-focused components with full type defs and SSR support. Part of Webber's design systems.",
5
5
  "keywords": [
6
6
  "react",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@w5-ui/assets": "^0.2.1",
42
- "@w5-ui/tokens": "^0.5.0"
42
+ "@w5-ui/tokens": "^0.8.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/react": "^18.3.0",
@@ -0,0 +1,425 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+ import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'
3
+
4
+ /**
5
+ * TreeBrowser - React mirror of the Svelte `@w5-ui/svelte` component
6
+ * (instructure-ui `ui-tree-browser`, medium size, `folderTree` variant).
7
+ * Styling lives in `@w5-ui/tokens/dashboard.css` under `.tree-browser*`;
8
+ * see the Svelte source for the token mapping. API mirrors 1:1.
9
+ */
10
+
11
+ export interface TreeBrowserCollection {
12
+ id: string
13
+ name: string
14
+ descriptor?: string
15
+ collections?: string[]
16
+ items?: string[]
17
+ }
18
+
19
+ export interface TreeBrowserItem {
20
+ id: string
21
+ name: string
22
+ descriptor?: string
23
+ }
24
+
25
+ export type TreeBrowserNodeType = 'collection' | 'item'
26
+
27
+ export interface TreeBrowserRowContext {
28
+ id: string
29
+ type: TreeBrowserNodeType
30
+ name: string
31
+ }
32
+
33
+ export interface TreeBrowserProps extends Omit<HTMLAttributes<HTMLUListElement>, 'onSelect'> {
34
+ collections: Record<string, TreeBrowserCollection>
35
+ items?: Record<string, TreeBrowserItem>
36
+ rootId: string
37
+ /** Uncontrolled initial expansion. Ignored when `expanded` is set. */
38
+ defaultExpanded?: string[]
39
+ /** Controlled expansion set. Pair with `onToggle`. */
40
+ expanded?: string[]
41
+ onToggle?: (id: string) => void
42
+ selected?: string
43
+ onSelect?: (id: string, type: TreeBrowserNodeType) => void
44
+ size?: 'medium'
45
+ variant?: 'folderTree' | 'indent'
46
+ label?: string
47
+ /** Per-row leading icon; overrides the built-in folder/file glyphs. */
48
+ renderIcon?: (ctx: TreeBrowserRowContext) => ReactNode
49
+ /** Trailing badge (missing-file marker, count, etc.). */
50
+ renderBadge?: (ctx: TreeBrowserRowContext) => ReactNode
51
+ }
52
+
53
+ interface FlatNode {
54
+ id: string
55
+ type: TreeBrowserNodeType
56
+ hasChildren: boolean
57
+ parentId: string | undefined
58
+ }
59
+
60
+ function childIds(
61
+ collections: Record<string, TreeBrowserCollection>,
62
+ id: string,
63
+ ): { collections: string[]; items: string[] } {
64
+ const node = collections[id]
65
+ return { collections: node?.collections ?? [], items: node?.items ?? [] }
66
+ }
67
+
68
+ function hasChildren(collections: Record<string, TreeBrowserCollection>, id: string): boolean {
69
+ const { collections: c, items } = childIds(collections, id)
70
+ return c.length > 0 || items.length > 0
71
+ }
72
+
73
+ /** Root collection's direct children (folders then items). */
74
+ export function topLevelIds(
75
+ collections: Record<string, TreeBrowserCollection>,
76
+ rootId: string,
77
+ ): string[] {
78
+ const root = collections[rootId]
79
+ if (!root) return []
80
+ return [...(root.collections ?? []), ...(root.items ?? [])]
81
+ }
82
+
83
+ function flattenVisible(
84
+ collections: Record<string, TreeBrowserCollection>,
85
+ rootId: string,
86
+ expanded: Set<string>,
87
+ ): FlatNode[] {
88
+ const out: FlatNode[] = []
89
+ const seen = new Set<string>([rootId])
90
+ const walk = (collectionId: string): void => {
91
+ const { collections: cols, items } = childIds(collections, collectionId)
92
+ for (const childId of cols) {
93
+ if (seen.has(childId)) continue
94
+ seen.add(childId)
95
+ const childHasChildren = hasChildren(collections, childId)
96
+ out.push({
97
+ id: childId,
98
+ type: 'collection',
99
+ hasChildren: childHasChildren,
100
+ parentId: collectionId,
101
+ })
102
+ if (childHasChildren && expanded.has(childId)) walk(childId)
103
+ }
104
+ for (const itemId of items) {
105
+ if (seen.has(itemId)) continue
106
+ seen.add(itemId)
107
+ out.push({ id: itemId, type: 'item', hasChildren: false, parentId: collectionId })
108
+ }
109
+ }
110
+ walk(rootId)
111
+ return out
112
+ }
113
+
114
+ function FolderIcon({ open }: { open: boolean }) {
115
+ return (
116
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="16" height="16">
117
+ {open ? (
118
+ <path
119
+ d="M1.5 5.5h13l-1.6 7A1 1 0 0 1 11.9 13H3.4a1 1 0 0 1-1-.8L1 4.2a1 1 0 0 1 1-1.2h3.2a1 1 0 0 1 .8.4l.9 1.2H13a1 1 0 0 1 1 1v.9"
120
+ stroke="currentColor"
121
+ strokeWidth="1.2"
122
+ strokeLinejoin="round"
123
+ />
124
+ ) : (
125
+ <path
126
+ d="M1.5 3.8a1 1 0 0 1 1-1h3.2a1 1 0 0 1 .8.4l.9 1.2h6.1a1 1 0 0 1 1 1v6.3a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V3.8Z"
127
+ stroke="currentColor"
128
+ strokeWidth="1.2"
129
+ strokeLinejoin="round"
130
+ />
131
+ )}
132
+ </svg>
133
+ )
134
+ }
135
+
136
+ function FileIcon() {
137
+ return (
138
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="16" height="16">
139
+ <path
140
+ d="M4 1.5h5L13 5.5v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-12a1 1 0 0 1 1-1Z"
141
+ stroke="currentColor"
142
+ strokeWidth="1.2"
143
+ strokeLinejoin="round"
144
+ />
145
+ <path d="M9 1.5v4h4" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
146
+ </svg>
147
+ )
148
+ }
149
+
150
+ interface NodeProps {
151
+ id: string
152
+ type: TreeBrowserNodeType
153
+ collections: Record<string, TreeBrowserCollection>
154
+ items: Record<string, TreeBrowserItem>
155
+ expanded: Set<string>
156
+ selected: string | undefined
157
+ focusedId: string
158
+ variant: 'folderTree' | 'indent'
159
+ renderIcon?: (ctx: TreeBrowserRowContext) => ReactNode
160
+ renderBadge?: (ctx: TreeBrowserRowContext) => ReactNode
161
+ }
162
+
163
+ function TreeBrowserNode(props: NodeProps) {
164
+ const { id, type, collections, items, expanded, selected, focusedId, renderIcon, renderBadge } =
165
+ props
166
+ const isCollection = type === 'collection'
167
+ const node = isCollection ? collections[id] : items[id]
168
+ const name = node?.name ?? ''
169
+ const descriptor = node?.descriptor
170
+ const nodeHasChildren = isCollection && hasChildren(collections, id)
171
+ const isExpanded = expanded.has(id)
172
+ const isSelected = selected === id
173
+ const children = isCollection ? childIds(collections, id) : { collections: [], items: [] }
174
+ const ctx: TreeBrowserRowContext = { id, type, name }
175
+
176
+ return (
177
+ <li
178
+ role="treeitem"
179
+ aria-expanded={nodeHasChildren ? isExpanded : undefined}
180
+ aria-selected={isSelected}
181
+ tabIndex={focusedId === id ? 0 : -1}
182
+ data-tree-id={id}
183
+ data-type={type}
184
+ className="tree-browser__item"
185
+ >
186
+ <div className={`tree-browser__row${isSelected ? ' tree-browser__row--selected' : ''}`}>
187
+ {nodeHasChildren ? (
188
+ <svg
189
+ className={`tree-browser__chevron${isExpanded ? ' tree-browser__chevron--open' : ''}`}
190
+ viewBox="0 0 12 12"
191
+ fill="none"
192
+ aria-hidden="true"
193
+ >
194
+ <path
195
+ d="M4 2l4 4-4 4"
196
+ stroke="currentColor"
197
+ strokeWidth="1.5"
198
+ strokeLinecap="round"
199
+ strokeLinejoin="round"
200
+ />
201
+ </svg>
202
+ ) : (
203
+ <span className="tree-browser__indent" aria-hidden="true" />
204
+ )}
205
+ <span className="tree-browser__icon" aria-hidden="true">
206
+ {renderIcon ? (
207
+ renderIcon(ctx)
208
+ ) : isCollection ? (
209
+ <FolderIcon open={isExpanded} />
210
+ ) : (
211
+ <FileIcon />
212
+ )}
213
+ </span>
214
+ <span className="tree-browser__names">
215
+ <span className="tree-browser__name">{name}</span>
216
+ {descriptor && <span className="tree-browser__descriptor">{descriptor}</span>}
217
+ </span>
218
+ {renderBadge && <span className="tree-browser__badge">{renderBadge(ctx)}</span>}
219
+ </div>
220
+ {isCollection && nodeHasChildren && isExpanded && (
221
+ <ul role="group" className="tree-browser__group">
222
+ {children.collections.map((childId) => (
223
+ <TreeBrowserNode key={childId} {...props} id={childId} type="collection" />
224
+ ))}
225
+ {children.items.map((itemId) => (
226
+ <TreeBrowserNode key={itemId} {...props} id={itemId} type="item" />
227
+ ))}
228
+ </ul>
229
+ )}
230
+ </li>
231
+ )
232
+ }
233
+
234
+ export function TreeBrowser({
235
+ collections,
236
+ items = {},
237
+ rootId,
238
+ defaultExpanded = [],
239
+ expanded,
240
+ onToggle,
241
+ selected,
242
+ onSelect,
243
+ size: _size = 'medium',
244
+ variant = 'folderTree',
245
+ label,
246
+ renderIcon,
247
+ renderBadge,
248
+ className = '',
249
+ ...rest
250
+ }: TreeBrowserProps) {
251
+ const isControlled = expanded !== undefined
252
+ const [internalExpanded, setInternalExpanded] = useState<Set<string>>(
253
+ () => new Set(defaultExpanded),
254
+ )
255
+ const expandedSet = isControlled ? new Set(expanded) : internalExpanded
256
+ // Selection: controlled via `selected` when provided, otherwise tracked
257
+ // internally so a click marks the row (parity with the Svelte bindable).
258
+ const [internalSelected, setInternalSelected] = useState<string | undefined>(selected)
259
+ const effectiveSelected = selected !== undefined ? selected : internalSelected
260
+ const [focusedId, setFocusedId] = useState<string>(
261
+ () => flattenVisible(collections, rootId, new Set(defaultExpanded))[0]?.id ?? '',
262
+ )
263
+ const rootRef = useRef<HTMLUListElement>(null)
264
+
265
+ const toggle = useCallback(
266
+ (id: string) => {
267
+ if (isControlled) {
268
+ onToggle?.(id)
269
+ return
270
+ }
271
+ setInternalExpanded((prev) => {
272
+ const next = new Set(prev)
273
+ if (next.has(id)) next.delete(id)
274
+ else next.add(id)
275
+ return next
276
+ })
277
+ },
278
+ [isControlled, onToggle],
279
+ )
280
+
281
+ function moveFocus(id: string) {
282
+ setFocusedId(id)
283
+ requestAnimationFrame(() => {
284
+ const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id
285
+ rootRef.current?.querySelector<HTMLElement>(`[data-tree-id="${escaped}"]`)?.focus()
286
+ })
287
+ }
288
+
289
+ function select(id: string, type: TreeBrowserNodeType) {
290
+ setFocusedId(id)
291
+ setInternalSelected(id)
292
+ onSelect?.(id, type)
293
+ }
294
+
295
+ function nodeType(el: Element): TreeBrowserNodeType {
296
+ return el.getAttribute('data-type') === 'collection' ? 'collection' : 'item'
297
+ }
298
+
299
+ function handleFocus(e: React.FocusEvent<HTMLUListElement>) {
300
+ const el = (e.target as HTMLElement).closest('[data-tree-id]')
301
+ if (el) setFocusedId(el.getAttribute('data-tree-id') ?? focusedId)
302
+ }
303
+
304
+ function handleClick(e: React.MouseEvent<HTMLUListElement>) {
305
+ const row = (e.target as HTMLElement).closest('.tree-browser__row')
306
+ if (!row) return
307
+ const el = row.closest('[data-tree-id]')
308
+ if (!el) return
309
+ const id = el.getAttribute('data-tree-id')
310
+ if (!id) return
311
+ const type = nodeType(el)
312
+ select(id, type)
313
+ if (type === 'collection' && hasChildren(collections, id)) toggle(id)
314
+ }
315
+
316
+ function handleKeyDown(e: KeyboardEvent<HTMLUListElement>) {
317
+ const el = (e.target as HTMLElement).closest('[data-tree-id]')
318
+ if (!el) return
319
+ const id = el.getAttribute('data-tree-id')
320
+ if (!id) return
321
+ const type = nodeType(el)
322
+
323
+ const flat = flattenVisible(collections, rootId, expandedSet)
324
+ const idx = flat.findIndex((n) => n.id === id)
325
+ const current = flat[idx]
326
+ const nodeHasChildren = !!current?.hasChildren
327
+ const isNodeExpanded = expandedSet.has(id)
328
+
329
+ switch (e.key) {
330
+ case 'ArrowDown': {
331
+ e.preventDefault()
332
+ const next = flat[idx + 1]
333
+ if (next) moveFocus(next.id)
334
+ break
335
+ }
336
+ case 'ArrowUp': {
337
+ e.preventDefault()
338
+ const prev = flat[idx - 1]
339
+ if (prev) moveFocus(prev.id)
340
+ break
341
+ }
342
+ case 'ArrowRight': {
343
+ e.preventDefault()
344
+ if (nodeHasChildren && !isNodeExpanded) toggle(id)
345
+ else if (nodeHasChildren && isNodeExpanded) {
346
+ const { collections: c, items: i } = childIds(collections, id)
347
+ const first = c[0] ?? i[0]
348
+ if (first) moveFocus(first)
349
+ }
350
+ break
351
+ }
352
+ case 'ArrowLeft': {
353
+ e.preventDefault()
354
+ if (nodeHasChildren && isNodeExpanded) toggle(id)
355
+ else if (current?.parentId && current.parentId !== rootId) moveFocus(current.parentId)
356
+ break
357
+ }
358
+ case 'Home': {
359
+ e.preventDefault()
360
+ if (flat[0]) moveFocus(flat[0].id)
361
+ break
362
+ }
363
+ case 'End': {
364
+ e.preventDefault()
365
+ const last = flat[flat.length - 1]
366
+ if (last) moveFocus(last.id)
367
+ break
368
+ }
369
+ case 'Enter':
370
+ case ' ': {
371
+ e.preventDefault()
372
+ select(id, type)
373
+ if (type === 'collection' && nodeHasChildren) toggle(id)
374
+ break
375
+ }
376
+ }
377
+ }
378
+
379
+ const rootChildren = childIds(collections, rootId)
380
+
381
+ return (
382
+ <ul
383
+ ref={rootRef}
384
+ role="tree"
385
+ aria-label={label}
386
+ data-variant={variant}
387
+ className={`tree-browser ${className}`.trim()}
388
+ onFocus={handleFocus}
389
+ onClick={handleClick}
390
+ onKeyDown={handleKeyDown}
391
+ {...rest}
392
+ >
393
+ {rootChildren.collections.map((id) => (
394
+ <TreeBrowserNode
395
+ key={id}
396
+ id={id}
397
+ type="collection"
398
+ collections={collections}
399
+ items={items}
400
+ expanded={expandedSet}
401
+ selected={effectiveSelected}
402
+ focusedId={focusedId}
403
+ variant={variant}
404
+ renderIcon={renderIcon}
405
+ renderBadge={renderBadge}
406
+ />
407
+ ))}
408
+ {rootChildren.items.map((id) => (
409
+ <TreeBrowserNode
410
+ key={id}
411
+ id={id}
412
+ type="item"
413
+ collections={collections}
414
+ items={items}
415
+ expanded={expandedSet}
416
+ selected={effectiveSelected}
417
+ focusedId={focusedId}
418
+ variant={variant}
419
+ renderIcon={renderIcon}
420
+ renderBadge={renderBadge}
421
+ />
422
+ ))}
423
+ </ul>
424
+ )
425
+ }
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export * from './components/FileUpload.js'
80
80
  export * from './components/Combobox.js'
81
81
  export * from './components/Drawer.js'
82
82
  export * from './components/TreeView.js'
83
+ export * from './components/TreeBrowser.js'
83
84
  export * from './components/DatePicker.js'
84
85
  export * from './components/CommandPalette.js'
85
86
  export * from './components/InlineEdit.js'