@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 +2 -2
- package/src/components/TreeBrowser.tsx +425 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@w5-ui/react",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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'
|