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.
@@ -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 { fluentSelect, provideFluentDesignSystem } from "@fluentui/web-components";
3
- import type { SlotType } from "../types/index.js";
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(fluentSelect());
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
- position?: "above" | "below";
19
- multiple?: boolean;
20
- disabled?: boolean;
21
- appearance?: "outline" | "filled";
22
- required?: boolean;
23
- autofocus?: boolean;
24
- name?: string;
25
- value?: string;
26
- label?: string;
27
- ariaLabel?: string;
28
- title?: string;
29
- width?: string;
30
- height?: string;
31
- /** Max visible options for multiple select. When explicitly set, auto-calculates height based on option row height. */
32
- maxVisibleOptions?: number;
33
- labelTemplate?: SlotType;
34
- indicatorTemplate?: SlotType;
35
- children?: SlotType;
36
- onchange?: (detail: SelectChangeDetail) => void;
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 = undefined,
47
- appearance = undefined,
48
- required = undefined,
49
- autofocus = undefined,
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
- let selectElement: HTMLElement | undefined = $state();
65
- let calculatedHeight: string | undefined = $state();
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
- // Re-apply value when options are added asynchronously
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
- if (!selectElement || value === undefined || value === null) return;
72
-
73
- const observer = new MutationObserver(() => {
74
- if (value !== undefined && value !== null) {
75
- // Force fluent-select to re-evaluate the value
76
- const el = selectElement as any;
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
- observer.observe(selectElement, { childList: true, subtree: true });
153
+ const selectedSingle = $derived<string>(selectionState.value[0] ?? "")
84
154
 
85
- return () => observer.disconnect();
86
- });
155
+ // ---------- Open / close (single mode) ----------
156
+ let isOpen = $state(false)
87
157
 
88
- // Auto-calculate height for multiple select
158
+ // Allow external control via `open` prop (binds both ways).
89
159
  $effect(() => {
90
- if (multiple && selectElement && !height) {
91
- // Wait for options to render and get their computed height
92
- setTimeout(() => {
93
- const options = selectElement?.querySelectorAll('fluent-option');
94
- if (options && options.length > 0) {
95
- const firstOption = options[0] as HTMLElement;
96
- // Get computed height or use fallback
97
- const computedHeight = window.getComputedStyle(firstOption).height;
98
- const optionHeight = parseInt(computedHeight) || firstOption.offsetHeight || 32;
99
- // If maxVisibleOptions set, limit to that; otherwise show all
100
- const visibleCount = maxVisibleOptions !== undefined
101
- ? Math.min(options.length, maxVisibleOptions)
102
- : options.length;
103
- // Add padding for top/bottom of the select container (approx 2.5rem = 40px)
104
- const containerPadding = 40;
105
- calculatedHeight = `${visibleCount * optionHeight + containerPadding}px`;
106
- }
107
- }, 0);
108
- } else {
109
- calculatedHeight = undefined;
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
- // Whether we need a scroll container (maxVisibleOptions limits visible items)
114
- const needsScrollContainer = $derived(multiple && maxVisibleOptions !== undefined && !height);
115
-
116
- // Style for the scroll container (when needed)
117
- const scrollContainerStyle = $derived(() => {
118
- if (!needsScrollContainer || !calculatedHeight) return undefined;
119
- const styles: string[] = [];
120
- styles.push(`height: ${calculatedHeight}`);
121
- styles.push('overflow: auto');
122
- styles.push('overscroll-behavior: contain');
123
- styles.push('padding: 0px 0.1rem');
124
- styles.push('display: inline-block');
125
- if (width) styles.push(`width: ${width}`);
126
- return styles.join('; ');
127
- });
128
-
129
- // Compute inline style for fluent-select
130
- const computedStyle = $derived(() => {
131
- const styles: string[] = [];
132
- if (style) styles.push(style);
133
- // Width goes on container if using scroll container, otherwise on select
134
- if (width && !needsScrollContainer) styles.push(`width: ${width}`);
135
- // Height handling - only apply directly when not using scroll container
136
- if (height) {
137
- styles.push(`height: ${height}`);
138
- } else if (calculatedHeight && !needsScrollContainer) {
139
- styles.push(`height: ${calculatedHeight}`);
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
- return styles.join('; ') || undefined;
142
- });
337
+ }
143
338
 
144
- function handleChange(e: Event) {
145
- const target = e.target as HTMLSelectElement;
146
- value = target.value;
339
+ function handleTriggerKeydown(ev: KeyboardEvent) {
340
+ if (disabled) return
147
341
 
148
- // Find selected option and extract its data and display text
149
- const selectedOptionEl = selectElement?.querySelector(`fluent-option[value="${target.value}"]`) as HTMLElement | null;
150
- const contextData = selectedOptionEl?.dataset.optionContext;
151
- const data = contextData ? JSON.parse(contextData) : undefined;
152
- const selectedOption = selectedOptionEl?.textContent?.trim();
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
- onchange?.({ value: target.value, selectedOption, data });
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
- {#if needsScrollContainer}
159
- <div class="select-scroll-wrapper" style="display: inline-flex; flex-direction: column;">
160
- {#if label || labelTemplate}
161
- <label for={id} class="fluent-label">
162
- {#if label}
163
- {label}
164
- {/if}
165
- {#if labelTemplate}
166
- {@render labelTemplate?.()}
167
- {/if}
168
- </label>
169
- {/if}
170
- <div class="select-scroll-container" style={scrollContainerStyle()}>
171
- <!-- svelte-ignore a11y_autofocus -->
172
- <fluent-select
173
- bind:this={selectElement}
174
- {id}
175
- class={className}
176
- style={computedStyle() || style}
177
- {open}
178
- {position}
179
- {multiple}
180
- {disabled}
181
- {appearance}
182
- {required}
183
- {autofocus}
184
- {name}
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
- {#if label || labelTemplate}
203
- <label for={id} class="fluent-label">
204
- {#if label}
205
- {label}
206
- {/if}
207
- {#if labelTemplate}
208
- {@render labelTemplate?.()}
209
- {/if}
210
- </label>
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
- <fluent-select
214
- bind:this={selectElement}
510
+ <button
511
+ bind:this={triggerEl}
512
+ type="button"
215
513
  {id}
216
- class={className}
217
- style={computedStyle() || style}
218
- {open}
219
- {position}
220
- {multiple}
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
- aria-label={ariaLabel || label}
229
- onchange={handleChange}
522
+ class="select-trigger {appearance} {className}"
523
+ class:open={isOpen}
524
+ style={(triggerStyle() ? triggerStyle() + "; " : "") + style}
525
+ onclick={toggleDropdown}
526
+ onkeydown={handleTriggerKeydown}
230
527
  >
231
- {#if indicatorTemplate}
232
- <span slot="indicator">
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
- </span>
235
- {/if}
236
- {#if children}
237
- {@render children?.()}
238
- {/if}
239
- </fluent-select>
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>