svelte-fluentui 1.0.0 → 1.2.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/README.md +16 -32
- package/dist/assets/styles/components.scss +23 -3
- package/dist/components/Calendar.svelte +41 -4
- package/dist/components/Calendar.svelte.d.ts +2 -0
- package/dist/components/Checkbox.svelte +21 -15
- package/dist/components/Combobox.svelte +2 -13
- package/dist/components/Combobox.svelte.d.ts +0 -1
- package/dist/components/DatePicker.svelte +22 -5
- package/dist/components/DatePicker.svelte.d.ts +2 -0
- package/dist/components/Divider.svelte +9 -2
- package/dist/components/Divider.svelte.d.ts +2 -0
- package/dist/components/Listbox.svelte +0 -2
- package/dist/components/Listbox.svelte.d.ts +0 -1
- package/dist/components/PositioningRegion.svelte +23 -5
- package/dist/components/PositioningRegion.svelte.d.ts +6 -0
- package/dist/components/Select.svelte +665 -188
- package/dist/components/Select.svelte.d.ts +6 -3
- package/dist/components/Switch.svelte +19 -13
- package/dist/components/Textarea.svelte +28 -0
- package/dist/components/TimePicker.svelte +14 -3
- package/dist/components/TimePicker.svelte.d.ts +2 -0
- package/dist/main.css +15 -0
- package/dist/main.css.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,52 +1,76 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
* Select Component (rewrite)
|
|
3
|
+
*
|
|
4
|
+
* Custom Select that replaces the previous <fluent-select> wrapper.
|
|
5
|
+
*
|
|
6
|
+
* Single mode: custom combobox-style trigger + portalled listbox via
|
|
7
|
+
* PositioningRegion. The listbox max-height is capped to the available
|
|
8
|
+
* viewport space via PositioningRegion's availableHeight middleware, so
|
|
9
|
+
* the dropdown never extends past the viewport edges.
|
|
10
|
+
*
|
|
11
|
+
* Multi mode: always-expanded inline listbox (matches Blazor
|
|
12
|
+
* FluentSelect, which renders multi-select inline because the underlying
|
|
13
|
+
* web component doesn't support a multi-select dropdown).
|
|
14
|
+
*
|
|
15
|
+
* <Option> children work unchanged — they read selection via the
|
|
16
|
+
* 'selected-options' context, which this component provides.
|
|
17
|
+
-->
|
|
18
|
+
|
|
1
19
|
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
20
|
+
import {setContext, untrack, tick} from "svelte"
|
|
21
|
+
import {provideFluentDesignSystem, fluentOption} from "@fluentui/web-components"
|
|
22
|
+
import PositioningRegion from "./PositioningRegion.svelte"
|
|
23
|
+
import type {SlotType} from "../types/index.js"
|
|
24
|
+
import type {SelectedOptionSvelteContext} from "../types/combobox.js"
|
|
4
25
|
|
|
5
|
-
provideFluentDesignSystem().register(
|
|
26
|
+
provideFluentDesignSystem().register(fluentOption())
|
|
6
27
|
|
|
7
28
|
type SelectChangeDetail = {
|
|
8
|
-
value: string
|
|
9
|
-
selectedOption?: string
|
|
10
|
-
data?: Record<string, unknown
|
|
11
|
-
}
|
|
29
|
+
value: string
|
|
30
|
+
selectedOption?: string
|
|
31
|
+
data?: Record<string, unknown>
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
type Props = {
|
|
14
|
-
id?: string
|
|
15
|
-
class?: string
|
|
16
|
-
style?: string
|
|
17
|
-
open?: boolean
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
id?: string
|
|
36
|
+
class?: string
|
|
37
|
+
style?: string
|
|
38
|
+
open?: boolean
|
|
39
|
+
/** Force the dropdown above/below the trigger. Default: auto (flip()). */
|
|
40
|
+
position?: "above" | "below"
|
|
41
|
+
multiple?: boolean
|
|
42
|
+
disabled?: boolean
|
|
43
|
+
appearance?: "outline" | "filled"
|
|
44
|
+
required?: boolean
|
|
45
|
+
autofocus?: boolean
|
|
46
|
+
name?: string
|
|
47
|
+
/** Selected value. String in single mode, string[] in multi mode. */
|
|
48
|
+
value?: string | string[]
|
|
49
|
+
label?: string
|
|
50
|
+
ariaLabel?: string
|
|
51
|
+
title?: string
|
|
52
|
+
width?: string
|
|
53
|
+
/** Multi mode: explicit height for the inline listbox. */
|
|
54
|
+
height?: string
|
|
55
|
+
/** Multi mode: cap visible option rows, enables internal scroll. */
|
|
56
|
+
maxVisibleOptions?: number
|
|
57
|
+
labelTemplate?: SlotType
|
|
58
|
+
indicatorTemplate?: SlotType
|
|
59
|
+
children?: SlotType
|
|
60
|
+
onchange?: (detail: SelectChangeDetail) => void
|
|
61
|
+
}
|
|
38
62
|
|
|
39
63
|
let {
|
|
40
64
|
id = undefined,
|
|
41
65
|
class: className = "",
|
|
42
66
|
style = "",
|
|
43
|
-
open = undefined,
|
|
67
|
+
open = $bindable(undefined),
|
|
44
68
|
position = undefined,
|
|
45
69
|
multiple = false,
|
|
46
|
-
disabled =
|
|
47
|
-
appearance =
|
|
48
|
-
required =
|
|
49
|
-
autofocus =
|
|
70
|
+
disabled = false,
|
|
71
|
+
appearance = "outline",
|
|
72
|
+
required = false,
|
|
73
|
+
autofocus = false,
|
|
50
74
|
name = undefined,
|
|
51
75
|
value = $bindable(),
|
|
52
76
|
label = undefined,
|
|
@@ -59,182 +83,635 @@
|
|
|
59
83
|
indicatorTemplate = undefined,
|
|
60
84
|
children = undefined,
|
|
61
85
|
onchange = undefined
|
|
62
|
-
}: Props = $props()
|
|
86
|
+
}: Props = $props()
|
|
87
|
+
|
|
88
|
+
// ---------- Selection state (shared with <Option> via context) ----------
|
|
89
|
+
// We provide our own context instead of createSelectedOptions because the
|
|
90
|
+
// single-mode UX is "click sets value", not "click toggles" — the existing
|
|
91
|
+
// store would deselect when clicking the already-selected option.
|
|
92
|
+
|
|
93
|
+
function normalize(v: typeof value): string[] {
|
|
94
|
+
if (v == null) return []
|
|
95
|
+
return Array.isArray(v) ? [...v] : [v]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let selectionState = $state<{value: string[]}>({value: normalize(value)})
|
|
99
|
+
|
|
100
|
+
const ctx: SelectedOptionSvelteContext = {
|
|
101
|
+
get value() {
|
|
102
|
+
return selectionState.value.length ? selectionState.value : null
|
|
103
|
+
},
|
|
104
|
+
set value(v) {
|
|
105
|
+
selectionState.value = v ?? []
|
|
106
|
+
},
|
|
107
|
+
set(v) {
|
|
108
|
+
selectionState.value = Array.isArray(v) ? [...v] : (v ? [v] : [])
|
|
109
|
+
},
|
|
110
|
+
toggle(val: string) {
|
|
111
|
+
if (val == null) return
|
|
112
|
+
if (multiple) {
|
|
113
|
+
const cur = selectionState.value
|
|
114
|
+
selectionState.value = cur.includes(val)
|
|
115
|
+
? cur.filter(x => x !== val)
|
|
116
|
+
: [...cur, val]
|
|
117
|
+
} else {
|
|
118
|
+
selectionState.value = [val]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setContext<SelectedOptionSvelteContext>("selected-options", ctx)
|
|
63
124
|
|
|
64
|
-
|
|
65
|
-
|
|
125
|
+
// prop value -> internal state
|
|
126
|
+
$effect(() => {
|
|
127
|
+
const normalized = normalize(value)
|
|
128
|
+
const current = untrack(() => selectionState.value)
|
|
129
|
+
if (
|
|
130
|
+
normalized.length !== current.length ||
|
|
131
|
+
normalized.some((v, i) => v !== current[i])
|
|
132
|
+
) {
|
|
133
|
+
selectionState.value = normalized
|
|
134
|
+
}
|
|
135
|
+
})
|
|
66
136
|
|
|
67
|
-
//
|
|
68
|
-
// The fluent-select web component only evaluates current-value at init,
|
|
69
|
-
// so if options arrive later (async), the selection is lost.
|
|
137
|
+
// internal state -> prop value
|
|
70
138
|
$effect(() => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (el && el.value !== value) {
|
|
78
|
-
el.value = value;
|
|
139
|
+
const sel = selectionState.value
|
|
140
|
+
untrack(() => {
|
|
141
|
+
if (multiple) {
|
|
142
|
+
const cur = Array.isArray(value) ? value : (value != null ? [value] : [])
|
|
143
|
+
if (sel.length !== cur.length || sel.some((v, i) => v !== cur[i])) {
|
|
144
|
+
value = [...sel]
|
|
79
145
|
}
|
|
146
|
+
} else {
|
|
147
|
+
const next = sel[0] ?? ""
|
|
148
|
+
if (value !== next) value = next
|
|
80
149
|
}
|
|
81
|
-
})
|
|
150
|
+
})
|
|
151
|
+
})
|
|
82
152
|
|
|
83
|
-
|
|
153
|
+
const selectedSingle = $derived<string>(selectionState.value[0] ?? "")
|
|
84
154
|
|
|
85
|
-
|
|
86
|
-
|
|
155
|
+
// ---------- Open / close (single mode) ----------
|
|
156
|
+
let isOpen = $state(false)
|
|
87
157
|
|
|
88
|
-
//
|
|
158
|
+
// Allow external control via `open` prop (binds both ways).
|
|
89
159
|
$effect(() => {
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
160
|
+
if (open !== undefined && open !== untrack(() => isOpen)) {
|
|
161
|
+
isOpen = open
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
$effect(() => {
|
|
165
|
+
if (open !== undefined && isOpen !== untrack(() => open)) {
|
|
166
|
+
open = isOpen
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
let triggerEl: HTMLButtonElement | undefined = $state()
|
|
171
|
+
let listEl: HTMLDivElement | undefined = $state()
|
|
172
|
+
let inlineListEl: HTMLDivElement | undefined = $state()
|
|
173
|
+
// Single-mode hidden mirror of <Option> children. Stays mounted even when
|
|
174
|
+
// the dropdown is closed, so getOptionText() can resolve the trigger's
|
|
175
|
+
// display text from `value` (the portalled listEl unmounts on close).
|
|
176
|
+
let sourceEl: HTMLDivElement | undefined = $state()
|
|
177
|
+
let highlightedIndex = $state(-1)
|
|
178
|
+
let typeAheadBuffer = ""
|
|
179
|
+
let typeAheadTimer: ReturnType<typeof setTimeout> | undefined
|
|
180
|
+
|
|
181
|
+
function getOptionEls(root: HTMLElement | undefined): HTMLElement[] {
|
|
182
|
+
if (!root) return []
|
|
183
|
+
return Array.from(root.querySelectorAll("fluent-option")) as HTMLElement[]
|
|
184
|
+
}
|
|
185
|
+
function enabledOptionEls(root: HTMLElement | undefined): HTMLElement[] {
|
|
186
|
+
return getOptionEls(root).filter(el => !el.hasAttribute("disabled"))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// fluent-option carries its value as a JS PROPERTY (set by Svelte's custom-element
|
|
190
|
+
// property-assignment path), not an HTML attribute — so getAttribute("value")
|
|
191
|
+
// returns null. Read the property first, fall back to the attribute for the
|
|
192
|
+
// rare case where it was set as a string attribute.
|
|
193
|
+
function optionValue(el: HTMLElement): string {
|
|
194
|
+
const v = (el as HTMLElement & {value?: string}).value
|
|
195
|
+
if (typeof v === "string") return v
|
|
196
|
+
return el.getAttribute("value") ?? ""
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getOptionText(val: string): string {
|
|
200
|
+
const roots = multiple
|
|
201
|
+
? [inlineListEl]
|
|
202
|
+
: [listEl, sourceEl]
|
|
203
|
+
for (const root of roots) {
|
|
204
|
+
if (!root) continue
|
|
205
|
+
const all = getOptionEls(root)
|
|
206
|
+
const el = all.find(o => optionValue(o) === val)
|
|
207
|
+
if (el) return el.textContent?.trim() ?? ""
|
|
208
|
+
}
|
|
209
|
+
return ""
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getOptionData(el: HTMLElement): Record<string, unknown> | undefined {
|
|
213
|
+
const raw = el.dataset.optionContext
|
|
214
|
+
if (!raw) return undefined
|
|
215
|
+
try { return JSON.parse(raw) } catch { return undefined }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function openDropdown() {
|
|
219
|
+
if (disabled || multiple) return
|
|
220
|
+
isOpen = true
|
|
221
|
+
tick().then(() => {
|
|
222
|
+
const opts = enabledOptionEls(listEl)
|
|
223
|
+
const i = opts.findIndex(el => optionValue(el) === selectedSingle)
|
|
224
|
+
highlightedIndex = i >= 0 ? i : (opts.length > 0 ? 0 : -1)
|
|
225
|
+
scrollHighlightedIntoView()
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function closeDropdown(refocus = true) {
|
|
230
|
+
if (!isOpen) return
|
|
231
|
+
isOpen = false
|
|
232
|
+
highlightedIndex = -1
|
|
233
|
+
typeAheadBuffer = ""
|
|
234
|
+
if (refocus) triggerEl?.focus()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function toggleDropdown() {
|
|
238
|
+
if (isOpen) closeDropdown()
|
|
239
|
+
else openDropdown()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Keyboard-path selection: explicit call from key handlers when there's no
|
|
243
|
+
// click event to ride. Mutates selection via ctx.toggle (which has the right
|
|
244
|
+
// mode-aware semantics), fires onchange, and closes in single mode.
|
|
245
|
+
function selectByValue(val: string, el?: HTMLElement) {
|
|
246
|
+
const optEl = el ?? getOptionEls(multiple ? inlineListEl : listEl)
|
|
247
|
+
.find(o => optionValue(o) === val)
|
|
248
|
+
|
|
249
|
+
ctx.toggle(val)
|
|
250
|
+
|
|
251
|
+
onchange?.({
|
|
252
|
+
value: multiple ? "" : (selectionState.value[0] ?? ""),
|
|
253
|
+
selectedOption: optEl?.textContent?.trim(),
|
|
254
|
+
data: optEl ? getOptionData(optEl) : undefined
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
if (!multiple) closeDropdown()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Click-path: Option.svelte's own onclick already called ctx.toggle by the
|
|
261
|
+
// time this bubble-phase handler runs (option is a descendant of the
|
|
262
|
+
// listbox). We deliberately do NOT call ctx.toggle here — doing so would
|
|
263
|
+
// double-toggle in multi mode and cancel the user's click. We only handle
|
|
264
|
+
// the post-selection side effects: onchange + close-on-single.
|
|
265
|
+
function handleListClick(ev: MouseEvent) {
|
|
266
|
+
const target = ev.target as HTMLElement | null
|
|
267
|
+
if (!target) return
|
|
268
|
+
const optEl = target.closest("fluent-option") as HTMLElement | null
|
|
269
|
+
if (!optEl) return
|
|
270
|
+
if (optEl.hasAttribute("disabled")) {
|
|
271
|
+
ev.preventDefault()
|
|
272
|
+
return
|
|
110
273
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (!
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
274
|
+
|
|
275
|
+
onchange?.({
|
|
276
|
+
value: multiple ? "" : (selectionState.value[0] ?? ""),
|
|
277
|
+
selectedOption: optEl.textContent?.trim(),
|
|
278
|
+
data: getOptionData(optEl)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (!multiple) closeDropdown()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------- Keyboard (single mode) ----------
|
|
285
|
+
function moveHighlight(delta: number) {
|
|
286
|
+
const opts = enabledOptionEls(listEl)
|
|
287
|
+
if (opts.length === 0) return
|
|
288
|
+
const cur = highlightedIndex
|
|
289
|
+
let next = cur + delta
|
|
290
|
+
if (next < 0) next = 0
|
|
291
|
+
if (next >= opts.length) next = opts.length - 1
|
|
292
|
+
highlightedIndex = next
|
|
293
|
+
scrollHighlightedIntoView()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function scrollHighlightedIntoView() {
|
|
297
|
+
if (!listEl || highlightedIndex < 0) return
|
|
298
|
+
const opts = enabledOptionEls(listEl)
|
|
299
|
+
const el = opts[highlightedIndex]
|
|
300
|
+
el?.scrollIntoView({block: "nearest"})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function typeAheadJump(ch: string) {
|
|
304
|
+
clearTimeout(typeAheadTimer)
|
|
305
|
+
typeAheadBuffer += ch.toLowerCase()
|
|
306
|
+
typeAheadTimer = setTimeout(() => { typeAheadBuffer = "" }, 750)
|
|
307
|
+
|
|
308
|
+
const opts = enabledOptionEls(listEl)
|
|
309
|
+
if (opts.length === 0) return
|
|
310
|
+
const start = highlightedIndex >= 0 ? highlightedIndex : 0
|
|
311
|
+
const n = opts.length
|
|
312
|
+
|
|
313
|
+
// Mode picking:
|
|
314
|
+
// - "Repeat" (buffer="cc"|"ccc"): user is cycling — advance past current.
|
|
315
|
+
// - "Single re-press" (buffer="c", current option already starts with "c"):
|
|
316
|
+
// treat like cycle too, so c-pause-c-pause-c advances through matches
|
|
317
|
+
// even when the buffer timed out between presses.
|
|
318
|
+
// - "Prefix" (anything else): land on the first option matching the buffer
|
|
319
|
+
// from the current position.
|
|
320
|
+
const isRepeat = typeAheadBuffer.length > 1
|
|
321
|
+
&& typeAheadBuffer.split("").every(c => c === typeAheadBuffer[0])
|
|
322
|
+
const isSingleRepress = typeAheadBuffer.length === 1
|
|
323
|
+
&& (opts[start]?.textContent?.trim().toLowerCase() ?? "").startsWith(typeAheadBuffer)
|
|
324
|
+
const advance = isRepeat || isSingleRepress
|
|
325
|
+
const needle = isRepeat ? typeAheadBuffer[0] : typeAheadBuffer
|
|
326
|
+
const startAt = advance ? (start + 1) % n : start
|
|
327
|
+
|
|
328
|
+
for (let i = 0; i < n; i++) {
|
|
329
|
+
const idx = (startAt + i) % n
|
|
330
|
+
const text = opts[idx].textContent?.trim().toLowerCase() ?? ""
|
|
331
|
+
if (text.startsWith(needle)) {
|
|
332
|
+
highlightedIndex = idx
|
|
333
|
+
scrollHighlightedIntoView()
|
|
334
|
+
return
|
|
335
|
+
}
|
|
140
336
|
}
|
|
141
|
-
|
|
142
|
-
});
|
|
337
|
+
}
|
|
143
338
|
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
value = target.value;
|
|
339
|
+
function handleTriggerKeydown(ev: KeyboardEvent) {
|
|
340
|
+
if (disabled) return
|
|
147
341
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
342
|
+
if (!isOpen) {
|
|
343
|
+
switch (ev.key) {
|
|
344
|
+
case "ArrowDown":
|
|
345
|
+
case "ArrowUp":
|
|
346
|
+
case "Enter":
|
|
347
|
+
case " ":
|
|
348
|
+
ev.preventDefault()
|
|
349
|
+
openDropdown()
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
// Type-ahead on the closed trigger: open + jump-highlight (no commit).
|
|
353
|
+
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
|
354
|
+
ev.preventDefault()
|
|
355
|
+
openDropdown()
|
|
356
|
+
tick().then(() => typeAheadJump(ev.key))
|
|
357
|
+
}
|
|
358
|
+
return
|
|
359
|
+
}
|
|
153
360
|
|
|
154
|
-
|
|
361
|
+
switch (ev.key) {
|
|
362
|
+
case "ArrowDown": ev.preventDefault(); moveHighlight(1); break
|
|
363
|
+
case "ArrowUp": ev.preventDefault(); moveHighlight(-1); break
|
|
364
|
+
case "Home": {
|
|
365
|
+
ev.preventDefault()
|
|
366
|
+
const opts = enabledOptionEls(listEl)
|
|
367
|
+
if (opts.length) { highlightedIndex = 0; scrollHighlightedIntoView() }
|
|
368
|
+
break
|
|
369
|
+
}
|
|
370
|
+
case "End": {
|
|
371
|
+
ev.preventDefault()
|
|
372
|
+
const opts = enabledOptionEls(listEl)
|
|
373
|
+
if (opts.length) { highlightedIndex = opts.length - 1; scrollHighlightedIntoView() }
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
case "Enter":
|
|
377
|
+
case " ": {
|
|
378
|
+
ev.preventDefault()
|
|
379
|
+
const opts = enabledOptionEls(listEl)
|
|
380
|
+
const el = opts[highlightedIndex]
|
|
381
|
+
if (el) selectByValue(optionValue(el), el)
|
|
382
|
+
break
|
|
383
|
+
}
|
|
384
|
+
case "Escape": ev.preventDefault(); closeDropdown(); break
|
|
385
|
+
case "Tab": closeDropdown(false); break
|
|
386
|
+
default:
|
|
387
|
+
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
|
388
|
+
ev.preventDefault()
|
|
389
|
+
typeAheadJump(ev.key)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
155
392
|
}
|
|
393
|
+
|
|
394
|
+
// ---------- Visual highlight sync ----------
|
|
395
|
+
// Reflects highlightedIndex to a data-highlighted attribute on the corresponding
|
|
396
|
+
// fluent-option, so CSS can style the keyboard-cursor row distinctly from
|
|
397
|
+
// hover and selected states.
|
|
398
|
+
$effect(() => {
|
|
399
|
+
const _ = highlightedIndex // dep tracking
|
|
400
|
+
const opts = enabledOptionEls(listEl)
|
|
401
|
+
opts.forEach((el, i) => {
|
|
402
|
+
if (i === highlightedIndex) el.setAttribute("data-highlighted", "")
|
|
403
|
+
else el.removeAttribute("data-highlighted")
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// ---------- Close on outside pointerdown ----------
|
|
408
|
+
$effect(() => {
|
|
409
|
+
if (!isOpen) return
|
|
410
|
+
function onDown(ev: PointerEvent) {
|
|
411
|
+
const path = ev.composedPath()
|
|
412
|
+
if (triggerEl && path.includes(triggerEl)) return
|
|
413
|
+
if (listEl && path.includes(listEl)) return
|
|
414
|
+
closeDropdown(false)
|
|
415
|
+
}
|
|
416
|
+
// Use capture so we beat any handler that stops propagation.
|
|
417
|
+
document.addEventListener("pointerdown", onDown, true)
|
|
418
|
+
return () => document.removeEventListener("pointerdown", onDown, true)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// ---------- Multi-mode height calculation (maxVisibleOptions) ----------
|
|
422
|
+
let multiCalcHeight: string | undefined = $state()
|
|
423
|
+
$effect(() => {
|
|
424
|
+
if (!multiple || !inlineListEl || height) {
|
|
425
|
+
multiCalcHeight = undefined
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (maxVisibleOptions === undefined) {
|
|
429
|
+
multiCalcHeight = undefined
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
queueMicrotask(() => {
|
|
433
|
+
if (!inlineListEl) return
|
|
434
|
+
const opts = inlineListEl.querySelectorAll("fluent-option")
|
|
435
|
+
if (opts.length === 0) return
|
|
436
|
+
const first = opts[0] as HTMLElement
|
|
437
|
+
const rowH = parseInt(window.getComputedStyle(first).height) || first.offsetHeight || 32
|
|
438
|
+
const visible = Math.min(opts.length, maxVisibleOptions)
|
|
439
|
+
// + 8px for top/bottom padding of the listbox.
|
|
440
|
+
multiCalcHeight = `${visible * rowH + 8}px`
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// ---------- Computed styles ----------
|
|
445
|
+
const triggerStyle = $derived(() => {
|
|
446
|
+
const parts: string[] = []
|
|
447
|
+
if (width) parts.push(`width: ${width}`)
|
|
448
|
+
return parts.join("; ")
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const multiListStyle = $derived(() => {
|
|
452
|
+
const parts: string[] = []
|
|
453
|
+
if (width) parts.push(`width: ${width}`)
|
|
454
|
+
if (height) parts.push(`height: ${height}`)
|
|
455
|
+
else if (multiCalcHeight) parts.push(`height: ${multiCalcHeight}`)
|
|
456
|
+
return parts.join("; ")
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// Forced position support: prop -> Floating UI placement.
|
|
460
|
+
const forcedPlacement = $derived<"top" | "bottom" | undefined>(
|
|
461
|
+
position === "above" ? "top" : position === "below" ? "bottom" : undefined
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const displayText = $derived(getOptionText(selectedSingle))
|
|
465
|
+
|
|
466
|
+
const hasLabel = $derived(!!(label || labelTemplate))
|
|
467
|
+
|
|
468
|
+
const listboxId = $derived(id ? `${id}-listbox` : undefined)
|
|
156
469
|
</script>
|
|
157
470
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
current-value={value}
|
|
186
|
-
{title}
|
|
187
|
-
aria-label={ariaLabel || label}
|
|
188
|
-
onchange={handleChange}
|
|
189
|
-
>
|
|
190
|
-
{#if indicatorTemplate}
|
|
191
|
-
<span slot="indicator">
|
|
192
|
-
{@render indicatorTemplate()}
|
|
193
|
-
</span>
|
|
194
|
-
{/if}
|
|
195
|
-
{#if children}
|
|
196
|
-
{@render children?.()}
|
|
197
|
-
{/if}
|
|
198
|
-
</fluent-select>
|
|
199
|
-
</div>
|
|
471
|
+
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
472
|
+
{#if hasLabel}
|
|
473
|
+
<label class="fluent-label" for={id}>
|
|
474
|
+
{#if label}{label}{/if}
|
|
475
|
+
{#if labelTemplate}{@render labelTemplate?.()}{/if}
|
|
476
|
+
{#if required}<span class="required-indicator">*</span>{/if}
|
|
477
|
+
</label>
|
|
478
|
+
{/if}
|
|
479
|
+
|
|
480
|
+
{#if multiple}
|
|
481
|
+
<!-- ===== Multi mode: always-expanded inline listbox ===== -->
|
|
482
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
483
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
484
|
+
<div
|
|
485
|
+
bind:this={inlineListEl}
|
|
486
|
+
{id}
|
|
487
|
+
role="listbox"
|
|
488
|
+
tabindex="0"
|
|
489
|
+
aria-multiselectable="true"
|
|
490
|
+
aria-label={ariaLabel || label}
|
|
491
|
+
class="select-listbox-inline {appearance} {className}"
|
|
492
|
+
class:disabled
|
|
493
|
+
style={(multiListStyle() ? multiListStyle() + "; " : "") + style}
|
|
494
|
+
{title}
|
|
495
|
+
onclick={handleListClick}
|
|
496
|
+
>
|
|
497
|
+
{@render children?.()}
|
|
200
498
|
</div>
|
|
201
499
|
{:else}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
{/if}
|
|
500
|
+
<!-- ===== Single mode: trigger + portalled dropdown ===== -->
|
|
501
|
+
|
|
502
|
+
<!-- Hidden mirror of <Option> children so getOptionText() can resolve the
|
|
503
|
+
selected value's display text when the dropdown is closed. Aria-hidden
|
|
504
|
+
so screen readers don't see two copies of the options. -->
|
|
505
|
+
<div bind:this={sourceEl} class="select-options-source" aria-hidden="true">
|
|
506
|
+
{@render children?.()}
|
|
507
|
+
</div>
|
|
508
|
+
|
|
212
509
|
<!-- svelte-ignore a11y_autofocus -->
|
|
213
|
-
<
|
|
214
|
-
bind:this={
|
|
510
|
+
<button
|
|
511
|
+
bind:this={triggerEl}
|
|
512
|
+
type="button"
|
|
215
513
|
{id}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
{
|
|
220
|
-
{
|
|
514
|
+
role="combobox"
|
|
515
|
+
aria-haspopup="listbox"
|
|
516
|
+
aria-expanded={isOpen}
|
|
517
|
+
aria-controls={listboxId}
|
|
518
|
+
aria-label={ariaLabel || label}
|
|
221
519
|
{disabled}
|
|
222
|
-
{appearance}
|
|
223
|
-
{required}
|
|
224
520
|
{autofocus}
|
|
225
|
-
{name}
|
|
226
|
-
current-value={value}
|
|
227
521
|
{title}
|
|
228
|
-
|
|
229
|
-
|
|
522
|
+
class="select-trigger {appearance} {className}"
|
|
523
|
+
class:open={isOpen}
|
|
524
|
+
style={(triggerStyle() ? triggerStyle() + "; " : "") + style}
|
|
525
|
+
onclick={toggleDropdown}
|
|
526
|
+
onkeydown={handleTriggerKeydown}
|
|
230
527
|
>
|
|
231
|
-
{
|
|
232
|
-
|
|
528
|
+
<span class="select-trigger-value" class:placeholder={!displayText}>
|
|
529
|
+
{displayText}
|
|
530
|
+
</span>
|
|
531
|
+
<span class="select-trigger-indicator" aria-hidden="true">
|
|
532
|
+
{#if indicatorTemplate}
|
|
233
533
|
{@render indicatorTemplate()}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
534
|
+
{:else}
|
|
535
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
536
|
+
<path d="M2.22 4.47a.75.75 0 0 1 1.06 0L6 7.19l2.72-2.72a.75.75 0 1 1 1.06 1.06L6.53 8.78a.75.75 0 0 1-1.06 0L2.22 5.53a.75.75 0 0 1 0-1.06Z"/>
|
|
537
|
+
</svg>
|
|
538
|
+
{/if}
|
|
539
|
+
</span>
|
|
540
|
+
</button>
|
|
541
|
+
|
|
542
|
+
{#if isOpen && triggerEl}
|
|
543
|
+
<PositioningRegion
|
|
544
|
+
anchor={triggerEl}
|
|
545
|
+
visible={isOpen}
|
|
546
|
+
position={forcedPlacement}
|
|
547
|
+
matchWidth
|
|
548
|
+
availableHeight
|
|
549
|
+
>
|
|
550
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
551
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
552
|
+
<div
|
|
553
|
+
bind:this={listEl}
|
|
554
|
+
id={listboxId}
|
|
555
|
+
role="listbox"
|
|
556
|
+
tabindex="-1"
|
|
557
|
+
class="select-listbox-popover"
|
|
558
|
+
onclick={handleListClick}
|
|
559
|
+
>
|
|
560
|
+
{@render children?.()}
|
|
561
|
+
</div>
|
|
562
|
+
</PositioningRegion>
|
|
563
|
+
{/if}
|
|
564
|
+
|
|
565
|
+
<!-- Hidden input to participate in HTML form submission. -->
|
|
566
|
+
{#if name}
|
|
567
|
+
<input type="hidden" {name} {required} value={selectedSingle} />
|
|
568
|
+
{/if}
|
|
240
569
|
{/if}
|
|
570
|
+
|
|
571
|
+
<style>
|
|
572
|
+
.required-indicator {
|
|
573
|
+
color: var(--error-foreground-rest, #d13438);
|
|
574
|
+
margin-left: 0.25rem;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/* ===== Trigger button (single mode) ===== */
|
|
578
|
+
.select-trigger {
|
|
579
|
+
display: inline-flex;
|
|
580
|
+
align-items: center;
|
|
581
|
+
justify-content: space-between;
|
|
582
|
+
gap: 8px;
|
|
583
|
+
min-height: calc((var(--base-height-multiplier, 8) + var(--density, 0)) * var(--design-unit, 4) * 1px);
|
|
584
|
+
box-sizing: border-box;
|
|
585
|
+
padding: 0 calc(var(--design-unit, 4) * 2 * 1px);
|
|
586
|
+
background: var(--neutral-fill-input-rest, #ffffff);
|
|
587
|
+
border: 1px solid var(--neutral-stroke-rest, #d1d1d1);
|
|
588
|
+
border-radius: calc(var(--control-corner-radius, 4) * 1px);
|
|
589
|
+
font-family: inherit;
|
|
590
|
+
font-size: var(--type-ramp-base-font-size, 14px);
|
|
591
|
+
line-height: var(--type-ramp-base-line-height, 20px);
|
|
592
|
+
color: var(--neutral-foreground-rest, #242424);
|
|
593
|
+
cursor: pointer;
|
|
594
|
+
text-align: left;
|
|
595
|
+
transition: border-color 0.1s ease, background 0.1s ease, box-shadow 0.1s ease;
|
|
596
|
+
min-width: 200px;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.select-trigger:hover:not(:disabled) {
|
|
600
|
+
background: var(--neutral-fill-input-hover, #f5f5f5);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.select-trigger:focus-visible:not(:disabled),
|
|
604
|
+
.select-trigger.open:not(:disabled) {
|
|
605
|
+
/* Match fluent-text-field focus: accent-colored bottom edge via inset shadow
|
|
606
|
+
* so the underline thickens visually without shifting content by 1px. */
|
|
607
|
+
border-bottom-color: var(--accent-fill-rest, #0078d4);
|
|
608
|
+
box-shadow: inset 0 -1px 0 0 var(--accent-fill-rest, #0078d4);
|
|
609
|
+
outline: none;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.select-trigger.filled {
|
|
613
|
+
background: var(--neutral-fill-secondary-rest, #f5f5f5);
|
|
614
|
+
border: none;
|
|
615
|
+
border-bottom: 1px solid var(--neutral-stroke-rest, #d1d1d1);
|
|
616
|
+
border-radius: calc(var(--control-corner-radius, 4) * 1px) calc(var(--control-corner-radius, 4) * 1px) 0 0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
.select-trigger.filled:hover:not(:disabled) {
|
|
620
|
+
background: var(--neutral-fill-secondary-hover, #ebebeb);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.select-trigger.filled:focus-visible:not(:disabled),
|
|
624
|
+
.select-trigger.filled.open:not(:disabled) {
|
|
625
|
+
border-bottom-color: var(--accent-fill-rest, #0078d4);
|
|
626
|
+
box-shadow: 0 1px 0 0 var(--accent-fill-rest, #0078d4);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.select-trigger:disabled {
|
|
630
|
+
opacity: 0.4;
|
|
631
|
+
cursor: not-allowed;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.select-trigger-value {
|
|
635
|
+
flex: 1;
|
|
636
|
+
overflow: hidden;
|
|
637
|
+
white-space: nowrap;
|
|
638
|
+
text-overflow: ellipsis;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.select-trigger-value.placeholder {
|
|
642
|
+
color: var(--neutral-foreground-hint, #717171);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.select-trigger-indicator {
|
|
646
|
+
display: inline-flex;
|
|
647
|
+
align-items: center;
|
|
648
|
+
justify-content: center;
|
|
649
|
+
color: var(--neutral-foreground-hint, #717171);
|
|
650
|
+
flex-shrink: 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* Hidden mirror of options for trigger-text lookup (single mode). */
|
|
654
|
+
.select-options-source {
|
|
655
|
+
display: none;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/* ===== Dropdown listbox (single mode, portalled) ===== */
|
|
659
|
+
:global(.select-listbox-popover) {
|
|
660
|
+
display: flex;
|
|
661
|
+
flex-direction: column;
|
|
662
|
+
background: var(--neutral-layer-floating, #ffffff);
|
|
663
|
+
border: 1px solid var(--neutral-stroke-rest, #d1d1d1);
|
|
664
|
+
border-radius: calc(var(--control-corner-radius, 4) * 1px);
|
|
665
|
+
box-shadow: var(--elevation-shadow-flyout, 0 8px 16px rgba(0, 0, 0, 0.14), 0 0 2px rgba(0, 0, 0, 0.12));
|
|
666
|
+
padding: calc(var(--design-unit, 4) * 1px);
|
|
667
|
+
/* Cap to the available viewport space exposed by PositioningRegion.
|
|
668
|
+
* Fallback to 280px if the property isn't set. */
|
|
669
|
+
max-height: var(--available-height, 280px);
|
|
670
|
+
overflow-y: auto;
|
|
671
|
+
overscroll-behavior: contain;
|
|
672
|
+
box-sizing: border-box;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/* <fluent-option> defaults to inline-flex (sized to content). Inside our
|
|
676
|
+
* listbox we want stacked rows of equal width — keep flex so fluent-option's
|
|
677
|
+
* own internal layout still works, but stretch to full width and lock the
|
|
678
|
+
* flex item against shrinking so a fixed-height listbox doesn't compress
|
|
679
|
+
* rows together when content exceeds the cap. */
|
|
680
|
+
:global(.select-listbox-popover) :global(fluent-option),
|
|
681
|
+
.select-listbox-inline :global(fluent-option) {
|
|
682
|
+
display: flex;
|
|
683
|
+
width: 100%;
|
|
684
|
+
flex-shrink: 0;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/* Keyboard-cursor highlight (single mode). Distinct from hover and selected
|
|
688
|
+
* — driven by data-highlighted set in the highlightedIndex sync effect. */
|
|
689
|
+
:global(.select-listbox-popover) :global(fluent-option[data-highlighted]) {
|
|
690
|
+
background: var(--neutral-fill-stealth-hover, #f0f0f0);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* ===== Inline listbox (multi mode) ===== */
|
|
694
|
+
.select-listbox-inline {
|
|
695
|
+
display: flex;
|
|
696
|
+
flex-direction: column;
|
|
697
|
+
background: var(--neutral-fill-input-rest, #ffffff);
|
|
698
|
+
border: 1px solid var(--neutral-stroke-rest, #d1d1d1);
|
|
699
|
+
border-radius: calc(var(--control-corner-radius, 4) * 1px);
|
|
700
|
+
padding: calc(var(--design-unit, 4) * 1px);
|
|
701
|
+
overflow-y: auto;
|
|
702
|
+
overscroll-behavior: contain;
|
|
703
|
+
box-sizing: border-box;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.select-listbox-inline.filled {
|
|
707
|
+
background: var(--neutral-fill-secondary-rest, #f5f5f5);
|
|
708
|
+
border: none;
|
|
709
|
+
border-bottom: 1px solid var(--neutral-stroke-rest, #d1d1d1);
|
|
710
|
+
border-radius: calc(var(--control-corner-radius, 4) * 1px) calc(var(--control-corner-radius, 4) * 1px) 0 0;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.select-listbox-inline.disabled {
|
|
714
|
+
opacity: 0.4;
|
|
715
|
+
pointer-events: none;
|
|
716
|
+
}
|
|
717
|
+
</style>
|