svelte-fluentui 1.0.0-rc15 → 1.0.0-rc17

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.
Files changed (60) hide show
  1. package/README.md +37 -9
  2. package/dist/assets/styles/_variables.scss +14 -0
  3. package/dist/assets/styles/components/_field.scss +102 -0
  4. package/dist/assets/styles/components/_validation-summary.scss +43 -0
  5. package/dist/assets/styles/theme.scss +14 -0
  6. package/dist/components/AccordionItem.svelte +1 -1
  7. package/dist/components/Anchor.svelte +2 -1
  8. package/dist/components/Autocomplete.svelte +138 -48
  9. package/dist/components/Autocomplete.svelte.d.ts +2 -0
  10. package/dist/components/Badge.svelte +2 -1
  11. package/dist/components/Button.svelte +10 -2
  12. package/dist/components/Button.svelte.d.ts +3 -1
  13. package/dist/components/Calendar.svelte +39 -36
  14. package/dist/components/Calendar.svelte.d.ts +1 -0
  15. package/dist/components/Checkbox.svelte +3 -1
  16. package/dist/components/Dialog.svelte +4 -3
  17. package/dist/components/Field.svelte +76 -0
  18. package/dist/components/Field.svelte.d.ts +24 -0
  19. package/dist/components/GridCellEditor.svelte +20 -12
  20. package/dist/components/InputFile.svelte +4 -3
  21. package/dist/components/Listbox.svelte +2 -0
  22. package/dist/components/NumberField.svelte +1 -0
  23. package/dist/components/Option.svelte +2 -1
  24. package/dist/components/Paginator.svelte +9 -1
  25. package/dist/components/QuickGrid.svelte +3138 -2901
  26. package/dist/components/QuickGrid.svelte.d.ts +42 -10
  27. package/dist/components/SiteSettings.svelte +8 -11
  28. package/dist/components/Switch.svelte +1 -0
  29. package/dist/components/Tabs.svelte +1 -1
  30. package/dist/components/TextField.svelte +6 -2
  31. package/dist/components/TextField.svelte.d.ts +1 -0
  32. package/dist/components/Textarea.svelte +1 -0
  33. package/dist/components/TimePicker.svelte +5 -3
  34. package/dist/components/Toast.svelte +7 -0
  35. package/dist/components/ToastContainer.svelte +2 -1
  36. package/dist/components/Tooltip.svelte +1 -0
  37. package/dist/components/ValidationSummary.svelte +76 -0
  38. package/dist/components/ValidationSummary.svelte.d.ts +25 -0
  39. package/dist/components/layout/GridItem.svelte +1 -1
  40. package/dist/components/layout/MultiSplitter.svelte +1 -1
  41. package/dist/components/layout/MultiSplitterPane.svelte +8 -5
  42. package/dist/components/layout/Stack.svelte +3 -3
  43. package/dist/components/layout/TopNav.svelte +3 -3
  44. package/dist/components/layout/TopNav.svelte.d.ts +2 -3
  45. package/dist/components/nav/AppBar.svelte +1 -1
  46. package/dist/components/nav/NavGroup.svelte +1 -1
  47. package/dist/components/nav/NavItem.svelte +3 -2
  48. package/dist/components/nav/NavLink.svelte +18 -4
  49. package/dist/components/nav/NavLink.svelte.d.ts +3 -0
  50. package/dist/fluent-ui/calendar/range-of-dates.js +1 -1
  51. package/dist/index.d.ts +3 -0
  52. package/dist/index.js +2 -0
  53. package/dist/main.css +368 -0
  54. package/dist/main.css.map +1 -1
  55. package/dist/main.scss +3 -0
  56. package/dist/stores/settings.js +72 -1
  57. package/dist/stores/theme.js +3 -3
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +1 -1
package/README.md CHANGED
@@ -2,15 +2,43 @@
2
2
 
3
3
  A comprehensive Svelte wrapper library for Microsoft FluentUI web components (v2.6.x), providing a seamless way to use FluentUI components in Svelte applications.
4
4
 
5
- ## What's New in v1.0.0-rc15
6
-
7
- - **`QuickGrid` tree mode** — pass `treePathMember="path"` and mark one column with `isTree: true` to render hierarchical data with indentation + expand/collapse. Supports PostgreSQL ltree (`"1.2.3"`), POSIX (`"/1/2/3"`), and Windows (`"C:\foo\bar"`) path styles with auto-detection. Optional `treeLevelMember` / `treeParentMember` skip path parsing when those values are pre-computed in the database. `treeDataSorted` skips internal sort. `expandedPaths` is `$bindable`. `defaultExpandDepth` is interpreted relative to the dataset's shallowest level so subtree views always show their roots. Filter is ancestor-aware. Optional `treeDoubleClickBehavior="toggle"` lets users smash the row instead of aiming at the chevron
8
- - **`QuickGrid` per-column `filter` callback** — `(filterValue: string, row: T) => boolean | null` replaces the built-in substring match for one column. Use for numeric ranges, date ranges, regex, multi-field search. Returning `null` signals "input is syntactically incomplete" — the grid then ignores the filter (all rows pass) AND adds a `.invalid` class to the input so the user gets a red border instead of an empty grid
9
- - **`QuickGrid` grid-level `onfilterchange`** `(filters: Record<string, string>) => void` for server-side filtering. When set, internal filtering is fully bypassed; the grid still renders the inputs, fires this on every keystroke, and trusts the caller to update `items`
10
- - **`QuickGrid` `idMember` prop** — stable row identity. Drafts and invalid-cell markers now key by this value (or `treePathMember` in tree mode, or displayed-row index as a last-resort fallback that triggers a one-shot console warning) so they survive pagination, filter, sort, and tree expand/collapse re-orderings
11
- - **`QuickGrid` `columnMinWidth` prop** — default `min-width` for any column without its own. Stops content-sized columns from collapsing too far when paired with `fillerColumn`
12
- - **`QuickGrid` `fillerColumn` actually absorbs leftover space now** — was `width: auto` (a no-op for absorption), now `width: 100%`. Affects every demo using `fillerColumn`, not just tree
13
- - **BREAKING `QuickGrid` `invalidCells` shape: `rowIndex` `rowKey`** bindable `invalidCells: CellValidationState[]` now carries `rowKey: string` instead of `rowIndex: number`, matching the new stable-id keying. Migration: callers binding `invalidCells` need to read `c.rowKey` instead of `c.rowIndex`. The `onvalidationerror` callback's `detail.rowIndex` is unchanged
5
+ ## What's New in v1.0.0-rc17
6
+
7
+ - **`Field` component** — wraps any form control with label + hint + validation message + state border. `validationState: "none" | "warning" | "error" | "success"` drives both the message color and the bottom-border accent on the inner control via shadow parts. `hint` renders neutral helper text when there's no error to show. Matches FluentUI 2 / Blazor convention of factoring validation presentation out of individual controls
8
+ - **`ValidationSummary` component** — top-of-form panel listing all errors with clickable jump-to-field links. Takes a plain `errors: Record<string, string>` map, auto-hides when empty, renders as `role="alert"` for screen readers. Pairs with `<Field>` for submit-time validation flows
9
+ - **Field &amp; ValidationSummary demo page** at `/components/forms/field` covers all states, control types, live validation with touched-on-blur, horizontal orientation, and the submit-time summary pattern
10
+ - **Refactored `/applications/form-validation`** to use the new components ~80 lines of inline `<small class="field-error">` and hand-rolled summary panel removed; reads as a tutorial now
11
+ - **`QuickGrid` `Ctrl`+`→` / `Ctrl`+`←` tree expand-collapse in navigate mode** — keyboard-only tree traversal. `Ctrl`+`→` expands the focused row; `Ctrl`+`←` collapses it (or walks up to the nearest expanded ancestor and collapses that). Modifier-key shape avoids ambiguity with plain ArrowLeft/Right which navigate columns
12
+ - **`QuickGrid` navigate mode: all cells are now focusable, not only editable ones** — previously only editable cells got `tabindex=0`, so users couldn't land on read-only cells (like the tree column or parent rows in heterogeneous trees) and couldn't trigger `Ctrl`+arrow on them. Now every cell is focusable; Tab still walks through editable cells only (the productive Tab-through-fields UX). Focus indicator extended to all focused cells
13
+ - **`QuickGrid` Tab-while-editing now works in `dblclick` / `click` / `button` modes**only `navigate` mode had a Tab handler before; the others fell through to browser default and focus disappeared. Tab now commits + auto-opens the editor on the next editable cell (spreadsheet pattern)
14
+ - **`QuickGrid` mode-switcher on the editable-per-row-type demo** — `/components/quickgrid-tree` example gained a radio control to flip between `navigate`, `dblclick`, `click`, and `button` so you can verify keyboard behaviors across all modes
15
+ - **`QuickGrid` predefined context-menu types for tree expand / collapse** — `contextMenu={["expand-all", "collapse-all", "expand-tree", "collapse-tree"]}` wires common tree commands without writing handlers. `expand-all` / `collapse-all` operate on the right-clicked row's branch (file-explorer / IDE convention); `expand-tree` / `collapse-tree` operate on the entire dataset. Visibility gates auto-hide useless options (leaf rows don't show subtree commands, etc.). `ContextMenuItem.label` is now optional since predefined `type` supplies a default
16
+ - **Bug fix — Tab traversal correctly skips rows with zero editable columns** — the row-aware Tab promise in rc16 had a hole in heterogeneous trees where a row has *no* editable columns. New `findNextEditableCell` helper walks past such rows in both directions
17
+ - **Bug fix — Toast styles now actually ship in the bundle** — the `_toast.scss` partial existed and was complete (`.fluent-toast-container`, position variants, animations) but `main.scss` never `@use`d it, so every consumer got the toast DOM with zero styling: an unstyled box at top-left of the viewport instead of the styled popover at top-right. Affects every page using the toast service. Fixed by adding `@use "assets/styles/components/toast";` to `main.scss`
18
+ - **Form Validation demo page** — new `/applications/form-validation` walks through the common patterns: required &amp; format, cross-field (passwords match), numeric range with custom rules, debounced async availability check, conditional required, and submit-time error summary with jump-to-field links. Includes a tiny composable validator helper recipe for copy-paste
19
+
20
+ ## What's New in v1.0.0-rc16
21
+
22
+ ### QuickGrid
23
+ - **Per-row-type editability** — `column.isEditable` (renamed from `column.editable`) now accepts a `(row) => boolean` callback so heterogeneous trees can express "only some row types are editable" without the consumer hand-rolling click handlers. Two shapes: per-column (`isEditable: (row) => row.kind === "employee"` — different columns can be editable for different row types) and grid-level (`isRowEditable: (row) => boolean` — single predicate gating the whole row). Three-stage gate: master `editable` → `isRowEditable` → `column.isEditable`. Navigate-mode Tab traversal is row-aware so the cursor skips read-only cells correctly. New demo at `/components/quickgrid-tree` ("Editable per row type — teams vs employees")
24
+ - **`column.nowrap` prop** — body cells never wrap. Combine with `maxWidth` for ellipsis truncation, with `autoWidth` to size the column to `max(header, longest cell)`, or use alone for short identifiers like SKU/code columns. Demo on `/components/quickgrid` shows all three modes
25
+ - **BREAKING — `column.editable` renamed to `column.isEditable`** — boolean form is unchanged; the function form is additive. Migration: rename `editable: true` → `isEditable: true` on every column literal. Grid-level `editable` prop is unchanged
26
+ - **Component-prefixed CSS variables** — `--fluent-quickgrid-header-bg`, `-header-hover-bg`, `-header-sorted-bg`, `-stripe-bg`, `-row-hover-bg` decouple QuickGrid surfaces from the generic `--neutral-layer-*` palette. Themes that overrode `--neutral-layer-2` for navbar branding (e.g. DHL yellow) used to drag QuickGrid header + striped rows along at full saturation; consumers can now tint the grid independently
27
+ - **Sort indicator tracks accent** — the `▲` / `▼` triangle on the actively sorted column uses `var(--accent-fill-rest)` so theme switches propagate to it
28
+ - **Bug fix: body cells inherit width constraints** — `width` / `minWidth` / `maxWidth` / `columnMinWidth` were applied only to `<th>`, so a wide body cell could drag the column past the header's `max-width`. Now mirrored to `<td>` via `getColumnBodyStyle()`. Fixes the case where ellipsis wouldn't activate on `nowrap` columns
29
+
30
+ ### Autocomplete
31
+ - **`startIcon` and `endIcon` snippets** — decorative slots inside the input. `startIcon` renders before, `endIcon` renders after but yields to the auto-rendered clear button / loading spinner so a search icon swaps cleanly to a clear button on selection. New demo on `/components/autocomplete`
32
+ - **Chip-size CSS variables** — eight `--fluent-autocomplete-chip-*` tokens (font-size, line-height, padding, gap, max-width, etc.) drive both inline and external chip geometry, defaults in rem. Override globally via `:root { --fluent-autocomplete-chip-font-size: 0.75rem; }`
33
+ - **Inline chip size unified with external** — was 12px font inline / 14px font external; picking a value visually shrunk the chip. Now both use the same `--fluent-autocomplete-chip-font-size` token (default `0.875rem`)
34
+ - **Focus border follows FluentUI 2** — was painting all four sides accent + outer glow; now only the bottom edge turns accent (the standard FluentUI textfield convention). Matches the Blazor reference and avoids layout shift on focus
35
+ - **Bug fix: single-select replaces instead of appends** — `selectOption` was always appending, which silently wedged single-select mode after the second click (input went blank with two values held in state)
36
+ - **Bug fix: dropdown closes on outside scroll** — `PositioningRegion` doesn't re-anchor on scroll, so scrolling the page used to leave the popover floating detached from its anchor. Now a capture-phase scroll listener closes it when scrolling outside the input or popover surface
37
+
38
+ ### Tabs / Site settings
39
+ - **`Tabs` active tab text tracks accent** — `.fluent-tab.active` color changed to `var(--accent-fill-rest)` so the active label matches its bottom indicator instead of staying neutral gray
40
+ - **Site settings store applies to the document** — pushes every change to `document.documentElement` (`data-theme`, `dir`, `--accent-fill-*`, `--foreground-on-accent-*`, `--neutral-layer-1`, mirrored `--fluent-*` tokens). Theme mode `'system'` resolves via `matchMedia` and re-applies on OS-level theme changes. Foreground-on-accent is computed via W3C luminance so dark accents get white labels and light accents (PowerBI yellow) get black labels
41
+ - **`theme` store localStorage key namespaced** — `"theme"` → `"fluent-theme"` to prevent collisions with host applications using the same generic key (e.g. PureAdmin saves its theme name to `localStorage.theme`)
14
42
 
15
43
  ## Features
16
44
 
@@ -50,6 +50,13 @@ $footer-bg-light: $neutral-white !default;
50
50
  $footer-text-light: $neutral-gray-110 !default;
51
51
  $footer-border-light: $neutral-gray-40 !default;
52
52
 
53
+ // QuickGrid
54
+ $quickgrid-header-bg-light: $neutral-gray-20 !default;
55
+ $quickgrid-header-hover-bg-light: $neutral-gray-30 !default;
56
+ $quickgrid-header-sorted-bg-light: $neutral-gray-30 !default;
57
+ $quickgrid-stripe-bg-light: $neutral-gray-10 !default;
58
+ $quickgrid-row-hover-bg-light: $neutral-gray-30 !default;
59
+
53
60
  // ===== DARK THEME DEFAULTS =====
54
61
 
55
62
  // Background colors
@@ -94,6 +101,13 @@ $footer-bg-dark: $neutral-gray-150 !default;
94
101
  $footer-text-dark: $neutral-gray-20 !default;
95
102
  $footer-border-dark: $neutral-gray-100 !default;
96
103
 
104
+ // QuickGrid
105
+ $quickgrid-header-bg-dark: $neutral-gray-120 !default;
106
+ $quickgrid-header-hover-bg-dark: $neutral-gray-110 !default;
107
+ $quickgrid-header-sorted-bg-dark: $neutral-gray-110 !default;
108
+ $quickgrid-stripe-bg-dark: $neutral-gray-130 !default;
109
+ $quickgrid-row-hover-bg-dark: $neutral-gray-110 !default;
110
+
97
111
  // ===== COMPONENT-SPECIFIC VARIABLES =====
98
112
 
99
113
  // Spacing
@@ -0,0 +1,102 @@
1
+ /* ========================================
2
+ Field — label + control + hint + validation message
3
+ FluentUI 2 / Blazor convention: Field owns label, hint, and inline validation
4
+ message rendering. Inner control (TextField, NumberField, etc.) sits in the slot.
5
+ ======================================== */
6
+
7
+ .fluent-field {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 0.25rem;
11
+
12
+ // State color: per-data-state, fall back to error color so unrecognized
13
+ // states don't render unstyled. Components below read this var.
14
+ --fluent-field-state-color: transparent;
15
+
16
+ &--horizontal {
17
+ flex-direction: row;
18
+ flex-wrap: wrap;
19
+ align-items: flex-start;
20
+ gap: 0.5rem 1rem;
21
+
22
+ .fluent-field__label {
23
+ flex: 0 0 auto;
24
+ padding-top: 0.35rem; // align with input baseline-ish
25
+ margin-bottom: 0;
26
+ }
27
+ .fluent-field__control {
28
+ flex: 1 1 auto;
29
+ min-width: 0;
30
+ }
31
+ .fluent-field__hint,
32
+ .fluent-field__message {
33
+ flex-basis: 100%;
34
+ }
35
+ }
36
+
37
+ &__label {
38
+ display: block;
39
+ font-size: 0.875rem;
40
+ font-weight: 600;
41
+ line-height: 1.25;
42
+ color: var(--neutral-foreground-rest, #424242);
43
+ margin-bottom: 0;
44
+ }
45
+
46
+ &__required {
47
+ color: #c50f1f;
48
+ margin-left: 0.15rem;
49
+ font-weight: 700;
50
+ }
51
+
52
+ &__control {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.2rem;
56
+ min-width: 0;
57
+ }
58
+
59
+ &__hint {
60
+ display: block;
61
+ color: var(--neutral-foreground-hint, #707070);
62
+ font-size: 0.8rem;
63
+ line-height: 1.35;
64
+ }
65
+
66
+ &__message {
67
+ display: block;
68
+ font-size: 0.8rem;
69
+ line-height: 1.35;
70
+ color: var(--fluent-field-state-color);
71
+ }
72
+
73
+ // ===== States =====
74
+
75
+ &[data-state="error"] {
76
+ --fluent-field-state-color: #c50f1f;
77
+ }
78
+ &[data-state="warning"] {
79
+ --fluent-field-state-color: #c87018;
80
+ }
81
+ &[data-state="success"] {
82
+ --fluent-field-state-color: #107c10;
83
+ }
84
+
85
+ // Paint the inner Fluent web component's border to match the field state.
86
+ // The fluent-* web components expose `root` as the visible bordered box.
87
+ // FluentUI 2 paints only the bottom border on focus, so we mirror that
88
+ // convention for the validation accent rather than ringing all four sides.
89
+ &[data-state="error"],
90
+ &[data-state="warning"],
91
+ &[data-state="success"] {
92
+ fluent-text-field::part(root),
93
+ fluent-number-field::part(root),
94
+ fluent-search::part(root),
95
+ fluent-text-area::part(control),
96
+ fluent-select::part(control),
97
+ fluent-combobox::part(control) {
98
+ border-bottom-color: var(--fluent-field-state-color);
99
+ box-shadow: inset 0 -1px 0 0 var(--fluent-field-state-color);
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,43 @@
1
+ /* ========================================
2
+ Validation Summary — inline error panel listing all field errors
3
+ Pairs with <Field> for forms that defer all validation to submit.
4
+ ======================================== */
5
+
6
+ .fluent-validation-summary {
7
+ background: rgba(197, 15, 31, 0.08);
8
+ border-left: 3px solid #c50f1f;
9
+ padding: 0.6rem 0.9rem;
10
+ border-radius: 4px;
11
+ color: var(--neutral-foreground-rest, #424242);
12
+
13
+ &__title {
14
+ font-weight: 600;
15
+ margin-bottom: 0.4rem;
16
+ color: #c50f1f;
17
+ }
18
+
19
+ &__list {
20
+ margin: 0;
21
+ padding-left: 1.1rem;
22
+ list-style: disc;
23
+ }
24
+
25
+ &__item {
26
+ margin: 0.15rem 0;
27
+ line-height: 1.4;
28
+ }
29
+
30
+ &__link {
31
+ color: #c50f1f;
32
+ text-decoration: underline;
33
+ cursor: pointer;
34
+ }
35
+ &__link:hover {
36
+ text-decoration: none;
37
+ }
38
+ &__link:focus-visible {
39
+ outline: 2px solid var(--accent-fill-rest, #0078d4);
40
+ outline-offset: 2px;
41
+ border-radius: 2px;
42
+ }
43
+ }
@@ -52,6 +52,13 @@
52
52
  --fluent-footer-text: #{$footer-text-light};
53
53
  --fluent-footer-border: #{$footer-border-light};
54
54
 
55
+ // QuickGrid
56
+ --fluent-quickgrid-header-bg: #{$quickgrid-header-bg-light};
57
+ --fluent-quickgrid-header-hover-bg: #{$quickgrid-header-hover-bg-light};
58
+ --fluent-quickgrid-header-sorted-bg: #{$quickgrid-header-sorted-bg-light};
59
+ --fluent-quickgrid-stripe-bg: #{$quickgrid-stripe-bg-light};
60
+ --fluent-quickgrid-row-hover-bg: #{$quickgrid-row-hover-bg-light};
61
+
55
62
  // Component spacing
56
63
  --fluent-component-padding: #{$component-padding};
57
64
  --fluent-component-margin: #{$component-margin};
@@ -132,6 +139,13 @@
132
139
  --fluent-footer-bg: #{$footer-bg-dark};
133
140
  --fluent-footer-text: #{$footer-text-dark};
134
141
  --fluent-footer-border: #{$footer-border-dark};
142
+
143
+ // QuickGrid
144
+ --fluent-quickgrid-header-bg: #{$quickgrid-header-bg-dark};
145
+ --fluent-quickgrid-header-hover-bg: #{$quickgrid-header-hover-bg-dark};
146
+ --fluent-quickgrid-header-sorted-bg: #{$quickgrid-header-sorted-bg-dark};
147
+ --fluent-quickgrid-stripe-bg: #{$quickgrid-stripe-bg-dark};
148
+ --fluent-quickgrid-row-hover-bg: #{$quickgrid-row-hover-bg-dark};
135
149
  }
136
150
 
137
151
  [data-theme="dhl"] {
@@ -41,7 +41,7 @@
41
41
  // console.log("Accordion item change", ev)
42
42
  const target = ev.target as HTMLElement
43
43
 
44
- onchange?.(ev, target.attributes["expanded"]?.value === "")
44
+ onchange?.(ev, target.getAttribute("expanded") === "")
45
45
  }
46
46
  </script>
47
47
 
@@ -64,7 +64,8 @@
64
64
  }
65
65
  </script>
66
66
 
67
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
67
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
68
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
68
69
  <fluent-anchor
69
70
  bind:this={element}
70
71
  {id}
@@ -49,6 +49,12 @@
49
49
  headerContent?: Snippet
50
50
  footerContent?: Snippet
51
51
  optionTemplate?: Snippet<[OptionItem<T>]>
52
+ // Decorative slots inside the input. Render arbitrary content (typically icons or
53
+ // small buttons) at the start (left) or end (right) of the input. The auto-rendered
54
+ // clear button and loading spinner take precedence in the end slot — `endIcon`
55
+ // renders only when neither is active, so a search icon doesn't fight a clear button.
56
+ startIcon?: Snippet
57
+ endIcon?: Snippet
52
58
  class?: string
53
59
  style?: string
54
60
  initialSearchQuery?: string
@@ -89,6 +95,8 @@
89
95
  headerContent = undefined,
90
96
  footerContent = undefined,
91
97
  optionTemplate = undefined,
98
+ startIcon = undefined,
99
+ endIcon = undefined,
92
100
  class: className = "",
93
101
  style = "",
94
102
  initialSearchQuery = "",
@@ -204,16 +212,20 @@
204
212
  function selectOption(option: OptionItem) {
205
213
  if (disabled || readonly || option.disabled) return
206
214
 
207
- if (maxSelectedOptions && selectedOptions.length >= maxSelectedOptions) {
215
+ if (effectiveMultiple && maxSelectedOptions && selectedOptions.length >= maxSelectedOptions) {
208
216
  return
209
217
  }
210
218
 
211
- selectedOptions = [...selectedOptions, option.value]
219
+ // Single-select replaces the previous value; multi-select appends.
220
+ selectedOptions = effectiveMultiple
221
+ ? [...selectedOptions, option.value]
222
+ : [option.value]
212
223
  searchText = ""
213
224
  filteredOptions = []
214
225
  highlightedIndex = -1
215
226
 
216
- if (!keepOpen || (maxSelectedOptions && selectedOptions.length >= maxSelectedOptions)) {
227
+ // Always close after a single-select pick; multi-select honors keepOpen / max-cap.
228
+ if (!effectiveMultiple || !keepOpen || (maxSelectedOptions && selectedOptions.length >= maxSelectedOptions)) {
217
229
  closeDropdown()
218
230
  }
219
231
 
@@ -344,12 +356,31 @@
344
356
  }
345
357
  }
346
358
 
347
- // Add click outside listener
359
+ // Close dropdown on outside scroll. The options list itself is scrollable
360
+ // (max-height + overflow-y: auto) and is portaled out via PositioningRegion,
361
+ // so scrolling inside the listbox must not trigger this. Scrolling anywhere
362
+ // else on the page would otherwise leave the dropdown floating at its old
363
+ // anchor position, since PositioningRegion does not re-anchor on scroll.
364
+ function handleOutsideScroll(event: Event) {
365
+ const target = event.target as Node | null
366
+ if (!target) return
367
+ // Allow scroll inside our own anchor container (input + inline chips, etc.)
368
+ if (containerElement && containerElement.contains(target)) return
369
+ // Allow scroll inside the popover surface (options-list lives in a portal).
370
+ if (target instanceof Element && target.closest('.options-list, .positioning-region')) return
371
+ closeDropdown()
372
+ }
373
+
374
+ // Add click-outside and outside-scroll listeners
348
375
  $effect(() => {
349
376
  if (isOpen) {
350
377
  document.addEventListener("click", handleClickOutside)
378
+ // `capture: true` because most page scroll containers don't bubble
379
+ // scroll events to document; capture phase reliably catches them.
380
+ window.addEventListener("scroll", handleOutsideScroll, true)
351
381
  return () => {
352
382
  document.removeEventListener("click", handleClickOutside)
383
+ window.removeEventListener("scroll", handleOutsideScroll, true)
353
384
  }
354
385
  }
355
386
  })
@@ -446,7 +477,10 @@
446
477
  {/if}
447
478
 
448
479
  <!-- Input container -->
449
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
480
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
481
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
482
+ <!-- svelte-ignore a11y_role_has_required_aria_props -->
483
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
450
484
  <div
451
485
  class="autocomplete-input-container"
452
486
  class:inline-mode={tagsPosition === 'inline'}
@@ -460,6 +494,13 @@
460
494
  aria-expanded={isOpen}
461
495
  aria-haspopup="listbox"
462
496
  >
497
+ <!-- Start icon slot (e.g. search magnifying glass) -->
498
+ {#if startIcon}
499
+ <div class="input-start">
500
+ {@render startIcon()}
501
+ </div>
502
+ {/if}
503
+
463
504
  <!-- Tags INLINE (if tagsPosition === 'inline') -->
464
505
  {#if tagsPosition === 'inline' && showTags}
465
506
  {#each selectedOptions as value}
@@ -483,6 +524,7 @@
483
524
  {/if}
484
525
 
485
526
  <!-- Native input element -->
527
+ <!-- svelte-ignore a11y_autofocus -->
486
528
  <input
487
529
  type="text"
488
530
  class="autocomplete-native-input"
@@ -493,36 +535,42 @@
493
535
  readonly={readonly}
494
536
  {required}
495
537
  autofocus={autofocus}
496
- autocomplete={autocomplete || 'off'}
538
+ autocomplete={(autocomplete || 'off') as AutoFill}
497
539
  oninput={handleInput}
498
540
  onkeydown={handleKeyDown}
499
541
  onfocus={handleInputFocus}
500
542
  onblur={handleInputBlur}
501
543
  />
502
544
 
503
- <!-- End slot: clear button or loading indicator -->
504
- <div class="input-end">
505
- {#if hasSingleSelection && !disabled && !readonly}
506
- <button
507
- type="button"
508
- class="clear-button"
509
- onclick={clearSingleSelection}
510
- aria-label="Clear selection"
511
- title="Clear selection"
512
- >
513
- <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
514
- <path d="M2.09 2.22a.75.75 0 0 1 1.06-.13L6 4.94l2.85-2.85a.75.75 0 1 1 1.06 1.06L7.06 6l2.85 2.85a.75.75 0 1 1-1.06 1.06L6 7.06l-2.85 2.85a.75.75 0 0 1-1.06-1.06L4.94 6 2.09 3.15a.75.75 0 0 1-.13-1.06z"/>
515
- </svg>
516
- </button>
517
- {:else if showLoading}
518
- <div class="loading-indicator">
519
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="spinner">
520
- <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"/>
521
- <path d="M8 1a7 7 0 0 1 7 7" stroke="currentColor" stroke-width="2" fill="none"/>
522
- </svg>
523
- </div>
524
- {/if}
525
- </div>
545
+ <!-- End slot: clear button OR loading indicator OR custom endIcon (in that priority).
546
+ Only renders when something will show inside, so the `:has(.input-end)` padding
547
+ rule on the input doesn't reserve space for an empty slot. -->
548
+ {#if (hasSingleSelection && !disabled && !readonly) || showLoading || endIcon}
549
+ <div class="input-end">
550
+ {#if hasSingleSelection && !disabled && !readonly}
551
+ <button
552
+ type="button"
553
+ class="clear-button"
554
+ onclick={clearSingleSelection}
555
+ aria-label="Clear selection"
556
+ title="Clear selection"
557
+ >
558
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
559
+ <path d="M2.09 2.22a.75.75 0 0 1 1.06-.13L6 4.94l2.85-2.85a.75.75 0 1 1 1.06 1.06L7.06 6l2.85 2.85a.75.75 0 1 1-1.06 1.06L6 7.06l-2.85 2.85a.75.75 0 0 1-1.06-1.06L4.94 6 2.09 3.15a.75.75 0 0 1-.13-1.06z"/>
560
+ </svg>
561
+ </button>
562
+ {:else if showLoading}
563
+ <div class="loading-indicator">
564
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="spinner">
565
+ <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"/>
566
+ <path d="M8 1a7 7 0 0 1 7 7" stroke="currentColor" stroke-width="2" fill="none"/>
567
+ </svg>
568
+ </div>
569
+ {:else if endIcon}
570
+ {@render endIcon()}
571
+ {/if}
572
+ </div>
573
+ {/if}
526
574
  </div>
527
575
 
528
576
  <!-- Tags BELOW (if tagsPosition === 'below') -->
@@ -648,8 +696,12 @@
648
696
  }
649
697
 
650
698
  .autocomplete-input-container.inline-mode.focused:not(.disabled) {
651
- border-color: var(--accent-fill-rest, #0078d4);
652
- box-shadow: 0 0 0 1px var(--accent-fill-rest, #0078d4);
699
+ /* FluentUI 2 textfield focus: thin gray border on top/sides stays put,
700
+ bottom edge becomes accent + visually 2px thick. We use an inset box-shadow
701
+ to add the 1px instead of bumping border-bottom-width — that would shift
702
+ the input's content by 1px on focus. */
703
+ border-bottom-color: var(--accent-fill-rest, #0078d4);
704
+ box-shadow: inset 0 -1px 0 0 var(--accent-fill-rest, #0078d4);
653
705
  }
654
706
 
655
707
  .autocomplete-input-container.inline-mode.disabled {
@@ -679,18 +731,26 @@
679
731
  box-shadow: 0 1px 0 0 var(--accent-fill-rest, #0078d4);
680
732
  }
681
733
 
682
- /* ===== Inline Chips ===== */
734
+ /* ===== Inline Chips =====
735
+ Inline chips share the same height + typography as external chips so picking a value
736
+ doesn't visually shrink it on its way into the input. The only intentional difference
737
+ is the border: inline chips sit inside the input's own border so adding another would
738
+ double up; external chips are standalone elements and need their own.
739
+
740
+ Sizing tokens (`--fluent-autocomplete-chip-*`) drive both inline and external chip
741
+ geometry, so theme overrides apply everywhere chips render. Defaults are in rem so
742
+ chips scale with the user's root font-size. */
683
743
  .inline-chip {
684
744
  display: inline-flex;
685
745
  align-items: center;
686
- gap: 4px;
687
- padding: 2px 8px;
746
+ gap: var(--fluent-autocomplete-chip-gap, 0.25rem);
747
+ padding: var(--fluent-autocomplete-chip-padding-y, 0.125rem) var(--fluent-autocomplete-chip-padding-x, 0.5rem);
688
748
  background: var(--neutral-fill-secondary-rest, #f0f0f0);
689
749
  border-radius: calc(var(--control-corner-radius, 4) * 1px);
690
- font-size: 12px;
691
- line-height: 1.4;
750
+ font-size: var(--fluent-autocomplete-chip-font-size, 0.875rem);
751
+ line-height: var(--fluent-autocomplete-chip-line-height, 1.4);
692
752
  white-space: nowrap;
693
- max-width: 150px;
753
+ max-width: var(--fluent-autocomplete-chip-max-width, 9.375rem);
694
754
  color: var(--neutral-foreground-rest, #242424);
695
755
  }
696
756
 
@@ -705,10 +765,10 @@
705
765
  justify-content: center;
706
766
  background: transparent;
707
767
  border: none;
708
- padding: 2px;
768
+ padding: var(--fluent-autocomplete-chip-remove-padding, 0.125rem);
709
769
  cursor: pointer;
710
770
  color: var(--neutral-foreground-hint, #717171);
711
- border-radius: 2px;
771
+ border-radius: 0.125rem;
712
772
  flex-shrink: 0;
713
773
  transition: color 0.1s ease, background 0.1s ease;
714
774
  }
@@ -765,8 +825,9 @@
765
825
  }
766
826
 
767
827
  .autocomplete-input-container:not(.inline-mode) .autocomplete-native-input:focus {
768
- border-color: var(--accent-fill-rest, #0078d4);
769
- box-shadow: 0 0 0 1px var(--accent-fill-rest, #0078d4);
828
+ /* See comment on `.inline-mode.focused` — same FluentUI bottom-emphasis pattern. */
829
+ border-bottom-color: var(--accent-fill-rest, #0078d4);
830
+ box-shadow: inset 0 -1px 0 0 var(--accent-fill-rest, #0078d4);
770
831
  }
771
832
 
772
833
  .autocomplete-input-container:not(.inline-mode).filled .autocomplete-native-input {
@@ -776,12 +837,22 @@
776
837
  border-radius: calc(var(--control-corner-radius, 4) * 1px) calc(var(--control-corner-radius, 4) * 1px) 0 0;
777
838
  }
778
839
 
840
+ /* ===== Input Start Slot ===== */
841
+ .input-start {
842
+ flex-shrink: 0;
843
+ display: flex;
844
+ align-items: center;
845
+ padding-right: 4px;
846
+ color: var(--neutral-foreground-hint, #707070);
847
+ }
848
+
779
849
  /* ===== Input End Slot ===== */
780
850
  .input-end {
781
851
  flex-shrink: 0;
782
852
  display: flex;
783
853
  align-items: center;
784
854
  padding-left: 4px;
855
+ color: var(--neutral-foreground-hint, #707070);
785
856
  }
786
857
 
787
858
  /* Non-inline mode: position end slot inside input */
@@ -792,6 +863,22 @@
792
863
  transform: translateY(-50%);
793
864
  }
794
865
 
866
+ .autocomplete-input-container:not(.inline-mode) .input-start {
867
+ position: absolute;
868
+ left: 8px;
869
+ top: 50%;
870
+ transform: translateY(-50%);
871
+ }
872
+
873
+ /* Non-inline mode: pad the input so its text doesn't sit underneath the icons */
874
+ .autocomplete-input-container:not(.inline-mode):has(.input-start) .autocomplete-native-input {
875
+ padding-left: 32px;
876
+ }
877
+
878
+ .autocomplete-input-container:not(.inline-mode):has(.input-end) .autocomplete-native-input {
879
+ padding-right: 32px;
880
+ }
881
+
795
882
  /* ===== Selected Options Container (above/below modes) ===== */
796
883
  .selected-options {
797
884
  display: flex;
@@ -802,17 +889,20 @@
802
889
  overflow-y: auto;
803
890
  }
804
891
 
805
- /* External chips (above/below modes) - FluentUI Blazor style */
892
+ /* External chips (above/below modes) - FluentUI Blazor style.
893
+ Same `--fluent-autocomplete-chip-*` tokens as inline chips. Adds a 1px border
894
+ since these aren't sitting inside the input's own border. */
806
895
  .external-chip {
807
896
  display: inline-flex;
808
897
  align-items: center;
809
- gap: 4px;
810
- padding: 2px 8px;
898
+ gap: var(--fluent-autocomplete-chip-gap, 0.25rem);
899
+ padding: var(--fluent-autocomplete-chip-padding-y, 0.125rem) var(--fluent-autocomplete-chip-padding-x, 0.5rem);
811
900
  background: var(--neutral-fill-secondary-rest, #f5f5f5);
812
901
  color: var(--neutral-foreground-rest, #242424);
813
902
  border: 1px solid var(--neutral-stroke-rest, #d1d1d1);
814
903
  border-radius: calc(var(--control-corner-radius, 4) * 1px);
815
- font-size: 14px;
904
+ font-size: var(--fluent-autocomplete-chip-font-size, 0.875rem);
905
+ line-height: var(--fluent-autocomplete-chip-line-height, 1.4);
816
906
  font-weight: 400;
817
907
  white-space: nowrap;
818
908
  }
@@ -820,7 +910,7 @@
820
910
  .external-chip .chip-text {
821
911
  overflow: hidden;
822
912
  text-overflow: ellipsis;
823
- max-width: 200px;
913
+ max-width: var(--fluent-autocomplete-chip-text-max-width, 12.5rem);
824
914
  }
825
915
 
826
916
  .external-chip .chip-remove {
@@ -829,10 +919,10 @@
829
919
  justify-content: center;
830
920
  background: transparent;
831
921
  border: none;
832
- padding: 2px;
922
+ padding: var(--fluent-autocomplete-chip-remove-padding, 0.125rem);
833
923
  cursor: pointer;
834
924
  color: var(--error-foreground-rest, #c42b1c);
835
- border-radius: 2px;
925
+ border-radius: 0.125rem;
836
926
  }
837
927
 
838
928
  .external-chip .chip-remove:hover {
@@ -36,6 +36,8 @@ type Props<T = any> = {
36
36
  headerContent?: Snippet;
37
37
  footerContent?: Snippet;
38
38
  optionTemplate?: Snippet<[OptionItem<T>]>;
39
+ startIcon?: Snippet;
40
+ endIcon?: Snippet;
39
41
  class?: string;
40
42
  style?: string;
41
43
  initialSearchQuery?: string;
@@ -62,7 +62,8 @@
62
62
  })
63
63
  </script>
64
64
 
65
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
65
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
66
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
66
67
  <span
67
68
  class={classes}
68
69
  style={computedStyle || null}