@w5-ui/svelte 0.6.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.
|
|
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,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@w5-ui/assets": "^0.2.1",
|
|
48
|
-
"@w5-ui/tokens": "^0.
|
|
48
|
+
"@w5-ui/tokens": "^0.7.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"svelte": "^5.56.4",
|
|
@@ -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'
|