@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 +2 -2
- package/src/lib/components/Badge.svelte +1 -1
- package/src/lib/components/Button.svelte +1 -1
- package/src/lib/components/DatePicker.svelte +1 -1
- package/src/lib/components/DateRangePicker.svelte +1 -1
- package/src/lib/components/NotificationPanel.svelte +1 -1
- package/src/lib/components/Pagination.svelte +1 -1
- package/src/lib/components/Sidebar.svelte +1 -1
- package/src/lib/components/SkipLink.svelte +1 -1
- package/src/lib/components/SplitButton.svelte +1 -1
- package/src/lib/components/Stepper.svelte +1 -1
- package/src/lib/components/TreeBrowser.svelte +281 -0
- package/src/lib/components/TreeBrowserNode.svelte +169 -0
- package/src/lib/components/tree-browser-model.ts +111 -0
- package/src/lib/index.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@w5-ui/svelte",
|
|
3
|
-
"version": "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.
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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'
|