@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 +3 -3
- package/src/lib/components/Popover.svelte +20 -1
- package/src/lib/components/Topbar.svelte +19 -9
- 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 +11 -0
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,10 +45,10 @@
|
|
|
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
|
-
"svelte": "^5.
|
|
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=
|
|
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
|
-
|
|
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'
|