@rokkit/ui 1.0.0-next.127 → 1.0.0-next.128
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 +6 -16
- package/src/components/BreadCrumbs.svelte +25 -17
- package/src/components/Button.svelte +11 -5
- package/src/components/Carousel.svelte +11 -6
- package/src/components/Code.svelte +6 -2
- package/src/components/FloatingAction.svelte +24 -21
- package/src/components/FloatingNavigation.svelte +36 -29
- package/src/components/Grid.svelte +128 -0
- package/src/components/ItemContent.svelte +21 -20
- package/src/components/LazyTree.svelte +165 -0
- package/src/components/List.svelte +147 -435
- package/src/components/Menu.svelte +195 -346
- package/src/components/MultiSelect.svelte +238 -390
- package/src/components/PaletteManager.svelte +15 -5
- package/src/components/Pill.svelte +19 -14
- package/src/components/Range.svelte +8 -3
- package/src/components/Rating.svelte +19 -9
- package/src/components/SearchFilter.svelte +11 -3
- package/src/components/Select.svelte +265 -454
- package/src/components/Stepper.svelte +9 -6
- package/src/components/Switch.svelte +11 -11
- package/src/components/Table.svelte +0 -1
- package/src/components/Tabs.svelte +96 -172
- package/src/components/Timeline.svelte +5 -5
- package/src/components/Toggle.svelte +55 -119
- package/src/components/Toolbar.svelte +24 -23
- package/src/components/Tree.svelte +115 -584
- package/src/components/UploadFileStatus.svelte +83 -0
- package/src/components/UploadProgress.svelte +131 -0
- package/src/components/UploadTarget.svelte +124 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +6 -1
- package/src/types/button.ts +3 -0
- package/src/types/code.ts +4 -4
- package/src/types/floating-action.ts +13 -8
- package/src/types/floating-navigation.ts +14 -2
- package/src/types/index.ts +5 -3
- package/src/types/list.ts +10 -6
- package/src/types/menu.ts +38 -138
- package/src/types/palette.ts +17 -0
- package/src/types/select.ts +33 -63
- package/src/types/switch.ts +9 -5
- package/src/types/table.ts +6 -6
- package/src/types/tabs.ts +13 -34
- package/src/types/timeline.ts +5 -3
- package/src/types/toggle.ts +15 -56
- package/src/types/toolbar.ts +1 -1
- package/src/types/tree.ts +9 -18
- package/src/types/upload-file-status.ts +45 -0
- package/src/types/upload-progress.ts +111 -0
- package/src/types/upload-target.ts +68 -0
- package/src/utils/upload.js +128 -0
- package/src/types/item-proxy.ts +0 -358
|
@@ -1,186 +1,158 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
/**
|
|
3
|
+
* MultiSelect — Trigger + dropdown with List-style flatView content.
|
|
4
|
+
*
|
|
5
|
+
* Same architecture as Select but with toggle selection (dropdown stays open),
|
|
6
|
+
* checkbox indicators, and tags display in trigger.
|
|
7
|
+
*
|
|
8
|
+
* Data attributes:
|
|
9
|
+
* data-select / data-multiselect — root container (both for theme compat)
|
|
10
|
+
* data-select-trigger — trigger button
|
|
11
|
+
* data-select-value — selected value display area
|
|
12
|
+
* data-select-tags — tags container
|
|
13
|
+
* data-select-tag — individual tag
|
|
14
|
+
* data-select-tag-text — tag text
|
|
15
|
+
* data-select-tag-remove — tag remove button
|
|
16
|
+
* data-select-count — count indicator when exceeding maxDisplay
|
|
17
|
+
* data-select-placeholder — placeholder text
|
|
18
|
+
* data-select-arrow — dropdown arrow icon
|
|
19
|
+
* data-select-dropdown — dropdown container
|
|
20
|
+
* data-select-option — option items
|
|
21
|
+
* data-select-checkbox — checkbox indicator
|
|
22
|
+
* data-select-group-label — group header (non-interactive)
|
|
23
|
+
* data-select-group-icon — icon inside group label
|
|
24
|
+
* data-select-divider — divider between groups
|
|
25
|
+
* data-path, data-selected, data-checked, data-disabled, data-open, data-size
|
|
26
|
+
*/
|
|
27
|
+
// @ts-nocheck
|
|
28
|
+
import type { ProxyItem } from '@rokkit/states'
|
|
29
|
+
import { Wrapper, ProxyTree } from '@rokkit/states'
|
|
30
|
+
import { Navigator, Trigger } from '@rokkit/actions'
|
|
31
|
+
import { DEFAULT_STATE_ICONS, resolveSnippet, ITEM_SNIPPET, GROUP_SNIPPET } from '@rokkit/core'
|
|
11
32
|
import ItemContent from './ItemContent.svelte'
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
33
|
+
|
|
34
|
+
interface MultiSelectIcons {
|
|
35
|
+
opened?: string
|
|
36
|
+
closed?: string
|
|
37
|
+
checked?: string
|
|
38
|
+
remove?: string
|
|
39
|
+
}
|
|
15
40
|
|
|
16
41
|
let {
|
|
17
|
-
|
|
18
|
-
fields
|
|
42
|
+
items = [],
|
|
43
|
+
fields = {},
|
|
19
44
|
value = $bindable<unknown[]>([]),
|
|
20
|
-
selected = $bindable<
|
|
45
|
+
selected = $bindable<unknown[]>([]),
|
|
21
46
|
placeholder = 'Select...',
|
|
22
47
|
size = 'md',
|
|
23
|
-
align = 'left',
|
|
24
|
-
direction = 'down',
|
|
25
|
-
maxRows = 5,
|
|
26
|
-
maxDisplay = 3,
|
|
27
48
|
disabled = false,
|
|
49
|
+
maxDisplay = 3,
|
|
50
|
+
align = 'start',
|
|
51
|
+
direction = 'down',
|
|
52
|
+
icons: userIcons = {} as MultiSelectIcons,
|
|
28
53
|
onchange,
|
|
29
54
|
class: className = '',
|
|
30
|
-
icons: userIcons,
|
|
31
|
-
item: itemSnippet,
|
|
32
|
-
groupLabel: groupLabelSnippet,
|
|
33
|
-
selectedValues: selectedValuesSnippet,
|
|
34
55
|
...snippets
|
|
35
|
-
}:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
}: {
|
|
57
|
+
items?: unknown[]
|
|
58
|
+
fields?: Record<string, string>
|
|
59
|
+
value?: unknown[]
|
|
60
|
+
selected?: unknown[]
|
|
61
|
+
placeholder?: string
|
|
62
|
+
size?: string
|
|
63
|
+
disabled?: boolean
|
|
64
|
+
maxDisplay?: number
|
|
65
|
+
align?: 'start' | 'end'
|
|
66
|
+
direction?: 'up' | 'down'
|
|
67
|
+
icons?: MultiSelectIcons
|
|
68
|
+
onchange?: (values: unknown[], items: unknown[]) => void
|
|
69
|
+
class?: string
|
|
70
|
+
[key: string]: unknown
|
|
71
|
+
} = $props()
|
|
72
|
+
|
|
73
|
+
const icons = $derived({ ...DEFAULT_STATE_ICONS.selector, ...DEFAULT_STATE_ICONS.checkbox, ...DEFAULT_STATE_ICONS.action, ...userIcons })
|
|
74
|
+
|
|
75
|
+
// ─── Dropdown state ───────────────────────────────────────────────────────
|
|
39
76
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const defaultRowHeight = $derived(size === 'sm' ? 28 : size === 'lg' ? 40 : 34)
|
|
45
|
-
|
|
46
|
-
// Measured row height (updated when dropdown opens)
|
|
47
|
-
let measuredRowHeight = $state<number | null>(null)
|
|
48
|
-
|
|
49
|
-
// Use measured height if available, otherwise fall back to default
|
|
50
|
-
const maxHeight = $derived(maxRows * (measuredRowHeight ?? defaultRowHeight))
|
|
77
|
+
let isOpen = $state(false)
|
|
78
|
+
let selectRef = $state<HTMLElement | null>(null)
|
|
79
|
+
let triggerRef = $state<HTMLElement | null>(null)
|
|
80
|
+
let dropdownRef = $state<HTMLElement | null>(null)
|
|
51
81
|
|
|
52
|
-
|
|
53
|
-
* Create an ItemProxy for the given item
|
|
54
|
-
*/
|
|
55
|
-
function createProxy(item: SelectItem): ItemProxy {
|
|
56
|
-
return new ItemProxy(item, userFields)
|
|
57
|
-
}
|
|
82
|
+
// ─── Pre-process items ────────────────────────────────────────────────────
|
|
58
83
|
|
|
59
|
-
|
|
84
|
+
const childrenField = $derived(fields?.children || 'children')
|
|
60
85
|
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
for (const child of proxy.children) {
|
|
68
|
-
items.push(child as SelectItem)
|
|
69
|
-
}
|
|
70
|
-
} else {
|
|
71
|
-
items.push(option)
|
|
86
|
+
// Force groups expanded + disabled (non-navigable labels)
|
|
87
|
+
const processedItems = $derived(
|
|
88
|
+
items.map((item) => {
|
|
89
|
+
const children = item[childrenField]
|
|
90
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
91
|
+
return { ...item, expanded: true, disabled: true }
|
|
72
92
|
}
|
|
73
|
-
|
|
74
|
-
return items
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
/** Map from raw item → flat index key (for data-path). Uses Map to support primitives. */
|
|
78
|
-
const itemPathMap = $derived.by(() => {
|
|
79
|
-
const map = new Map<unknown, string>()
|
|
80
|
-
flatItems.forEach((item, index) => {
|
|
81
|
-
map.set(item, String(index))
|
|
93
|
+
return item
|
|
82
94
|
})
|
|
83
|
-
|
|
84
|
-
})
|
|
95
|
+
)
|
|
85
96
|
|
|
86
|
-
// ───
|
|
97
|
+
// ─── Wrapper ──────────────────────────────────────────────────────────────
|
|
87
98
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
function handleSelect(extractedValue: unknown, proxy: ProxyItem) {
|
|
100
|
+
if (proxy.disabled) return
|
|
101
|
+
toggleItemSelection(extractedValue)
|
|
102
|
+
}
|
|
91
103
|
|
|
92
|
-
|
|
104
|
+
const proxyTree = $derived(new ProxyTree(processedItems, fields))
|
|
105
|
+
const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect }))
|
|
93
106
|
|
|
107
|
+
// Override cancel/blur to close dropdown
|
|
94
108
|
$effect(() => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const proxy = createProxy(item)
|
|
104
|
-
const extracted = proxy.itemValue
|
|
105
|
-
return value.some((v) => v === extracted)
|
|
106
|
-
})
|
|
107
|
-
.map((item) => ({ proxy: createProxy(item), original: item }))
|
|
109
|
+
const w = wrapper
|
|
110
|
+
w.cancel = () => {
|
|
111
|
+
isOpen = false
|
|
112
|
+
triggerRef?.focus()
|
|
113
|
+
}
|
|
114
|
+
w.blur = () => {
|
|
115
|
+
isOpen = false
|
|
116
|
+
}
|
|
108
117
|
})
|
|
109
118
|
|
|
110
|
-
//
|
|
119
|
+
// When wrapper recreates while open, focus first item
|
|
111
120
|
$effect(() => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
function onAction(event: Event) {
|
|
116
|
-
const detail = (event as CustomEvent).detail
|
|
117
|
-
|
|
118
|
-
if (detail.name === 'move') {
|
|
119
|
-
const key = controller.focusedKey
|
|
120
|
-
if (key) {
|
|
121
|
-
const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
|
|
122
|
-
if (target && target !== document.activeElement) {
|
|
123
|
-
target.focus()
|
|
124
|
-
target.scrollIntoView?.({ block: 'nearest' })
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (detail.name === 'select') {
|
|
130
|
-
handleSelectAction()
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
el.addEventListener('action', onAction)
|
|
135
|
-
return () => el.removeEventListener('action', onAction)
|
|
121
|
+
const _w = wrapper
|
|
122
|
+
if (isOpen) _w.first(null)
|
|
136
123
|
})
|
|
137
124
|
|
|
138
|
-
|
|
139
|
-
* Handle the navigator's select action — toggle selection (don't close)
|
|
140
|
-
*/
|
|
141
|
-
function handleSelectAction() {
|
|
142
|
-
const key = controller.focusedKey
|
|
143
|
-
if (!key) return
|
|
144
|
-
|
|
145
|
-
const proxy = controller.lookup.get(key)
|
|
146
|
-
if (!proxy) return
|
|
125
|
+
// ─── Selection logic ──────────────────────────────────────────────────────
|
|
147
126
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Sync DOM focus to controller state
|
|
154
|
-
*/
|
|
155
|
-
function handleFocusIn(event: FocusEvent) {
|
|
156
|
-
const target = event.target as HTMLElement
|
|
157
|
-
if (!target) return
|
|
158
|
-
const path = target.dataset.path
|
|
159
|
-
if (path !== undefined) {
|
|
160
|
-
controller.moveTo(path)
|
|
161
|
-
}
|
|
127
|
+
function isItemSelected(extractedValue: unknown): boolean {
|
|
128
|
+
return (value ?? []).some((v) => v === extractedValue)
|
|
162
129
|
}
|
|
163
130
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const proxy = createProxy(item)
|
|
168
|
-
if (proxy.disabled) return
|
|
169
|
-
|
|
170
|
-
const extracted = proxy.itemValue
|
|
171
|
-
const isAlreadySelected = (value ?? []).some((v) => v === extracted)
|
|
131
|
+
function toggleItemSelection(extractedValue: unknown) {
|
|
132
|
+
const currentValues = value ?? []
|
|
133
|
+
const alreadySelected = currentValues.some((v) => v === extractedValue)
|
|
172
134
|
|
|
173
135
|
let newValues: unknown[]
|
|
174
|
-
let newItems:
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
newValues =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
136
|
+
let newItems: unknown[]
|
|
137
|
+
|
|
138
|
+
if (alreadySelected) {
|
|
139
|
+
newValues = currentValues.filter((v) => v !== extractedValue)
|
|
140
|
+
// Rebuild selected items from remaining values
|
|
141
|
+
newItems = []
|
|
142
|
+
for (const [, proxy] of wrapper.lookup) {
|
|
143
|
+
if (!proxy.hasChildren && newValues.some((v) => v === proxy.value)) {
|
|
144
|
+
newItems.push(proxy.original)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
181
147
|
} else {
|
|
182
|
-
newValues = [...
|
|
183
|
-
|
|
148
|
+
newValues = [...currentValues, extractedValue]
|
|
149
|
+
// Rebuild selected items from lookup to include all values
|
|
150
|
+
newItems = []
|
|
151
|
+
for (const [, proxy] of wrapper.lookup) {
|
|
152
|
+
if (!proxy.hasChildren && newValues.some((v) => v === proxy.value)) {
|
|
153
|
+
newItems.push(proxy.original)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
184
156
|
}
|
|
185
157
|
|
|
186
158
|
value = newValues
|
|
@@ -188,234 +160,96 @@
|
|
|
188
160
|
onchange?.(newValues, newItems)
|
|
189
161
|
}
|
|
190
162
|
|
|
191
|
-
function
|
|
192
|
-
const
|
|
193
|
-
const newValues =
|
|
194
|
-
const newItems =
|
|
195
|
-
|
|
196
|
-
.
|
|
197
|
-
|
|
163
|
+
function removeTag(extractedValue: unknown) {
|
|
164
|
+
const currentValues = value ?? []
|
|
165
|
+
const newValues = currentValues.filter((v) => v !== extractedValue)
|
|
166
|
+
const newItems: unknown[] = []
|
|
167
|
+
for (const [, proxy] of wrapper.lookup) {
|
|
168
|
+
if (!proxy.hasChildren && newValues.some((v) => v === proxy.value)) {
|
|
169
|
+
newItems.push(proxy.original)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
198
172
|
value = newValues
|
|
199
173
|
selected = newItems
|
|
200
174
|
onchange?.(newValues, newItems)
|
|
201
175
|
}
|
|
202
176
|
|
|
203
|
-
// ───
|
|
204
|
-
|
|
205
|
-
function toggleDropdown() {
|
|
206
|
-
if (disabled) return
|
|
207
|
-
if (isOpen) {
|
|
208
|
-
closeDropdown()
|
|
209
|
-
} else {
|
|
210
|
-
openDropdown()
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function openDropdown() {
|
|
215
|
-
if (disabled || isOpen) return
|
|
216
|
-
isOpen = true
|
|
217
|
-
controller.moveFirst()
|
|
218
|
-
requestAnimationFrame(() => {
|
|
219
|
-
measureRowHeight()
|
|
220
|
-
focusCurrentItem()
|
|
221
|
-
})
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function closeDropdown() {
|
|
225
|
-
isOpen = false
|
|
226
|
-
}
|
|
177
|
+
// ─── Selected items for tags display ──────────────────────────────────────
|
|
227
178
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
target.scrollIntoView?.({ block: 'nearest' })
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function measureRowHeight() {
|
|
240
|
-
const dropdown = selectRef?.querySelector('[data-select-dropdown]')
|
|
241
|
-
if (dropdown) {
|
|
242
|
-
const firstOption = dropdown.querySelector('[data-select-option]')
|
|
243
|
-
if (firstOption) {
|
|
244
|
-
const height = firstOption.getBoundingClientRect().height
|
|
245
|
-
if (height > 0) {
|
|
246
|
-
measuredRowHeight = height
|
|
247
|
-
}
|
|
179
|
+
const selectedProxies = $derived.by(() => {
|
|
180
|
+
const vals = value ?? []
|
|
181
|
+
if (vals.length === 0) return []
|
|
182
|
+
const result: ProxyItem[] = []
|
|
183
|
+
for (const [, proxy] of wrapper.lookup) {
|
|
184
|
+
if (!proxy.hasChildren && vals.some((v) => v === proxy.value)) {
|
|
185
|
+
result.push(proxy)
|
|
248
186
|
}
|
|
249
187
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// ─── Trigger keyboard handling ─────────────────────────────────
|
|
253
|
-
|
|
254
|
-
function handleTriggerKeyDown(event: KeyboardEvent) {
|
|
255
|
-
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
256
|
-
event.preventDefault()
|
|
257
|
-
openDropdown()
|
|
258
|
-
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
259
|
-
event.preventDefault()
|
|
260
|
-
toggleDropdown()
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ─── Escape + click-outside ────────────────────────────────────
|
|
265
|
-
|
|
266
|
-
function handleEscapeKey(event: KeyboardEvent) {
|
|
267
|
-
if (!isOpen) return
|
|
268
|
-
if (event.key === 'Escape') {
|
|
269
|
-
event.preventDefault()
|
|
270
|
-
closeDropdown()
|
|
271
|
-
const trigger = selectRef?.querySelector('[data-select-trigger]') as HTMLElement | undefined
|
|
272
|
-
trigger?.focus()
|
|
273
|
-
}
|
|
274
|
-
}
|
|
188
|
+
return result
|
|
189
|
+
})
|
|
275
190
|
|
|
276
|
-
|
|
277
|
-
if (selectRef && !selectRef.contains(event.target as Node)) {
|
|
278
|
-
closeDropdown()
|
|
279
|
-
}
|
|
280
|
-
}
|
|
191
|
+
// ─── Trigger action ───────────────────────────────────────────────────────
|
|
281
192
|
|
|
282
193
|
$effect(() => {
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
194
|
+
if (!triggerRef || !selectRef || disabled) return
|
|
195
|
+
const t = new Trigger(triggerRef, selectRef, {
|
|
196
|
+
isOpen: () => isOpen,
|
|
197
|
+
onopen: () => {
|
|
198
|
+
isOpen = true
|
|
199
|
+
requestAnimationFrame(() => wrapper.first(null))
|
|
200
|
+
},
|
|
201
|
+
onclose: () => { isOpen = false },
|
|
202
|
+
onlast: () => requestAnimationFrame(() => wrapper.last(null))
|
|
203
|
+
})
|
|
204
|
+
return () => t.destroy()
|
|
291
205
|
})
|
|
292
206
|
|
|
293
|
-
// ───
|
|
207
|
+
// ─── Navigator on dropdown ────────────────────────────────────────────────
|
|
294
208
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
209
|
+
$effect(() => {
|
|
210
|
+
if (!isOpen || !dropdownRef) return
|
|
211
|
+
const dir = getComputedStyle(dropdownRef).direction || 'ltr'
|
|
212
|
+
const nav = new Navigator(dropdownRef, wrapper, { dir })
|
|
213
|
+
return () => nav.destroy()
|
|
214
|
+
})
|
|
302
215
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
event.stopPropagation()
|
|
313
|
-
handleItemToggle(item)
|
|
314
|
-
}
|
|
216
|
+
// DOM focus sync
|
|
217
|
+
$effect(() => {
|
|
218
|
+
const key = wrapper.focusedKey
|
|
219
|
+
if (!isOpen || !dropdownRef || !key) return
|
|
220
|
+
requestAnimationFrame(() => {
|
|
221
|
+
const target = dropdownRef?.querySelector(`[data-path="${key}"]`) as HTMLElement | null
|
|
222
|
+
if (target && target !== document.activeElement) {
|
|
223
|
+
target.focus()
|
|
224
|
+
target.scrollIntoView?.({ block: 'nearest' })
|
|
315
225
|
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
226
|
+
})
|
|
227
|
+
})
|
|
318
228
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
229
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
const groupDividers = $derived.by(() => {
|
|
232
|
+
const set = new Set<string>()
|
|
233
|
+
let foundFirst = false
|
|
234
|
+
for (const node of wrapper.flatView) {
|
|
235
|
+
if (node.hasChildren) {
|
|
236
|
+
if (foundFirst) set.add(node.key)
|
|
237
|
+
foundFirst = true
|
|
328
238
|
}
|
|
329
239
|
}
|
|
330
|
-
return
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Check if an item is currently selected
|
|
335
|
-
*/
|
|
336
|
-
function isSelected(proxy: ItemProxy): boolean {
|
|
337
|
-
const extracted = proxy.itemValue
|
|
338
|
-
return (value ?? []).some((v) => v === extracted)
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function shouldShowDivider(optionIndex: number, isGroup: boolean): boolean {
|
|
342
|
-
return isGroup && optionIndex > 0
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Get the data-path key for a raw item
|
|
347
|
-
*/
|
|
348
|
-
function getPathKey(item: SelectItem): string | undefined {
|
|
349
|
-
return itemPathMap.get(item)
|
|
350
|
-
}
|
|
240
|
+
return set
|
|
241
|
+
})
|
|
351
242
|
</script>
|
|
352
243
|
|
|
353
|
-
{#snippet
|
|
354
|
-
<
|
|
355
|
-
type="button"
|
|
356
|
-
data-select-option
|
|
357
|
-
data-path={pathKey}
|
|
358
|
-
data-disabled={proxy.disabled || undefined}
|
|
359
|
-
data-selected={isItemSelected || undefined}
|
|
360
|
-
role="option"
|
|
361
|
-
aria-selected={isItemSelected}
|
|
362
|
-
disabled={proxy.disabled}
|
|
363
|
-
aria-label={proxy.label}
|
|
364
|
-
onkeydown={handlers.onkeydown}
|
|
365
|
-
>
|
|
366
|
-
<span data-select-checkbox data-checked={isItemSelected || undefined}>
|
|
367
|
-
{#if isItemSelected}
|
|
368
|
-
<span class={icons.checked} aria-hidden="true"></span>
|
|
369
|
-
{/if}
|
|
370
|
-
</span>
|
|
371
|
-
<ItemContent {proxy} />
|
|
372
|
-
</button>
|
|
373
|
-
{/snippet}
|
|
374
|
-
|
|
375
|
-
{#snippet defaultGroupLabel(proxy: ItemProxy)}
|
|
376
|
-
<div data-select-group-label role="presentation">
|
|
377
|
-
{#if proxy.icon}
|
|
378
|
-
<span data-select-group-icon class={proxy.icon} aria-hidden="true"></span>
|
|
379
|
-
{/if}
|
|
380
|
-
<span>{proxy.text}</span>
|
|
381
|
-
</div>
|
|
382
|
-
{/snippet}
|
|
383
|
-
|
|
384
|
-
{#snippet renderOption(item: SelectItem, proxy: ItemProxy, pathKey: string | undefined)}
|
|
385
|
-
{@const customSnippet = resolveItemSnippet(proxy)}
|
|
386
|
-
{@const handlers = createHandlers(item)}
|
|
387
|
-
{@const isItemSelected = isSelected(proxy)}
|
|
388
|
-
{#if customSnippet}
|
|
389
|
-
<div
|
|
390
|
-
data-select-option
|
|
391
|
-
data-select-option-custom
|
|
392
|
-
data-path={pathKey}
|
|
393
|
-
data-disabled={proxy.disabled || undefined}
|
|
394
|
-
data-selected={isItemSelected || undefined}
|
|
395
|
-
>
|
|
396
|
-
<svelte:boundary>
|
|
397
|
-
{@render customSnippet(item, proxy.fields, handlers, isItemSelected)}
|
|
398
|
-
{#snippet failed()}
|
|
399
|
-
{@render defaultOption(proxy, handlers, isItemSelected, pathKey)}
|
|
400
|
-
{/snippet}
|
|
401
|
-
</svelte:boundary>
|
|
402
|
-
</div>
|
|
403
|
-
{:else}
|
|
404
|
-
{@render defaultOption(proxy, handlers, isItemSelected, pathKey)}
|
|
405
|
-
{/if}
|
|
244
|
+
{#snippet defaultOptionContent(proxy: ProxyItem)}
|
|
245
|
+
<ItemContent {proxy} />
|
|
406
246
|
{/snippet}
|
|
407
247
|
|
|
408
|
-
{#snippet
|
|
409
|
-
{#if
|
|
410
|
-
<
|
|
411
|
-
{@render groupLabelSnippet(proxy.original as SelectItem, proxy.fields)}
|
|
412
|
-
{#snippet failed()}
|
|
413
|
-
{@render defaultGroupLabel(proxy)}
|
|
414
|
-
{/snippet}
|
|
415
|
-
</svelte:boundary>
|
|
416
|
-
{:else}
|
|
417
|
-
{@render defaultGroupLabel(proxy)}
|
|
248
|
+
{#snippet defaultGroupContent(proxy: ProxyItem)}
|
|
249
|
+
{#if proxy.get('icon')}
|
|
250
|
+
<span data-select-group-icon class={proxy.get('icon')} aria-hidden="true"></span>
|
|
418
251
|
{/if}
|
|
252
|
+
<span>{proxy.label}</span>
|
|
419
253
|
{/snippet}
|
|
420
254
|
|
|
421
255
|
<div
|
|
@@ -425,45 +259,39 @@
|
|
|
425
259
|
data-open={isOpen || undefined}
|
|
426
260
|
data-size={size}
|
|
427
261
|
data-disabled={disabled || undefined}
|
|
428
|
-
data-align={
|
|
262
|
+
data-align={align}
|
|
429
263
|
data-direction={direction}
|
|
430
264
|
class={className || undefined}
|
|
431
265
|
>
|
|
432
266
|
<button
|
|
267
|
+
bind:this={triggerRef}
|
|
433
268
|
type="button"
|
|
434
269
|
data-select-trigger
|
|
435
270
|
{disabled}
|
|
436
271
|
aria-haspopup="listbox"
|
|
437
272
|
aria-expanded={isOpen}
|
|
438
|
-
onclick={toggleDropdown}
|
|
439
|
-
onkeydown={handleTriggerKeyDown}
|
|
440
273
|
>
|
|
441
274
|
<span data-select-value>
|
|
442
|
-
{#if
|
|
443
|
-
{
|
|
444
|
-
selectedItems.map((item) => item.original),
|
|
445
|
-
selectedItems[0]?.proxy.fields ?? {}
|
|
446
|
-
)}
|
|
447
|
-
{:else if selectedItems.length > 0}
|
|
448
|
-
{#if selectedItems.length <= maxDisplay}
|
|
275
|
+
{#if selectedProxies.length > 0}
|
|
276
|
+
{#if selectedProxies.length <= maxDisplay}
|
|
449
277
|
<span data-select-tags>
|
|
450
|
-
{#each
|
|
278
|
+
{#each selectedProxies as proxy (proxy.value)}
|
|
451
279
|
<span data-select-tag>
|
|
452
|
-
<span data-select-tag-text>{
|
|
280
|
+
<span data-select-tag-text>{proxy.label}</span>
|
|
453
281
|
<span
|
|
454
282
|
role="button"
|
|
455
283
|
tabindex="0"
|
|
456
284
|
data-select-tag-remove
|
|
457
|
-
aria-label="Remove {
|
|
285
|
+
aria-label="Remove {proxy.label}"
|
|
458
286
|
onclick={(e) => {
|
|
459
287
|
e.stopPropagation()
|
|
460
|
-
|
|
288
|
+
removeTag(proxy.value)
|
|
461
289
|
}}
|
|
462
290
|
onkeydown={(e) => {
|
|
463
291
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
464
292
|
e.preventDefault()
|
|
465
293
|
e.stopPropagation()
|
|
466
|
-
|
|
294
|
+
removeTag(proxy.value)
|
|
467
295
|
}
|
|
468
296
|
}}
|
|
469
297
|
>
|
|
@@ -473,7 +301,7 @@
|
|
|
473
301
|
{/each}
|
|
474
302
|
</span>
|
|
475
303
|
{:else}
|
|
476
|
-
<span data-select-count>{
|
|
304
|
+
<span data-select-count>{selectedProxies.length} selected</span>
|
|
477
305
|
{/if}
|
|
478
306
|
{:else}
|
|
479
307
|
<span data-select-placeholder>{placeholder}</span>
|
|
@@ -490,30 +318,50 @@
|
|
|
490
318
|
role="listbox"
|
|
491
319
|
aria-multiselectable="true"
|
|
492
320
|
aria-orientation="vertical"
|
|
493
|
-
style="max-height: {maxHeight}px"
|
|
494
|
-
onfocusin={handleFocusIn}
|
|
495
|
-
use:navigator={{ wrapper: controller, orientation: 'vertical' }}
|
|
496
321
|
>
|
|
497
|
-
{#each
|
|
498
|
-
{@const proxy =
|
|
499
|
-
|
|
500
|
-
{
|
|
501
|
-
|
|
502
|
-
|
|
322
|
+
{#each wrapper.flatView as node (node.key)}
|
|
323
|
+
{@const proxy = node.proxy}
|
|
324
|
+
{@const sel = !node.hasChildren && isItemSelected(proxy.value)}
|
|
325
|
+
{@const content = resolveSnippet(snippets as Record<string, unknown>, proxy, node.hasChildren ? GROUP_SNIPPET : ITEM_SNIPPET)}
|
|
326
|
+
|
|
327
|
+
{#if node.type === 'separator'}
|
|
328
|
+
<hr data-select-separator />
|
|
329
|
+
{:else if node.hasChildren}
|
|
330
|
+
{#if groupDividers.has(node.key)}
|
|
331
|
+
<div data-select-divider></div>
|
|
503
332
|
{/if}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
{@const pathKey = getPathKey(child as SelectItem)}
|
|
511
|
-
{@render renderOption(child as SelectItem, childProxy, pathKey)}
|
|
512
|
-
{/each}
|
|
333
|
+
<div data-select-group-label role="presentation">
|
|
334
|
+
{#if content}
|
|
335
|
+
{@render content(proxy)}
|
|
336
|
+
{:else}
|
|
337
|
+
{@render defaultGroupContent(proxy)}
|
|
338
|
+
{/if}
|
|
513
339
|
</div>
|
|
514
340
|
{:else}
|
|
515
|
-
|
|
516
|
-
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
data-select-option
|
|
344
|
+
data-path={node.key}
|
|
345
|
+
data-level={node.level}
|
|
346
|
+
data-selected={sel || undefined}
|
|
347
|
+
data-disabled={proxy.disabled || undefined}
|
|
348
|
+
role="option"
|
|
349
|
+
aria-selected={sel}
|
|
350
|
+
aria-label={proxy.label}
|
|
351
|
+
disabled={proxy.disabled || disabled}
|
|
352
|
+
tabindex="-1"
|
|
353
|
+
>
|
|
354
|
+
<span data-select-checkbox data-checked={sel || undefined}>
|
|
355
|
+
{#if sel}
|
|
356
|
+
<span class={icons.checked} aria-hidden="true"></span>
|
|
357
|
+
{/if}
|
|
358
|
+
</span>
|
|
359
|
+
{#if content}
|
|
360
|
+
{@render content(proxy)}
|
|
361
|
+
{:else}
|
|
362
|
+
{@render defaultOptionContent(proxy)}
|
|
363
|
+
{/if}
|
|
364
|
+
</button>
|
|
517
365
|
{/if}
|
|
518
366
|
{/each}
|
|
519
367
|
</div>
|