@w5-ui/react 0.5.0 → 0.7.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/ColumnToggle.tsx +1 -0
- package/src/components/CommandPalette.tsx +1 -0
- package/src/components/ContextMenu.tsx +1 -1
- package/src/components/DateRangePicker.tsx +16 -9
- package/src/components/ExpandableRow.tsx +1 -0
- package/src/components/NumberInput.tsx +1 -0
- package/src/components/PasswordInput.tsx +1 -0
- package/src/components/RadioGroup.tsx +2 -1
- package/src/components/TagInput.tsx +7 -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.7.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.7.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/react": "^18.3.0",
|
|
@@ -87,6 +87,7 @@ export function ColumnToggle({ columns, visible, onChange }: ColumnToggleProps)
|
|
|
87
87
|
<label className="col-toggle__label">
|
|
88
88
|
<input
|
|
89
89
|
type="checkbox"
|
|
90
|
+
aria-label={col.label}
|
|
90
91
|
className="col-toggle__check"
|
|
91
92
|
checked={visible.has(col.key)}
|
|
92
93
|
disabled={col.required}
|
|
@@ -103,7 +103,7 @@ export function ContextMenu({
|
|
|
103
103
|
>
|
|
104
104
|
{items.map((entry, i) => {
|
|
105
105
|
if ('separator' in entry) {
|
|
106
|
-
return <li key={i} role="separator" className="ctx-menu-sep" />
|
|
106
|
+
return <li key={i} role="separator" aria-hidden="true" className="ctx-menu-sep" />
|
|
107
107
|
}
|
|
108
108
|
const ai = actionItems.indexOf(entry)
|
|
109
109
|
return (
|
|
@@ -145,8 +145,23 @@ export function DateRangePicker({
|
|
|
145
145
|
setPicking(null)
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
// Escape closes the dialog and restores focus to the trigger. Handled
|
|
149
|
+
// at the document level (rather than a JSX onKeyDown on the dialog) so
|
|
150
|
+
// it works regardless of focus position inside the popover.
|
|
151
|
+
function onKey(e: KeyboardEvent) {
|
|
152
|
+
if (e.key === 'Escape') {
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
setOpen(false)
|
|
155
|
+
setPicking(null)
|
|
156
|
+
triggerRef.current?.focus()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
148
159
|
document.addEventListener('mousedown', handler)
|
|
149
|
-
|
|
160
|
+
document.addEventListener('keydown', onKey)
|
|
161
|
+
return () => {
|
|
162
|
+
document.removeEventListener('mousedown', handler)
|
|
163
|
+
document.removeEventListener('keydown', onKey)
|
|
164
|
+
}
|
|
150
165
|
}, [open])
|
|
151
166
|
|
|
152
167
|
function renderCalendar(grid: Date[], gridMonth: number, gridYear: number) {
|
|
@@ -302,14 +317,6 @@ export function DateRangePicker({
|
|
|
302
317
|
aria-modal="true"
|
|
303
318
|
aria-label="Select date range"
|
|
304
319
|
className="drp-cal"
|
|
305
|
-
onKeyDown={(e) => {
|
|
306
|
-
if (e.key === 'Escape') {
|
|
307
|
-
e.preventDefault()
|
|
308
|
-
setOpen(false)
|
|
309
|
-
setPicking(null)
|
|
310
|
-
triggerRef.current?.focus()
|
|
311
|
-
}
|
|
312
|
-
}}
|
|
313
320
|
>
|
|
314
321
|
<div className="drp-months">
|
|
315
322
|
{renderCalendar(leftGrid, viewMonth, viewYear)}
|
|
@@ -49,6 +49,7 @@ export function RadioGroup({
|
|
|
49
49
|
<input
|
|
50
50
|
type="radio"
|
|
51
51
|
id={optId}
|
|
52
|
+
aria-labelledby={`${optId}-label`}
|
|
52
53
|
name={groupName}
|
|
53
54
|
value={opt.value}
|
|
54
55
|
checked={value !== undefined ? value === opt.value : undefined}
|
|
@@ -57,7 +58,7 @@ export function RadioGroup({
|
|
|
57
58
|
onChange={() => onChange?.(opt.value)}
|
|
58
59
|
className="radio"
|
|
59
60
|
/>
|
|
60
|
-
<span className="radio-text">
|
|
61
|
+
<span className="radio-text" id={`${optId}-label`}>
|
|
61
62
|
<span>{opt.label}</span>
|
|
62
63
|
{opt.description && <span className="radio-description">{opt.description}</span>}
|
|
63
64
|
</span>
|
|
@@ -61,7 +61,13 @@ export function TagInput({
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
|
-
|
|
64
|
+
// Click-to-focus lives on the presentational wrapper rather than the
|
|
65
|
+
// role="group" element, so a non-interactive ARIA role never carries a
|
|
66
|
+
// mouse handler.
|
|
67
|
+
<div
|
|
68
|
+
className={`tag-input-wrapper${className ? ` ${className}` : ''}`}
|
|
69
|
+
onClick={() => inputRef.current?.focus()}
|
|
70
|
+
>
|
|
65
71
|
{label && (
|
|
66
72
|
<label htmlFor={inputId} className="tag-input__label">
|
|
67
73
|
{label}
|
|
@@ -72,7 +78,6 @@ export function TagInput({
|
|
|
72
78
|
role="group"
|
|
73
79
|
aria-label={label ?? 'Tags'}
|
|
74
80
|
className={`tag-input${disabled ? ' tag-input--disabled' : ''}`}
|
|
75
|
-
onClick={() => inputRef.current?.focus()}
|
|
76
81
|
>
|
|
77
82
|
{tags.map((t, i) => (
|
|
78
83
|
<Tag key={t} label={t} onRemove={disabled ? undefined : () => removeTag(i)} />
|
|
@@ -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'
|