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.
- package/README.md +37 -9
- package/dist/assets/styles/_variables.scss +14 -0
- package/dist/assets/styles/components/_field.scss +102 -0
- package/dist/assets/styles/components/_validation-summary.scss +43 -0
- package/dist/assets/styles/theme.scss +14 -0
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/Anchor.svelte +2 -1
- package/dist/components/Autocomplete.svelte +138 -48
- package/dist/components/Autocomplete.svelte.d.ts +2 -0
- package/dist/components/Badge.svelte +2 -1
- package/dist/components/Button.svelte +10 -2
- package/dist/components/Button.svelte.d.ts +3 -1
- package/dist/components/Calendar.svelte +39 -36
- package/dist/components/Calendar.svelte.d.ts +1 -0
- package/dist/components/Checkbox.svelte +3 -1
- package/dist/components/Dialog.svelte +4 -3
- package/dist/components/Field.svelte +76 -0
- package/dist/components/Field.svelte.d.ts +24 -0
- package/dist/components/GridCellEditor.svelte +20 -12
- package/dist/components/InputFile.svelte +4 -3
- package/dist/components/Listbox.svelte +2 -0
- package/dist/components/NumberField.svelte +1 -0
- package/dist/components/Option.svelte +2 -1
- package/dist/components/Paginator.svelte +9 -1
- package/dist/components/QuickGrid.svelte +3138 -2901
- package/dist/components/QuickGrid.svelte.d.ts +42 -10
- package/dist/components/SiteSettings.svelte +8 -11
- package/dist/components/Switch.svelte +1 -0
- package/dist/components/Tabs.svelte +1 -1
- package/dist/components/TextField.svelte +6 -2
- package/dist/components/TextField.svelte.d.ts +1 -0
- package/dist/components/Textarea.svelte +1 -0
- package/dist/components/TimePicker.svelte +5 -3
- package/dist/components/Toast.svelte +7 -0
- package/dist/components/ToastContainer.svelte +2 -1
- package/dist/components/Tooltip.svelte +1 -0
- package/dist/components/ValidationSummary.svelte +76 -0
- package/dist/components/ValidationSummary.svelte.d.ts +25 -0
- package/dist/components/layout/GridItem.svelte +1 -1
- package/dist/components/layout/MultiSplitter.svelte +1 -1
- package/dist/components/layout/MultiSplitterPane.svelte +8 -5
- package/dist/components/layout/Stack.svelte +3 -3
- package/dist/components/layout/TopNav.svelte +3 -3
- package/dist/components/layout/TopNav.svelte.d.ts +2 -3
- package/dist/components/nav/AppBar.svelte +1 -1
- package/dist/components/nav/NavGroup.svelte +1 -1
- package/dist/components/nav/NavItem.svelte +3 -2
- package/dist/components/nav/NavLink.svelte +18 -4
- package/dist/components/nav/NavLink.svelte.d.ts +3 -0
- package/dist/fluent-ui/calendar/range-of-dates.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/main.css +368 -0
- package/dist/main.css.map +1 -1
- package/dist/main.scss +3 -0
- package/dist/stores/settings.js +72 -1
- package/dist/stores/theme.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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-
|
|
6
|
-
|
|
7
|
-
- **`
|
|
8
|
-
- **`
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
- **`QuickGrid` `
|
|
12
|
-
- **`QuickGrid`
|
|
13
|
-
-
|
|
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 & 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 & 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"] {
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
}
|
|
65
65
|
</script>
|
|
66
66
|
|
|
67
|
-
<!-- svelte-ignore a11y_no_static_element_interactions
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
652
|
-
|
|
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:
|
|
687
|
-
padding:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
769
|
-
|
|
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:
|
|
810
|
-
padding:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
925
|
+
border-radius: 0.125rem;
|
|
836
926
|
}
|
|
837
927
|
|
|
838
928
|
.external-chip .chip-remove:hover {
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
})
|
|
63
63
|
</script>
|
|
64
64
|
|
|
65
|
-
<!-- svelte-ignore a11y_no_static_element_interactions
|
|
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}
|