@w5-ui/svelte 0.6.0 → 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/svelte",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Svelte 5 components for the Dash UI design system - mirrors the @w5-ui/react API 1:1. Part of Webber's design systems.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@w5-ui/assets": "^0.2.1",
48
- "@w5-ui/tokens": "^0.5.0"
48
+ "@w5-ui/tokens": "^0.8.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "svelte": "^5.56.4",
@@ -33,7 +33,7 @@
33
33
  // Status-warning is yellow in both motifs; near-black text stays
34
34
  // legible against it. `text-neutral-10` resolves to `#0a0a0b`.
35
35
  warn: 'bg-status-warning text-neutral-10',
36
- info: 'bg-brand-05 text-white',
36
+ info: 'bg-primary-fill text-primary-fill-fg',
37
37
  success: 'bg-status-success text-white',
38
38
  neutral: 'bg-status-neutral text-white',
39
39
  };
@@ -47,7 +47,7 @@
47
47
  // ghost: transparent surface with motif-aware text/border + hover.
48
48
  // danger: semantic status-danger text + faded border.
49
49
  const VARIANT: Record<NonNullable<Props['variant']>, string> = {
50
- primary: 'bg-brand-05 text-white hover:bg-brand-06 border-transparent',
50
+ primary: 'bg-primary-fill text-primary-fill-fg hover:bg-primary-fill-hover active:bg-primary-fill-press border-transparent',
51
51
  ghost:
52
52
  'bg-transparent text-text-2 border-border-2 hover:bg-row-hover hover:text-text-1',
53
53
  danger: 'bg-transparent text-status-danger border-status-danger/30',
@@ -135,7 +135,7 @@
135
135
  if (outside) cls += ' text-text-4';
136
136
  else cls += ' text-text-2';
137
137
  if (isToday && !isSelected) cls += ' ring-1 ring-inset ring-brand-05';
138
- if (isSelected) cls += ' bg-brand-05 text-white hover:bg-brand-06';
138
+ if (isSelected) cls += ' bg-primary-fill text-primary-fill-fg hover:bg-primary-fill-hover';
139
139
  return cls;
140
140
  }
141
141
  </script>
@@ -137,7 +137,7 @@
137
137
  if (outside) cls += ' text-text-4';
138
138
  else cls += ' text-text-2';
139
139
  if (isTodayDay && !start && !end) cls += ' ring-1 ring-inset ring-brand-05';
140
- if (start || end) cls += ' bg-brand-05 text-white hover:bg-brand-06';
140
+ if (start || end) cls += ' bg-primary-fill text-primary-fill-fg hover:bg-primary-fill-hover';
141
141
  else if (inRange) cls += ' bg-brand-05/20 text-text-1';
142
142
  return cls;
143
143
  }
@@ -114,7 +114,7 @@
114
114
  <h2 id={titleId} class="m-0 flex items-center gap-2 text-15 font-semibold text-text-1">
115
115
  Notifications
116
116
  {#if unread > 0}
117
- <span class="inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-brand-05 px-1.5 text-11 font-bold text-white tabular-nums" aria-label={`${unread} unread`}>{unread}</span>
117
+ <span class="inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary-fill px-1.5 text-11 font-bold text-primary-fill-fg tabular-nums" aria-label={`${unread} unread`}>{unread}</span>
118
118
  {/if}
119
119
  </h2>
120
120
  <div class="flex items-center gap-1.5">
@@ -29,7 +29,7 @@
29
29
 
30
30
  const BTN_BASE =
31
31
  'inline-flex h-[30px] min-w-[30px] cursor-pointer items-center justify-center rounded-md border border-transparent bg-transparent px-1.5 text-13 font-medium leading-none text-text-3 transition-[background-color,color,border-color] duration-100 hover:bg-row-hover hover:text-text-1 hover:border-border-2 disabled:cursor-not-allowed disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05';
32
- const BTN_ACTIVE = 'bg-brand-05 text-white border-brand-05 hover:bg-brand-06 hover:border-brand-06';
32
+ const BTN_ACTIVE = 'bg-primary-fill text-primary-fill-fg border-primary-fill hover:bg-primary-fill-hover hover:border-primary-fill-hover';
33
33
  </script>
34
34
 
35
35
  {#if totalPages > 1}
@@ -60,7 +60,7 @@
60
60
  {/if}
61
61
  {#if it.pill !== undefined}
62
62
  <span
63
- class="ml-auto rounded-full bg-brand-05 px-1.5 py-px text-[10px] font-semibold text-white"
63
+ class="ml-auto rounded-full bg-primary-fill px-1.5 py-px text-[10px] font-semibold text-primary-fill-fg"
64
64
  >{it.pill}<span class="sr-only"> alert{it.pill !== 1 ? 's' : ''}</span></span>
65
65
  {/if}
66
66
  </button>
@@ -9,7 +9,7 @@
9
9
  </script>
10
10
 
11
11
  <a
12
- class="absolute left-0 top-0 z-[100] -translate-y-full rounded-br-md bg-brand-05 px-3 py-2 text-13 font-semibold text-white no-underline focus:translate-y-0 focus:outline-2 focus:-outline-offset-4 focus:outline-white
12
+ class="absolute left-0 top-0 z-[100] -translate-y-full rounded-br-md bg-primary-fill px-3 py-2 text-13 font-semibold text-primary-fill-fg no-underline focus:translate-y-0 focus:outline-2 focus:-outline-offset-4 focus:outline-white
13
13
  {className}"
14
14
  {href}
15
15
  >
@@ -77,7 +77,7 @@
77
77
 
78
78
  // Pre-composed variant chrome so Tailwind's static scanner sees the literals.
79
79
  const VARIANT: Record<NonNullable<Props['variant']>, string> = {
80
- primary: 'bg-brand-05 text-white hover:bg-brand-06 border-brand-05',
80
+ primary: 'bg-primary-fill text-primary-fill-fg hover:bg-primary-fill-hover active:bg-primary-fill-press border-primary-fill',
81
81
  ghost: 'bg-transparent text-text-2 border-border-2 hover:bg-row-hover hover:text-text-1',
82
82
  danger: 'bg-transparent text-status-danger border-status-danger/30',
83
83
  };
@@ -20,7 +20,7 @@
20
20
  // - label: colour + weight
21
21
  // - connector line (::before via Tailwind arbitrary): bg colour
22
22
  const NUM: Record<'active' | 'done' | 'upcoming', string> = {
23
- active: 'border-brand-05 bg-brand-05 text-white',
23
+ active: 'border-primary-fill bg-primary-fill text-primary-fill-fg',
24
24
  done: 'border-brand-05 bg-brand-05/[0.18] text-status-info',
25
25
  upcoming: 'border-bg-3 bg-bg-1 text-text-4',
26
26
  };
@@ -0,0 +1,281 @@
1
+ <script module lang="ts">
2
+ export type {
3
+ TreeBrowserCollection,
4
+ TreeBrowserItem,
5
+ TreeBrowserData,
6
+ TreeBrowserNodeType,
7
+ FlatNode,
8
+ } from './tree-browser-model.ts'
9
+ export { topLevelIds } from './tree-browser-model.ts'
10
+ export type { TreeBrowserRowContext } from './TreeBrowserNode.svelte'
11
+ </script>
12
+
13
+ <!--
14
+ TreeBrowser - a folder/file tree modelled on instructure-ui's
15
+ `ui-tree-browser` (TreeBrowser v2), medium size, `folderTree` variant.
16
+
17
+ ## Data model
18
+ instructure-ui shape: id-keyed `collections` (folders) + id-keyed
19
+ `items` (leaves) + a `rootId`. The root collection is structural (its
20
+ own row is never drawn); its `collections` then `items` become the
21
+ top-level rows. Folders always render before items within a level.
22
+
23
+ ## Visual mapping (instructure medium/folderTree -> @w5-ui/tokens)
24
+ Both motifs (dark + light) resolve through the token variables below,
25
+ so the component needs no motif branching.
26
+
27
+ | instructure-ui (medium/folderTree) | @w5-ui token / utility |
28
+ | ---------------------------------- | --------------------------------- |
29
+ | `baseSpacingMedium` row indent | nested `ul` `pl-3` (12px = space-3)|
30
+ | row min height | `min-h-8` (32px) |
31
+ | row horizontal padding | `px-2` (8px = space-2) |
32
+ | `iconsMarginRightMedium` | `gap-2` (8px = space-2) |
33
+ | name font size (medium) | `text-13` (--fs-13) |
34
+ | descriptor font size | `text-11` (--fs-11), `text-text-4`|
35
+ | folderTree `before` connector | nested `ul` `border-l border-border-2` |
36
+ | text colour (resting / selected) | `text-text-2` / `text-text-1` |
37
+ | hover state | `bg-row-hover` |
38
+ | selected state | `bg-brand-05/[0.18]` |
39
+ | focus ring | `--focus-ring` (`:focus-visible`) |
40
+
41
+ ## Expansion
42
+ Uncontrolled via `defaultExpanded`, or controlled via `expanded` +
43
+ `ontoggle`. `topLevelIds(collections, rootId)` (exported) lets a
44
+ consumer expand only the top level in one line.
45
+
46
+ ## Accessibility
47
+ `role="tree"` on the list, `role="treeitem"` / `role="group"` on rows
48
+ and nested lists, `aria-expanded` / `aria-selected`, roving tabindex,
49
+ and full Arrow/Home/End/Enter/Space keyboard navigation. Fixed row
50
+ heights keep expansion CLS-free; the caret rotation respects
51
+ `prefers-reduced-motion`.
52
+ -->
53
+ <script lang="ts">
54
+ import { tick, type Snippet } from 'svelte'
55
+ import TreeBrowserNode, { type TreeBrowserRowContext } from './TreeBrowserNode.svelte'
56
+ import {
57
+ childIds,
58
+ flattenVisible,
59
+ hasChildren,
60
+ type TreeBrowserCollection,
61
+ type TreeBrowserItem,
62
+ type TreeBrowserNodeType,
63
+ } from './tree-browser-model.ts'
64
+
65
+ interface Props {
66
+ collections?: Record<string, TreeBrowserCollection>;
67
+ items?: Record<string, TreeBrowserItem>;
68
+ rootId?: string;
69
+ /** Uncontrolled initial expansion. Ignored when `expanded` is set. */
70
+ defaultExpanded?: string[];
71
+ /** Controlled expansion set. When provided, pair with `ontoggle`. */
72
+ expanded?: string[] | undefined;
73
+ ontoggle?: ((id: string) => void) | undefined;
74
+ selected?: string | undefined;
75
+ onselect?: ((id: string, type: TreeBrowserNodeType) => void) | undefined;
76
+ /** Reserved for future small/large sizes. Only medium is implemented. */
77
+ size?: 'medium';
78
+ variant?: 'folderTree' | 'indent';
79
+ label?: string | undefined;
80
+ /** Per-row leading icon that overrides the built-in folder/file glyphs. */
81
+ itemIcon?: Snippet<[TreeBrowserRowContext]>;
82
+ /** Trailing badge (missing-file marker, count, etc.). */
83
+ badge?: Snippet<[TreeBrowserRowContext]>;
84
+ className?: string;
85
+ }
86
+
87
+ let {
88
+ collections = {},
89
+ items = {},
90
+ rootId = 'root',
91
+ defaultExpanded = [],
92
+ expanded = undefined,
93
+ ontoggle = undefined,
94
+ selected = $bindable(undefined),
95
+ onselect = undefined,
96
+ size: _size = 'medium',
97
+ variant = 'folderTree',
98
+ label = undefined,
99
+ itemIcon = undefined,
100
+ badge = undefined,
101
+ className = '',
102
+ }: Props = $props()
103
+
104
+ const isControlled = $derived(expanded !== undefined)
105
+
106
+ // svelte-ignore state_referenced_locally
107
+ let internalExpanded = $state(new Set<string>(defaultExpanded))
108
+ let expandedSet = $derived(isControlled ? new Set(expanded) : internalExpanded)
109
+
110
+ let data = $derived({ collections, items, rootId })
111
+ let rootChildren = $derived(childIds(collections, rootId))
112
+ let flat = $derived(flattenVisible(data, expandedSet))
113
+
114
+ // svelte-ignore state_referenced_locally
115
+ let focusedId = $state(flattenVisible({ collections, items, rootId }, new Set(defaultExpanded))[0]?.id ?? '')
116
+ let rootEl = $state<HTMLUListElement | undefined>(undefined)
117
+
118
+ function setExpanded(id: string, next: boolean) {
119
+ if (isControlled) {
120
+ ontoggle?.(id)
121
+ return
122
+ }
123
+ if (next) internalExpanded.add(id)
124
+ else internalExpanded.delete(id)
125
+ internalExpanded = new Set(internalExpanded)
126
+ }
127
+
128
+ function toggle(id: string) {
129
+ setExpanded(id, !expandedSet.has(id))
130
+ }
131
+
132
+ function select(id: string, type: TreeBrowserNodeType) {
133
+ selected = id
134
+ focusedId = id
135
+ onselect?.(id, type)
136
+ }
137
+
138
+ async function moveFocus(id: string) {
139
+ focusedId = id
140
+ await tick()
141
+ rootEl?.querySelector<HTMLElement>(`[data-tree-id="${cssEscape(id)}"]`)?.focus()
142
+ }
143
+
144
+ function cssEscape(value: string): string {
145
+ return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/"/g, '\\"')
146
+ }
147
+
148
+ function nodeType(el: Element): TreeBrowserNodeType {
149
+ return el.getAttribute('data-type') === 'collection' ? 'collection' : 'item'
150
+ }
151
+
152
+ function handleFocusin(e: FocusEvent) {
153
+ const el = (e.target as HTMLElement).closest('[data-tree-id]')
154
+ if (el) focusedId = el.getAttribute('data-tree-id') ?? focusedId
155
+ }
156
+
157
+ function handleClick(e: MouseEvent) {
158
+ const row = (e.target as HTMLElement).closest('.w5-tb__row')
159
+ if (!row) return
160
+ const el = row.closest('[data-tree-id]')
161
+ if (!el) return
162
+ const id = el.getAttribute('data-tree-id')
163
+ if (!id) return
164
+ const type = nodeType(el)
165
+ select(id, type)
166
+ if (type === 'collection' && hasChildren(collections, id)) toggle(id)
167
+ }
168
+
169
+ function handleKeydown(e: KeyboardEvent) {
170
+ const el = (e.target as HTMLElement).closest('[data-tree-id]')
171
+ if (!el) return
172
+ const id = el.getAttribute('data-tree-id')
173
+ if (!id) return
174
+ const type = nodeType(el)
175
+
176
+ const idx = flat.findIndex((n) => n.id === id)
177
+ const current = flat[idx]
178
+ const nodeHasChildren = !!current?.hasChildren
179
+ const isNodeExpanded = expandedSet.has(id)
180
+
181
+ switch (e.key) {
182
+ case 'ArrowDown': {
183
+ e.preventDefault()
184
+ const next = flat[idx + 1]
185
+ if (next) moveFocus(next.id)
186
+ break
187
+ }
188
+ case 'ArrowUp': {
189
+ e.preventDefault()
190
+ const prev = flat[idx - 1]
191
+ if (prev) moveFocus(prev.id)
192
+ break
193
+ }
194
+ case 'ArrowRight': {
195
+ e.preventDefault()
196
+ if (nodeHasChildren && !isNodeExpanded) toggle(id)
197
+ else if (nodeHasChildren && isNodeExpanded) {
198
+ const first = firstChildId(id)
199
+ if (first) moveFocus(first)
200
+ }
201
+ break
202
+ }
203
+ case 'ArrowLeft': {
204
+ e.preventDefault()
205
+ if (nodeHasChildren && isNodeExpanded) toggle(id)
206
+ else if (current?.parentId && current.parentId !== rootId) moveFocus(current.parentId)
207
+ break
208
+ }
209
+ case 'Home': {
210
+ e.preventDefault()
211
+ if (flat[0]) moveFocus(flat[0].id)
212
+ break
213
+ }
214
+ case 'End': {
215
+ e.preventDefault()
216
+ const last = flat[flat.length - 1]
217
+ if (last) moveFocus(last.id)
218
+ break
219
+ }
220
+ case 'Enter':
221
+ case ' ': {
222
+ e.preventDefault()
223
+ select(id, type)
224
+ if (type === 'collection' && nodeHasChildren) toggle(id)
225
+ break
226
+ }
227
+ }
228
+ }
229
+
230
+ function firstChildId(id: string): string | undefined {
231
+ const { collections: c, items: i } = childIds(collections, id)
232
+ return c[0] ?? i[0]
233
+ }
234
+ </script>
235
+
236
+ <ul
237
+ bind:this={rootEl}
238
+ role="tree"
239
+ aria-label={label}
240
+ data-variant={variant}
241
+ class={`w5-tb m-0 list-none p-0 text-text-2 ${className}`}
242
+ onkeydown={handleKeydown}
243
+ onfocusin={handleFocusin}
244
+ onclick={handleClick}
245
+ >
246
+ {#each rootChildren.collections as id (id)}
247
+ <TreeBrowserNode
248
+ {id}
249
+ type="collection"
250
+ {data}
251
+ expanded={expandedSet}
252
+ {selected}
253
+ {focusedId}
254
+ {variant}
255
+ {itemIcon}
256
+ {badge}
257
+ />
258
+ {/each}
259
+ {#each rootChildren.items as id (id)}
260
+ <TreeBrowserNode
261
+ {id}
262
+ type="item"
263
+ {data}
264
+ expanded={expandedSet}
265
+ {selected}
266
+ {focusedId}
267
+ {variant}
268
+ {itemIcon}
269
+ {badge}
270
+ />
271
+ {/each}
272
+ </ul>
273
+ <!--
274
+ NOTE: This component is intentionally STYLE-LESS (Tailwind utilities
275
+ only, no `<style>` block). The assistant dashboard serves
276
+ `@w5-ui/svelte` as raw source (not prebundled); a `<style>` block
277
+ there emits a `?type=style` virtual module that `@tailwindcss/vite`
278
+ mis-parses ("Invalid declaration"). The focus ring is therefore
279
+ expressed via the `group-focus-visible/tb` utilities in
280
+ TreeBrowserNode instead of a scoped `:global` rule.
281
+ -->
@@ -0,0 +1,169 @@
1
+ <script module lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import type {
4
+ TreeBrowserData,
5
+ TreeBrowserNodeType,
6
+ } from './tree-browser-model.ts'
7
+
8
+ export interface TreeBrowserRowContext {
9
+ id: string
10
+ type: TreeBrowserNodeType
11
+ name: string
12
+ }
13
+
14
+ export interface TreeBrowserNodeProps {
15
+ id: string
16
+ type: TreeBrowserNodeType
17
+ data: TreeBrowserData
18
+ expanded: Set<string>
19
+ selected: string | undefined
20
+ focusedId: string
21
+ variant: 'folderTree' | 'indent'
22
+ itemIcon?: Snippet<[TreeBrowserRowContext]>
23
+ badge?: Snippet<[TreeBrowserRowContext]>
24
+ }
25
+ </script>
26
+
27
+ <script lang="ts">
28
+ import TreeBrowserNode from './TreeBrowserNode.svelte'
29
+ import { childIds, hasChildren } from './tree-browser-model.ts'
30
+
31
+ let {
32
+ id,
33
+ type,
34
+ data,
35
+ expanded,
36
+ selected,
37
+ focusedId,
38
+ variant,
39
+ itemIcon,
40
+ badge,
41
+ }: TreeBrowserNodeProps = $props()
42
+
43
+ let node = $derived(
44
+ type === 'collection' ? data.collections[id] : data.items?.[id],
45
+ )
46
+ let name = $derived(node?.name ?? '')
47
+ let descriptor = $derived(node?.descriptor)
48
+ let isCollection = $derived(type === 'collection')
49
+ let nodeHasChildren = $derived(isCollection && hasChildren(data.collections, id))
50
+ let isExpanded = $derived(expanded.has(id))
51
+ let isSelected = $derived(selected === id)
52
+ let children = $derived(isCollection ? childIds(data.collections, id) : { collections: [], items: [] })
53
+ let rowContext = $derived({ id, type, name } as TreeBrowserRowContext)
54
+ </script>
55
+
56
+ <li
57
+ role="treeitem"
58
+ aria-expanded={nodeHasChildren ? isExpanded : undefined}
59
+ aria-selected={isSelected}
60
+ tabindex={focusedId === id ? 0 : -1}
61
+ data-tree-id={id}
62
+ data-type={type}
63
+ class="w5-tb__item group/tb list-none outline-none focus:outline-none focus-visible:outline-none"
64
+ >
65
+ <div
66
+ data-selected={isSelected ? 'true' : undefined}
67
+ class="w5-tb__row flex min-h-8 cursor-pointer select-none items-center gap-2 rounded px-2 text-13 text-text-2 hover:bg-row-hover group-focus-visible/tb:[outline:var(--focus-ring)] group-focus-visible/tb:[outline-offset:-2px] data-[selected=true]:bg-brand-05/[0.18] data-[selected=true]:text-text-1"
68
+ >
69
+ {#if nodeHasChildren}
70
+ <svg
71
+ viewBox="0 0 12 12"
72
+ fill="none"
73
+ aria-hidden="true"
74
+ class="h-3 w-3 shrink-0 text-text-4 transition-transform duration-100 motion-reduce:transition-none {isExpanded ? 'rotate-90' : ''}"
75
+ >
76
+ <path
77
+ d="M4 2l4 4-4 4"
78
+ stroke="currentColor"
79
+ stroke-width="1.5"
80
+ stroke-linecap="round"
81
+ stroke-linejoin="round"
82
+ />
83
+ </svg>
84
+ {:else}
85
+ <span class="inline-block w-3 shrink-0" aria-hidden="true"></span>
86
+ {/if}
87
+
88
+ <span class="w5-tb__icon flex h-4 w-4 shrink-0 items-center justify-center text-text-4">
89
+ {#if itemIcon}
90
+ {@render itemIcon(rowContext)}
91
+ {:else if isCollection}
92
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" class="h-4 w-4">
93
+ {#if isExpanded}
94
+ <path
95
+ 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"
96
+ stroke="currentColor"
97
+ stroke-width="1.2"
98
+ stroke-linejoin="round"
99
+ />
100
+ {:else}
101
+ <path
102
+ 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"
103
+ stroke="currentColor"
104
+ stroke-width="1.2"
105
+ stroke-linejoin="round"
106
+ />
107
+ {/if}
108
+ </svg>
109
+ {:else}
110
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" class="h-4 w-4">
111
+ <path
112
+ 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"
113
+ stroke="currentColor"
114
+ stroke-width="1.2"
115
+ stroke-linejoin="round"
116
+ />
117
+ <path d="M9 1.5v4h4" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" />
118
+ </svg>
119
+ {/if}
120
+ </span>
121
+
122
+ <span class="w5-tb__names flex min-w-0 flex-1 flex-col justify-center leading-tight">
123
+ <span class="w5-tb__name truncate">{name}</span>
124
+ {#if descriptor}
125
+ <span class="w5-tb__descriptor truncate text-11 text-text-4">{descriptor}</span>
126
+ {/if}
127
+ </span>
128
+
129
+ {#if badge}
130
+ <span class="w5-tb__badge ml-auto shrink-0">{@render badge(rowContext)}</span>
131
+ {/if}
132
+ </div>
133
+
134
+ {#if isCollection && nodeHasChildren && isExpanded}
135
+ <ul
136
+ role="group"
137
+ class="w5-tb__group m-0 list-none pl-3 {variant === 'folderTree'
138
+ ? 'ml-[11px] border-l border-border-2'
139
+ : 'ml-2'}"
140
+ >
141
+ {#each children.collections as childId (childId)}
142
+ <TreeBrowserNode
143
+ id={childId}
144
+ type="collection"
145
+ {data}
146
+ {expanded}
147
+ {selected}
148
+ {focusedId}
149
+ {variant}
150
+ {itemIcon}
151
+ {badge}
152
+ />
153
+ {/each}
154
+ {#each children.items as itemId (itemId)}
155
+ <TreeBrowserNode
156
+ id={itemId}
157
+ type="item"
158
+ {data}
159
+ {expanded}
160
+ {selected}
161
+ {focusedId}
162
+ {variant}
163
+ {itemIcon}
164
+ {badge}
165
+ />
166
+ {/each}
167
+ </ul>
168
+ {/if}
169
+ </li>
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pure data model + helpers for {@link TreeBrowser} (instructure-ui
3
+ * `ui-tree-browser` shape). Kept framework-free so the React and Web
4
+ * Component mirrors reuse the exact same walking / keyboard logic.
5
+ *
6
+ * A tree is an id-keyed map of `collections` (folders) plus an id-keyed
7
+ * map of `items` (leaves) and a `rootId`. The root collection is a
8
+ * structural container: its own row is never rendered, only its
9
+ * children become the top-level rows.
10
+ */
11
+
12
+ export interface TreeBrowserCollection {
13
+ id: string
14
+ name: string
15
+ /** Small secondary line shown under the name (instructure descriptor). */
16
+ descriptor?: string
17
+ /** Child collection ids, in render order (folders render before items). */
18
+ collections?: string[]
19
+ /** Child item ids, in render order. */
20
+ items?: string[]
21
+ }
22
+
23
+ export interface TreeBrowserItem {
24
+ id: string
25
+ name: string
26
+ descriptor?: string
27
+ }
28
+
29
+ export type TreeBrowserNodeType = 'collection' | 'item'
30
+
31
+ /** A single visible (i.e. not hidden by a collapsed ancestor) row. */
32
+ export interface FlatNode {
33
+ id: string
34
+ type: TreeBrowserNodeType
35
+ depth: number
36
+ hasChildren: boolean
37
+ parentId: string | undefined
38
+ }
39
+
40
+ export interface TreeBrowserData {
41
+ collections: Record<string, TreeBrowserCollection>
42
+ items?: Record<string, TreeBrowserItem>
43
+ rootId: string
44
+ }
45
+
46
+ /**
47
+ * The ids of the root collection's direct child collections + items -
48
+ * exactly the rows a consumer expands to satisfy "top level only".
49
+ */
50
+ export function topLevelIds(
51
+ collections: Record<string, TreeBrowserCollection>,
52
+ rootId: string,
53
+ ): string[] {
54
+ const root = collections[rootId]
55
+ if (!root) return []
56
+ return [...(root.collections ?? []), ...(root.items ?? [])]
57
+ }
58
+
59
+ /** Ordered child ids of a collection: nested folders first, then items. */
60
+ export 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
+ /** Whether a collection has any children (folders or items). */
69
+ export function hasChildren(
70
+ collections: Record<string, TreeBrowserCollection>,
71
+ id: string,
72
+ ): boolean {
73
+ const { collections: c, items } = childIds(collections, id)
74
+ return c.length > 0 || items.length > 0
75
+ }
76
+
77
+ /**
78
+ * Depth-first flatten of the currently VISIBLE rows, honouring the
79
+ * `expanded` set. Cycle-safe (a collection referenced twice, or a
80
+ * self/loop reference, is visited once). Used for roving-tabindex
81
+ * keyboard navigation.
82
+ */
83
+ export function flattenVisible(data: TreeBrowserData, expanded: ReadonlySet<string>): FlatNode[] {
84
+ const out: FlatNode[] = []
85
+ const seen = new Set<string>([data.rootId])
86
+
87
+ const walk = (collectionId: string, depth: number): void => {
88
+ const { collections, items } = childIds(data.collections, collectionId)
89
+ for (const childId of collections) {
90
+ if (seen.has(childId)) continue
91
+ seen.add(childId)
92
+ const childHasChildren = hasChildren(data.collections, childId)
93
+ out.push({
94
+ id: childId,
95
+ type: 'collection',
96
+ depth,
97
+ hasChildren: childHasChildren,
98
+ parentId: collectionId,
99
+ })
100
+ if (childHasChildren && expanded.has(childId)) walk(childId, depth + 1)
101
+ }
102
+ for (const itemId of items) {
103
+ if (seen.has(itemId)) continue
104
+ seen.add(itemId)
105
+ out.push({ id: itemId, type: 'item', depth, hasChildren: false, parentId: collectionId })
106
+ }
107
+ }
108
+
109
+ walk(data.rootId, 0)
110
+ return out
111
+ }
package/src/lib/index.ts CHANGED
@@ -75,6 +75,16 @@ export type { ComboboxOption } from './components/Combobox.svelte'
75
75
  export { default as Drawer } from './components/Drawer.svelte'
76
76
  export { default as TreeView } from './components/TreeView.svelte'
77
77
  export type { TreeNode } from './components/TreeView.svelte'
78
+ export { default as TreeBrowser, topLevelIds } from './components/TreeBrowser.svelte'
79
+ export type {
80
+ TreeBrowserCollection,
81
+ TreeBrowserItem,
82
+ TreeBrowserData,
83
+ TreeBrowserNodeType,
84
+ TreeBrowserRowContext,
85
+ FlatNode,
86
+ } from './components/TreeBrowser.svelte'
87
+ export { childIds, hasChildren, flattenVisible } from './components/tree-browser-model.ts'
78
88
  export { default as DatePicker } from './components/DatePicker.svelte'
79
89
  export { default as CommandPalette } from './components/CommandPalette.svelte'
80
90
  export type { CommandItem } from './components/CommandPalette.svelte'