@w5-ui/svelte 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/svelte",
3
- "version": "0.5.0",
3
+ "version": "0.7.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,10 +45,10 @@
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.7.0"
49
49
  },
50
50
  "devDependencies": {
51
- "svelte": "^5.55.9",
51
+ "svelte": "^5.56.4",
52
52
  "typescript": "^5.5.0"
53
53
  },
54
54
  "files": [
@@ -1,5 +1,6 @@
1
1
  <script module lang="ts">
2
2
  let counter = 0;
3
+ export type PopoverWidth = 'sm' | 'md' | 'lg' | 'xl';
3
4
  </script>
4
5
 
5
6
  <script lang="ts">
@@ -13,6 +14,14 @@
13
14
  variant?: 'ghost' | 'primary';
14
15
  title?: string | undefined;
15
16
  placement?: 'bottom-start' | 'bottom-end' | 'bottom';
17
+ /**
18
+ * Panel max-width. `sm` (320px) is the default and suits menus /
19
+ * option lists. Wider sizes suit content-dense panels (e.g. a
20
+ * folder picker with a path field, breadcrumb and file rows).
21
+ * The panel still shrinks to its content and is kept on-screen by
22
+ * the viewport-clamp in `recomputePosition`.
23
+ */
24
+ width?: PopoverWidth;
16
25
  /**
17
26
  * Controlled open state. Default uncontrolled (`$bindable(false)`):
18
27
  * the trigger button toggles it and outside-clicks close it.
@@ -42,10 +51,20 @@
42
51
  title = undefined,
43
52
  placement = 'bottom-start',
44
53
  open = $bindable(false),
54
+ width = 'sm',
45
55
  trigger,
46
56
  children,
47
57
  }: Props = $props();
48
58
 
59
+ // Pre-composed width utilities so Tailwind's static scanner keeps
60
+ // each class in the build (mirrors `Modal`'s `size` scale).
61
+ const WIDTH: Record<PopoverWidth, string> = {
62
+ sm: 'max-w-[320px]',
63
+ md: 'max-w-[400px]',
64
+ lg: 'max-w-[480px]',
65
+ xl: 'max-w-[560px]',
66
+ };
67
+
49
68
  const uid = `dash-ui-pop-${++counter}`;
50
69
  const titleId = `${uid}-title`;
51
70
 
@@ -181,7 +200,7 @@
181
200
  use:portal
182
201
  role="dialog"
183
202
  aria-labelledby={title ? titleId : undefined}
184
- class="fixed z-[9100] min-w-[200px] max-w-[320px] rounded-[10px] border border-border-3 bg-bg-2 shadow-[0_8px_32px_rgba(0,0,0,0.5)] focus:outline-none"
203
+ class={`fixed z-[9100] min-w-[200px] ${WIDTH[width]} rounded-[10px] border border-border-3 bg-bg-2 shadow-[0_8px_32px_rgba(0,0,0,0.5)] focus:outline-none`}
185
204
  style={`top: ${panelTop}px; left: ${panelLeft}px`}
186
205
  tabindex={-1}
187
206
  >
@@ -144,7 +144,7 @@
144
144
  {#if siteSwitchable}
145
145
  <button
146
146
  type="button"
147
- class="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-row-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05"
147
+ class="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-row-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05 max-md:px-1.5"
148
148
  aria-label="Switch site: {siteName}"
149
149
  aria-haspopup="menu"
150
150
  title={`${siteName} — status: ${status}`}
@@ -164,12 +164,12 @@
164
164
  {:else}
165
165
  <span class={ringWrapClass}><span class={ringDotClass}></span></span>
166
166
  {/if}
167
- <span class="text-13 font-medium text-text-1" aria-hidden="true">{siteName}</span>
168
- <CaretIcon class="h-3.5 w-3.5 text-text-3" />
167
+ <span class="text-13 font-medium text-text-1 max-md:hidden" aria-hidden="true">{siteName}</span>
168
+ <CaretIcon class="h-3.5 w-3.5 text-text-3 max-md:hidden" />
169
169
  </button>
170
170
  {:else}
171
171
  <div
172
- class="flex items-center gap-2 px-2.5 py-1.5"
172
+ class="flex shrink-0 items-center gap-2 px-2.5 py-1.5 max-md:px-1.5"
173
173
  role="presentation"
174
174
  title={`${siteName} — status: ${status}`}
175
175
  >
@@ -188,11 +188,21 @@
188
188
  {:else}
189
189
  <span class={ringWrapClass}><span class={ringDotClass}></span></span>
190
190
  {/if}
191
- <span class="text-13 font-medium text-text-1">{siteName}</span>
191
+ <span class="text-13 font-medium text-text-1 max-md:hidden">{siteName}</span>
192
192
  </div>
193
193
  {/if}
194
194
 
195
- <nav class="ml-3.5 flex h-12 items-center gap-0.5" aria-label="Apps">
195
+ <!--
196
+ Apps nav. On phones (< md) the fixed-width app buttons can exceed
197
+ the viewport, so the strip becomes a horizontally swipeable rail
198
+ (`flex-1 min-w-0 overflow-x-auto`, scrollbar hidden) instead of
199
+ overflowing the header. Desktop keeps its natural auto width with
200
+ the flex spacer pushing the actions to the right edge.
201
+ -->
202
+ <nav
203
+ class="ml-3.5 flex h-12 items-center gap-0.5 max-md:ml-1 max-md:min-w-0 max-md:flex-1 max-md:overflow-x-auto max-md:[scrollbar-width:none] max-md:[&::-webkit-scrollbar]:hidden"
204
+ aria-label="Apps"
205
+ >
196
206
  {#each apps as a (a.id)}
197
207
  <button
198
208
  type="button"
@@ -213,14 +223,14 @@
213
223
  ? 'opacity-100 dark:brightness-[1.6]'
214
224
  : 'opacity-65 hover:opacity-90 dark:hover:brightness-[1.4]'}"
215
225
  />
216
- <span class="mt-0.5 text-[10px] font-medium leading-none">{a.label}</span>
226
+ <span class="mt-0.5 text-[10px] font-medium leading-none max-md:hidden">{a.label}</span>
217
227
  </button>
218
228
  {/each}
219
229
  </nav>
220
230
 
221
- <div class="flex-1"></div>
231
+ <div class="flex-1 max-md:hidden"></div>
222
232
 
223
- <div class="flex items-center gap-1 pr-2">
233
+ <div class="flex shrink-0 items-center gap-1 pr-2">
224
234
  {#if actions}
225
235
  {@render actions()}
226
236
  {:else}
@@ -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
@@ -68,12 +68,23 @@ export type { StepperStep } from './components/Stepper.svelte'
68
68
  export { default as SegmentedControl } from './components/SegmentedControl.svelte'
69
69
  export type { SegmentOption } from './components/SegmentedControl.svelte'
70
70
  export { default as Popover } from './components/Popover.svelte'
71
+ export type { PopoverWidth } from './components/Popover.svelte'
71
72
  export { default as FileUpload } from './components/FileUpload.svelte'
72
73
  export { default as Combobox } from './components/Combobox.svelte'
73
74
  export type { ComboboxOption } from './components/Combobox.svelte'
74
75
  export { default as Drawer } from './components/Drawer.svelte'
75
76
  export { default as TreeView } from './components/TreeView.svelte'
76
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'
77
88
  export { default as DatePicker } from './components/DatePicker.svelte'
78
89
  export { default as CommandPalette } from './components/CommandPalette.svelte'
79
90
  export type { CommandItem } from './components/CommandPalette.svelte'