@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@w5-ui/react",
3
- "version": "0.5.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.5.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}
@@ -110,6 +110,7 @@ export function CommandPalette({
110
110
  className="cp-input"
111
111
  type="text"
112
112
  role="combobox"
113
+ aria-label={placeholder}
113
114
  aria-expanded={filtered.length > 0}
114
115
  aria-autocomplete="list"
115
116
  aria-controls={listId}
@@ -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
- return () => document.removeEventListener('mousedown', handler)
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)}
@@ -22,6 +22,7 @@ export function ExpandableRow({
22
22
  <button
23
23
  type="button"
24
24
  className="exp-toggle"
25
+ aria-label={open ? 'Collapse row' : 'Expand row'}
25
26
  aria-expanded={open}
26
27
  aria-controls={detailId}
27
28
  onClick={() => setOpen((o) => !o)}
@@ -98,6 +98,7 @@ export function NumberInput({
98
98
  id={inputId}
99
99
  type="number"
100
100
  role="spinbutton"
101
+ aria-label={label}
101
102
  aria-valuenow={value}
102
103
  aria-valuemin={min}
103
104
  aria-valuemax={max}
@@ -89,6 +89,7 @@ export function PasswordInput({
89
89
  <input
90
90
  id={inputId}
91
91
  type={shown ? 'text' : 'password'}
92
+ aria-label={label}
92
93
  className="pwd-input__field"
93
94
  value={value}
94
95
  placeholder={placeholder}
@@ -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
- <div className={`tag-input-wrapper${className ? ` ${className}` : ''}`}>
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'