@f-ewald/components 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/custom-elements.json +2329 -301
- package/dist/form-select.d.ts +5 -4
- package/dist/form-select.d.ts.map +1 -1
- package/dist/form-select.js +6 -5
- package/dist/form-select.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/kanban-board.d.ts +110 -0
- package/dist/kanban-board.d.ts.map +1 -0
- package/dist/kanban-board.js +619 -0
- package/dist/kanban-board.js.map +1 -0
- package/dist/kanban-card.d.ts +35 -0
- package/dist/kanban-card.d.ts.map +1 -0
- package/dist/kanban-card.js +163 -0
- package/dist/kanban-card.js.map +1 -0
- package/dist/kanban-column.d.ts +30 -0
- package/dist/kanban-column.d.ts.map +1 -0
- package/dist/kanban-column.js +166 -0
- package/dist/kanban-column.js.map +1 -0
- package/dist/multi-select.d.ts +142 -0
- package/dist/multi-select.d.ts.map +1 -0
- package/dist/multi-select.js +1304 -0
- package/dist/multi-select.js.map +1 -0
- package/dist/tokens.css +3 -0
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +2 -0
- package/dist/tokens.js.map +1 -1
- package/docs/design-language.md +8 -0
- package/docs/form-select.md +5 -4
- package/docs/kanban-board.md +96 -0
- package/docs/kanban-card.md +56 -0
- package/docs/kanban-column.md +58 -0
- package/docs/multi-select.md +120 -0
- package/llms.txt +182 -4
- package/package.json +1 -1
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { LitElement, css, html, nothing, } from "lit";
|
|
8
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
9
|
+
import { iconCheckCircle, iconChevronRight, iconX } from "./icons.js";
|
|
10
|
+
import { tokens } from "./tokens.js";
|
|
11
|
+
let instanceCount = 0;
|
|
12
|
+
/**
|
|
13
|
+
* A form-associated multi-select: a trigger showing a compact summary of the
|
|
14
|
+
* current selection, opening a multi-selectable listbox popover, with an
|
|
15
|
+
* optional removable-chip list of the chosen values. A drop-in generic
|
|
16
|
+
* replacement for a native `<select multiple>` — set `name` on the element
|
|
17
|
+
* itself and each
|
|
18
|
+
* selected value is submitted as a repeated `name=value` entry, matching native
|
|
19
|
+
* multiple-select semantics; `new FormData(form).getAll(name)` and
|
|
20
|
+
* `form.reset()` work unchanged.
|
|
21
|
+
*
|
|
22
|
+
* The trigger visually follows `form-select`: a `2rem` field showing the
|
|
23
|
+
* `placeholder` when empty, the single selected label when one value is chosen,
|
|
24
|
+
* and `N selected` when several are. Set `show-chips` to additionally render
|
|
25
|
+
* the selected values as compact chips below the trigger, each with an
|
|
26
|
+
* accessible `32px` remove control; chips are off by default and the region
|
|
27
|
+
* reserves no space while nothing is selected. Like `form-select`, the host is
|
|
28
|
+
* `display: block` and fills its container by default; constrain the host
|
|
29
|
+
* (`multi-select { display: inline-block; }`) to shrink it to its content
|
|
30
|
+
* instead.
|
|
31
|
+
*
|
|
32
|
+
* Set `searchable` to replace the button trigger with an editable combobox that
|
|
33
|
+
* infix-filters the predefined options by case-insensitive label. Typed text is
|
|
34
|
+
* only a query: `values` changes exclusively when options are toggled, and an
|
|
35
|
+
* uncommitted query is discarded when the list closes.
|
|
36
|
+
*
|
|
37
|
+
* The `variant` chooses between two presentations, and `searchable` applies to
|
|
38
|
+
* both:
|
|
39
|
+
*
|
|
40
|
+
* - `"dropdown"` (default) is the popover form described above: a compact
|
|
41
|
+
* trigger (button, or search combobox when `searchable`) that opens a
|
|
42
|
+
* multi-selectable listbox on demand, closes on outside click / Escape, and
|
|
43
|
+
* shows a chevron.
|
|
44
|
+
* - `"list"` renders the options as a persistently visible, bordered surface
|
|
45
|
+
* with no popover, no chevron, and no `open` host state; it never registers
|
|
46
|
+
* outside-click listeners. Its scroll viewport is sized to roughly
|
|
47
|
+
* `visibleRows` `2rem` rows (see `visible-rows`). When not `searchable` the
|
|
48
|
+
* `listbox` itself is focusable and drives Arrow/Home/End/Enter/Space keyboard
|
|
49
|
+
* navigation via `aria-activedescendant`; when `searchable` a `2rem` search
|
|
50
|
+
* field sits above the list and owns navigation, and Escape only clears the
|
|
51
|
+
* query instead of hiding the list.
|
|
52
|
+
*
|
|
53
|
+
* @element multi-select
|
|
54
|
+
* @fires change - Fired with `{ values: string[] }` (a copied array) when a
|
|
55
|
+
* value is added or removed through the UI. Programmatic `values` assignments
|
|
56
|
+
* do not fire it.
|
|
57
|
+
*/
|
|
58
|
+
let MultiSelect = class MultiSelect extends LitElement {
|
|
59
|
+
constructor() {
|
|
60
|
+
super(...arguments);
|
|
61
|
+
/** The full list of selectable options. */
|
|
62
|
+
this.options = [];
|
|
63
|
+
/**
|
|
64
|
+
* The currently selected values. Programmatic assignments are deduplicated
|
|
65
|
+
* while preserving order and never fire `change`.
|
|
66
|
+
*/
|
|
67
|
+
this.values = [];
|
|
68
|
+
/** Form control name; each selected value submits under it. */
|
|
69
|
+
this.name = "";
|
|
70
|
+
/** Accessible label applied to the trigger. */
|
|
71
|
+
this.label = "";
|
|
72
|
+
/** Text shown on the trigger when nothing is selected. */
|
|
73
|
+
this.placeholder = "Select options";
|
|
74
|
+
/** Disables the whole control, preventing the popover from opening. */
|
|
75
|
+
this.disabled = false;
|
|
76
|
+
/** Marks the control as required for native form validation. */
|
|
77
|
+
this.required = false;
|
|
78
|
+
/**
|
|
79
|
+
* Enables editable, case-insensitive infix filtering by option label.
|
|
80
|
+
* Typed text never becomes a selected value.
|
|
81
|
+
*/
|
|
82
|
+
this.searchable = false;
|
|
83
|
+
/** Maximum number of selectable values; `0` (default) means unlimited. */
|
|
84
|
+
this.max = 0;
|
|
85
|
+
/**
|
|
86
|
+
* Presentation variant. `"dropdown"` (default) opens a popover listbox;
|
|
87
|
+
* `"list"` renders a persistently visible, bordered list surface. Reflected
|
|
88
|
+
* so consumers can style by `[variant="list"]`.
|
|
89
|
+
*/
|
|
90
|
+
this.variant = "dropdown";
|
|
91
|
+
/**
|
|
92
|
+
* Number of `2rem` rows the `list` variant's scroll viewport shows before it
|
|
93
|
+
* scrolls, mirroring a native `<select size>`. Normalized to an integer of at
|
|
94
|
+
* least `1` (default `5`); ignored by the `dropdown` variant.
|
|
95
|
+
*/
|
|
96
|
+
this.visibleRows = 5;
|
|
97
|
+
/**
|
|
98
|
+
* When true, the selected values are also rendered as removable chips below
|
|
99
|
+
* the trigger, each with an accessible `32px` remove control. Off by default:
|
|
100
|
+
* the trigger already summarizes the selection, and values can be toggled off
|
|
101
|
+
* in the listbox. Applies to both variants.
|
|
102
|
+
*/
|
|
103
|
+
this.showChips = false;
|
|
104
|
+
this._open = false;
|
|
105
|
+
this._activeIndex = -1;
|
|
106
|
+
this._query = null;
|
|
107
|
+
this._formDisabled = false;
|
|
108
|
+
this.#internals = this.attachInternals();
|
|
109
|
+
this.#defaultValues = null;
|
|
110
|
+
this.#listboxPointerDown = false;
|
|
111
|
+
this.#suppressNextBlur = false;
|
|
112
|
+
this.#restoringSearchFocus = false;
|
|
113
|
+
this.#searchSelection = null;
|
|
114
|
+
this.#isComposing = false;
|
|
115
|
+
this.#compositionJustEnded = false;
|
|
116
|
+
this.#compositionEndTimer = null;
|
|
117
|
+
this.#blurTimer = null;
|
|
118
|
+
this.#listboxId = `multi-select-listbox-${++instanceCount}`;
|
|
119
|
+
this.#onWindowMousedown = (e) => {
|
|
120
|
+
if (!e.composedPath().includes(this))
|
|
121
|
+
this.#close();
|
|
122
|
+
};
|
|
123
|
+
this.#onFocusOut = (e) => {
|
|
124
|
+
if (this.#listboxPointerDown || this.#suppressNextBlur)
|
|
125
|
+
return;
|
|
126
|
+
const next = e.relatedTarget;
|
|
127
|
+
// Focus leaving to body/null (e.g. a pointer press on a non-focusable
|
|
128
|
+
// element inside the component) is handled by the outside-mousedown
|
|
129
|
+
// listener; only a genuine Tab-out to another element closes here.
|
|
130
|
+
if (!(next instanceof Node))
|
|
131
|
+
return;
|
|
132
|
+
if (this === next || this.renderRoot.contains(next))
|
|
133
|
+
return;
|
|
134
|
+
this.#close();
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
static { this.formAssociated = true; }
|
|
138
|
+
static { this.styles = [
|
|
139
|
+
tokens,
|
|
140
|
+
css `
|
|
141
|
+
:host {
|
|
142
|
+
display: block;
|
|
143
|
+
position: relative;
|
|
144
|
+
font-family: var(
|
|
145
|
+
--ui-font,
|
|
146
|
+
ui-sans-serif,
|
|
147
|
+
system-ui,
|
|
148
|
+
sans-serif,
|
|
149
|
+
"Apple Color Emoji",
|
|
150
|
+
"Segoe UI Emoji",
|
|
151
|
+
"Segoe UI Symbol",
|
|
152
|
+
"Noto Color Emoji"
|
|
153
|
+
);
|
|
154
|
+
font-size: var(--ui-font-size-sm, 0.75rem);
|
|
155
|
+
}
|
|
156
|
+
.control {
|
|
157
|
+
position: relative;
|
|
158
|
+
}
|
|
159
|
+
button.trigger {
|
|
160
|
+
display: flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
justify-content: space-between;
|
|
163
|
+
gap: 0.25rem;
|
|
164
|
+
width: 100%;
|
|
165
|
+
height: 2rem;
|
|
166
|
+
box-sizing: border-box;
|
|
167
|
+
font-family: var(
|
|
168
|
+
--ui-font,
|
|
169
|
+
ui-sans-serif,
|
|
170
|
+
system-ui,
|
|
171
|
+
sans-serif,
|
|
172
|
+
"Apple Color Emoji",
|
|
173
|
+
"Segoe UI Emoji",
|
|
174
|
+
"Segoe UI Symbol",
|
|
175
|
+
"Noto Color Emoji"
|
|
176
|
+
);
|
|
177
|
+
font-size: var(--ui-font-size-sm, 0.75rem);
|
|
178
|
+
font-weight: var(--ui-font-weight-medium, 500);
|
|
179
|
+
line-height: var(--ui-line-height-tight, 1.25);
|
|
180
|
+
color: var(--ui-text, #0f172a);
|
|
181
|
+
background: var(--ui-surface, #ffffff);
|
|
182
|
+
border: 1px solid var(--ui-border, #e2e8f0);
|
|
183
|
+
border-radius: var(--ui-radius-sm, 0.25rem);
|
|
184
|
+
padding: 0.5rem 0.75rem;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
}
|
|
187
|
+
button.trigger:hover:not(:disabled) {
|
|
188
|
+
background: var(--ui-surface-muted, #f8fafc);
|
|
189
|
+
}
|
|
190
|
+
button.trigger:disabled {
|
|
191
|
+
cursor: not-allowed;
|
|
192
|
+
opacity: 0.6;
|
|
193
|
+
}
|
|
194
|
+
button.trigger:focus-visible {
|
|
195
|
+
outline: none;
|
|
196
|
+
border-color: var(--ui-primary, #4f46e5);
|
|
197
|
+
box-shadow: var(--ui-focus-ring, 0 0 0 3px rgb(79 70 229 / 0.35));
|
|
198
|
+
}
|
|
199
|
+
.trigger-label {
|
|
200
|
+
min-width: 0;
|
|
201
|
+
overflow: hidden;
|
|
202
|
+
text-overflow: ellipsis;
|
|
203
|
+
white-space: nowrap;
|
|
204
|
+
}
|
|
205
|
+
.trigger-label.placeholder {
|
|
206
|
+
color: var(--ui-text-muted, #64748b);
|
|
207
|
+
}
|
|
208
|
+
.search-trigger {
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
justify-content: space-between;
|
|
212
|
+
gap: 0.25rem;
|
|
213
|
+
width: 100%;
|
|
214
|
+
height: 2rem;
|
|
215
|
+
box-sizing: border-box;
|
|
216
|
+
color: var(--ui-text, #0f172a);
|
|
217
|
+
background: var(--ui-surface, #ffffff);
|
|
218
|
+
border: 1px solid var(--ui-border, #e2e8f0);
|
|
219
|
+
border-radius: var(--ui-radius-sm, 0.25rem);
|
|
220
|
+
cursor: text;
|
|
221
|
+
}
|
|
222
|
+
.search-trigger:hover:not(.disabled) {
|
|
223
|
+
background: var(--ui-surface-muted, #f8fafc);
|
|
224
|
+
}
|
|
225
|
+
.search-trigger:not(.disabled):focus-within {
|
|
226
|
+
border-color: var(--ui-primary, #4f46e5);
|
|
227
|
+
box-shadow: var(--ui-focus-ring, 0 0 0 3px rgb(79 70 229 / 0.35));
|
|
228
|
+
}
|
|
229
|
+
.search-trigger.disabled {
|
|
230
|
+
cursor: not-allowed;
|
|
231
|
+
opacity: 0.6;
|
|
232
|
+
}
|
|
233
|
+
input.search-input {
|
|
234
|
+
min-width: 0;
|
|
235
|
+
flex: 1;
|
|
236
|
+
box-sizing: border-box;
|
|
237
|
+
padding: 0.5rem 0 0.5rem 0.75rem;
|
|
238
|
+
color: inherit;
|
|
239
|
+
background: transparent;
|
|
240
|
+
border: 0;
|
|
241
|
+
outline: 0;
|
|
242
|
+
font-family: var(
|
|
243
|
+
--ui-font,
|
|
244
|
+
ui-sans-serif,
|
|
245
|
+
system-ui,
|
|
246
|
+
sans-serif,
|
|
247
|
+
"Apple Color Emoji",
|
|
248
|
+
"Segoe UI Emoji",
|
|
249
|
+
"Segoe UI Symbol",
|
|
250
|
+
"Noto Color Emoji"
|
|
251
|
+
);
|
|
252
|
+
font-size: var(--ui-font-size-sm, 0.75rem);
|
|
253
|
+
font-weight: var(--ui-font-weight-medium, 500);
|
|
254
|
+
line-height: var(--ui-line-height-tight, 1.25);
|
|
255
|
+
}
|
|
256
|
+
input.search-input::placeholder {
|
|
257
|
+
color: var(--ui-text-muted, #64748b);
|
|
258
|
+
font-weight: var(--ui-font-weight-medium, 500);
|
|
259
|
+
opacity: 1;
|
|
260
|
+
}
|
|
261
|
+
.chevron {
|
|
262
|
+
display: flex;
|
|
263
|
+
flex: 0 0 auto;
|
|
264
|
+
margin-right: 0.75rem;
|
|
265
|
+
color: var(--ui-text-muted, #64748b);
|
|
266
|
+
pointer-events: none;
|
|
267
|
+
transform: rotate(90deg);
|
|
268
|
+
transition: transform 150ms ease;
|
|
269
|
+
}
|
|
270
|
+
button.trigger .chevron {
|
|
271
|
+
margin-right: 0;
|
|
272
|
+
}
|
|
273
|
+
:host([open]) .chevron {
|
|
274
|
+
transform: rotate(-90deg);
|
|
275
|
+
}
|
|
276
|
+
ul.options {
|
|
277
|
+
position: absolute;
|
|
278
|
+
top: 100%;
|
|
279
|
+
left: 0;
|
|
280
|
+
z-index: 10;
|
|
281
|
+
min-width: 100%;
|
|
282
|
+
max-height: 40vh;
|
|
283
|
+
overflow-y: auto;
|
|
284
|
+
scrollbar-gutter: stable;
|
|
285
|
+
margin: 0.25rem 0 0;
|
|
286
|
+
padding: 0.25rem 0;
|
|
287
|
+
list-style: none;
|
|
288
|
+
white-space: nowrap;
|
|
289
|
+
color: var(--ui-text, #0f172a);
|
|
290
|
+
background: var(--ui-surface, #ffffff);
|
|
291
|
+
border: 1px solid var(--ui-border, #e2e8f0);
|
|
292
|
+
border-radius: var(--ui-radius-sm, 0.25rem);
|
|
293
|
+
box-shadow: var(--ui-shadow, 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1));
|
|
294
|
+
}
|
|
295
|
+
ul.options::-webkit-scrollbar {
|
|
296
|
+
width: 0.75rem;
|
|
297
|
+
}
|
|
298
|
+
ul.options::-webkit-scrollbar-thumb {
|
|
299
|
+
background: var(--ui-border, #e2e8f0);
|
|
300
|
+
border: 0.25rem solid transparent;
|
|
301
|
+
border-radius: 999px;
|
|
302
|
+
background-clip: padding-box;
|
|
303
|
+
}
|
|
304
|
+
.list-control {
|
|
305
|
+
display: flex;
|
|
306
|
+
flex-direction: column;
|
|
307
|
+
}
|
|
308
|
+
.search-field {
|
|
309
|
+
display: flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
width: 100%;
|
|
312
|
+
height: 2rem;
|
|
313
|
+
box-sizing: border-box;
|
|
314
|
+
margin-bottom: 0.25rem;
|
|
315
|
+
color: var(--ui-text, #0f172a);
|
|
316
|
+
background: var(--ui-surface, #ffffff);
|
|
317
|
+
border: 1px solid var(--ui-border, #e2e8f0);
|
|
318
|
+
border-radius: var(--ui-radius-sm, 0.25rem);
|
|
319
|
+
cursor: text;
|
|
320
|
+
}
|
|
321
|
+
.search-field:not(.disabled):focus-within {
|
|
322
|
+
border-color: var(--ui-primary, #4f46e5);
|
|
323
|
+
box-shadow: var(--ui-focus-ring, 0 0 0 3px rgb(79 70 229 / 0.35));
|
|
324
|
+
}
|
|
325
|
+
.search-field.disabled {
|
|
326
|
+
cursor: not-allowed;
|
|
327
|
+
opacity: 0.6;
|
|
328
|
+
}
|
|
329
|
+
input.list-search {
|
|
330
|
+
padding-right: 0.75rem;
|
|
331
|
+
}
|
|
332
|
+
ul.options.persistent {
|
|
333
|
+
position: static;
|
|
334
|
+
z-index: auto;
|
|
335
|
+
min-width: 0;
|
|
336
|
+
max-height: none;
|
|
337
|
+
margin: 0;
|
|
338
|
+
box-sizing: border-box;
|
|
339
|
+
border-radius: var(--ui-radius, 0.5rem);
|
|
340
|
+
box-shadow: none;
|
|
341
|
+
}
|
|
342
|
+
ul.options.persistent.disabled {
|
|
343
|
+
cursor: not-allowed;
|
|
344
|
+
opacity: 0.6;
|
|
345
|
+
pointer-events: none;
|
|
346
|
+
}
|
|
347
|
+
ul.options.persistent:focus-visible {
|
|
348
|
+
outline: none;
|
|
349
|
+
border-color: var(--ui-primary, #4f46e5);
|
|
350
|
+
box-shadow: var(--ui-focus-ring, 0 0 0 3px rgb(79 70 229 / 0.35));
|
|
351
|
+
}
|
|
352
|
+
li {
|
|
353
|
+
display: flex;
|
|
354
|
+
align-items: center;
|
|
355
|
+
gap: 0.5rem;
|
|
356
|
+
padding: 0.5rem 0.75rem;
|
|
357
|
+
cursor: pointer;
|
|
358
|
+
}
|
|
359
|
+
li.active,
|
|
360
|
+
li:hover:not([aria-disabled="true"]) {
|
|
361
|
+
background: var(--ui-surface-muted, #f8fafc);
|
|
362
|
+
}
|
|
363
|
+
li[aria-selected="true"] {
|
|
364
|
+
font-weight: var(--ui-font-weight-semibold, 600);
|
|
365
|
+
color: var(--ui-primary, #4f46e5);
|
|
366
|
+
}
|
|
367
|
+
li[aria-disabled="true"] {
|
|
368
|
+
cursor: not-allowed;
|
|
369
|
+
color: var(--ui-text-muted, #64748b);
|
|
370
|
+
opacity: 0.6;
|
|
371
|
+
}
|
|
372
|
+
.check {
|
|
373
|
+
display: inline-flex;
|
|
374
|
+
flex: 0 0 auto;
|
|
375
|
+
width: 14px;
|
|
376
|
+
height: 14px;
|
|
377
|
+
align-items: center;
|
|
378
|
+
justify-content: center;
|
|
379
|
+
color: var(--ui-primary, #4f46e5);
|
|
380
|
+
}
|
|
381
|
+
.check.hidden {
|
|
382
|
+
visibility: hidden;
|
|
383
|
+
}
|
|
384
|
+
.check > svg {
|
|
385
|
+
width: 100%;
|
|
386
|
+
height: 100%;
|
|
387
|
+
}
|
|
388
|
+
.option-content {
|
|
389
|
+
display: inline-flex;
|
|
390
|
+
min-width: 0;
|
|
391
|
+
flex: 1;
|
|
392
|
+
align-items: center;
|
|
393
|
+
gap: 0.5rem;
|
|
394
|
+
}
|
|
395
|
+
.option-icon {
|
|
396
|
+
display: inline-flex;
|
|
397
|
+
width: var(--multi-select-icon-size);
|
|
398
|
+
height: var(--multi-select-icon-size);
|
|
399
|
+
flex: 0 0 var(--multi-select-icon-size);
|
|
400
|
+
align-items: center;
|
|
401
|
+
justify-content: center;
|
|
402
|
+
}
|
|
403
|
+
.option-icon > svg {
|
|
404
|
+
width: 100%;
|
|
405
|
+
height: 100%;
|
|
406
|
+
}
|
|
407
|
+
.option-label {
|
|
408
|
+
min-width: 0;
|
|
409
|
+
overflow: hidden;
|
|
410
|
+
text-overflow: ellipsis;
|
|
411
|
+
white-space: nowrap;
|
|
412
|
+
}
|
|
413
|
+
li.no-options {
|
|
414
|
+
display: block;
|
|
415
|
+
padding: 0.5rem 0.75rem;
|
|
416
|
+
color: var(--ui-text-muted, #64748b);
|
|
417
|
+
cursor: default;
|
|
418
|
+
font-style: italic;
|
|
419
|
+
}
|
|
420
|
+
.chips {
|
|
421
|
+
display: flex;
|
|
422
|
+
flex-wrap: wrap;
|
|
423
|
+
gap: 0.25rem;
|
|
424
|
+
margin-top: 0.25rem;
|
|
425
|
+
/* Pin the chips row to the control's resolved width (0 width +
|
|
426
|
+
min-width 100%) so a long selected label ellipsizes instead of
|
|
427
|
+
contributing its content width and widening the control. */
|
|
428
|
+
width: 0;
|
|
429
|
+
min-width: 100%;
|
|
430
|
+
box-sizing: border-box;
|
|
431
|
+
}
|
|
432
|
+
.chip {
|
|
433
|
+
display: inline-flex;
|
|
434
|
+
align-items: center;
|
|
435
|
+
gap: 0.25rem;
|
|
436
|
+
max-width: 100%;
|
|
437
|
+
box-sizing: border-box;
|
|
438
|
+
padding-left: 0.5rem;
|
|
439
|
+
color: var(--ui-text, #0f172a);
|
|
440
|
+
background: var(--ui-surface-muted, #f8fafc);
|
|
441
|
+
border: 1px solid var(--ui-border, #e2e8f0);
|
|
442
|
+
border-radius: var(--ui-radius-sm, 0.25rem);
|
|
443
|
+
}
|
|
444
|
+
.chip-label {
|
|
445
|
+
min-width: 0;
|
|
446
|
+
overflow: hidden;
|
|
447
|
+
text-overflow: ellipsis;
|
|
448
|
+
white-space: nowrap;
|
|
449
|
+
font-size: var(--ui-font-size-sm, 0.75rem);
|
|
450
|
+
font-weight: var(--ui-font-weight-medium, 500);
|
|
451
|
+
line-height: var(--ui-line-height-tight, 1.25);
|
|
452
|
+
}
|
|
453
|
+
.chip-remove {
|
|
454
|
+
display: inline-flex;
|
|
455
|
+
flex: 0 0 auto;
|
|
456
|
+
width: 2rem;
|
|
457
|
+
height: 2rem;
|
|
458
|
+
align-items: center;
|
|
459
|
+
justify-content: center;
|
|
460
|
+
padding: 0;
|
|
461
|
+
color: var(--ui-text-muted, #64748b);
|
|
462
|
+
background: transparent;
|
|
463
|
+
border: 0;
|
|
464
|
+
border-radius: var(--ui-radius-sm, 0.25rem);
|
|
465
|
+
cursor: pointer;
|
|
466
|
+
}
|
|
467
|
+
.chip-remove:hover:not(:disabled) {
|
|
468
|
+
color: var(--ui-danger, #dc2626);
|
|
469
|
+
}
|
|
470
|
+
.chip-remove:disabled {
|
|
471
|
+
cursor: not-allowed;
|
|
472
|
+
opacity: 0.6;
|
|
473
|
+
}
|
|
474
|
+
.chip-remove:focus-visible {
|
|
475
|
+
outline: none;
|
|
476
|
+
box-shadow: var(--ui-focus-ring, 0 0 0 3px rgb(79 70 229 / 0.35));
|
|
477
|
+
}
|
|
478
|
+
.chip-remove > svg {
|
|
479
|
+
width: 18px;
|
|
480
|
+
height: 18px;
|
|
481
|
+
}
|
|
482
|
+
.sr-only {
|
|
483
|
+
position: absolute;
|
|
484
|
+
width: 1px;
|
|
485
|
+
height: 1px;
|
|
486
|
+
margin: -1px;
|
|
487
|
+
padding: 0;
|
|
488
|
+
overflow: hidden;
|
|
489
|
+
clip: rect(0 0 0 0);
|
|
490
|
+
clip-path: inset(50%);
|
|
491
|
+
white-space: nowrap;
|
|
492
|
+
border: 0;
|
|
493
|
+
}
|
|
494
|
+
@media (prefers-reduced-motion: reduce) {
|
|
495
|
+
.chevron {
|
|
496
|
+
transition: none;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
@media (forced-colors: active) {
|
|
500
|
+
button.trigger:focus-visible,
|
|
501
|
+
.search-trigger:not(.disabled):focus-within,
|
|
502
|
+
.search-field:not(.disabled):focus-within,
|
|
503
|
+
ul.options.persistent:focus-visible,
|
|
504
|
+
.chip-remove:focus-visible {
|
|
505
|
+
outline: 2px solid CanvasText;
|
|
506
|
+
outline-offset: 2px;
|
|
507
|
+
box-shadow: none;
|
|
508
|
+
}
|
|
509
|
+
button.trigger:disabled,
|
|
510
|
+
.search-trigger.disabled,
|
|
511
|
+
.search-field.disabled,
|
|
512
|
+
ul.options.persistent.disabled,
|
|
513
|
+
.chip-remove:disabled,
|
|
514
|
+
li[aria-disabled="true"] {
|
|
515
|
+
color: GrayText;
|
|
516
|
+
opacity: 1;
|
|
517
|
+
}
|
|
518
|
+
li.active,
|
|
519
|
+
li:hover:not([aria-disabled="true"]) {
|
|
520
|
+
color: HighlightText;
|
|
521
|
+
background: Highlight;
|
|
522
|
+
}
|
|
523
|
+
li[aria-selected="true"] {
|
|
524
|
+
color: Highlight;
|
|
525
|
+
}
|
|
526
|
+
li.active[aria-selected="true"],
|
|
527
|
+
li[aria-selected="true"]:hover {
|
|
528
|
+
color: HighlightText;
|
|
529
|
+
}
|
|
530
|
+
.chip {
|
|
531
|
+
border-color: CanvasText;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
`,
|
|
535
|
+
]; }
|
|
536
|
+
#internals;
|
|
537
|
+
#defaultValues;
|
|
538
|
+
#listboxPointerDown;
|
|
539
|
+
#suppressNextBlur;
|
|
540
|
+
#restoringSearchFocus;
|
|
541
|
+
#searchSelection;
|
|
542
|
+
#isComposing;
|
|
543
|
+
#compositionJustEnded;
|
|
544
|
+
#compositionEndTimer;
|
|
545
|
+
#blurTimer;
|
|
546
|
+
#listboxId;
|
|
547
|
+
/** Whether the host or an ancestor fieldset currently disables the control. */
|
|
548
|
+
get #isDisabled() {
|
|
549
|
+
return this.disabled || this._formDisabled;
|
|
550
|
+
}
|
|
551
|
+
/** `visibleRows` normalized to a whole number of rows, at least `1`. */
|
|
552
|
+
get #rows() {
|
|
553
|
+
const raw = this.visibleRows;
|
|
554
|
+
if (!Number.isFinite(raw))
|
|
555
|
+
return 5;
|
|
556
|
+
const floored = Math.floor(raw);
|
|
557
|
+
return floored < 1 ? 1 : floored;
|
|
558
|
+
}
|
|
559
|
+
disconnectedCallback() {
|
|
560
|
+
super.disconnectedCallback();
|
|
561
|
+
window.removeEventListener("mousedown", this.#onWindowMousedown, true);
|
|
562
|
+
if (this.#blurTimer)
|
|
563
|
+
clearTimeout(this.#blurTimer);
|
|
564
|
+
if (this.#compositionEndTimer)
|
|
565
|
+
clearTimeout(this.#compositionEndTimer);
|
|
566
|
+
this.#blurTimer = null;
|
|
567
|
+
this.#compositionEndTimer = null;
|
|
568
|
+
this.#listboxPointerDown = false;
|
|
569
|
+
this.#suppressNextBlur = false;
|
|
570
|
+
this.#searchSelection = null;
|
|
571
|
+
this.#isComposing = false;
|
|
572
|
+
this.#compositionJustEnded = false;
|
|
573
|
+
this.#close();
|
|
574
|
+
}
|
|
575
|
+
willUpdate(changed) {
|
|
576
|
+
if (!this.hasUpdated && this.#defaultValues === null) {
|
|
577
|
+
this.#defaultValues = this.#dedupe(this.values);
|
|
578
|
+
}
|
|
579
|
+
if (changed.has("values")) {
|
|
580
|
+
const deduped = this.#dedupe(this.values);
|
|
581
|
+
if (deduped.length !== this.values.length ||
|
|
582
|
+
deduped.some((value, index) => value !== this.values[index])) {
|
|
583
|
+
this.values = deduped;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if ((changed.has("disabled") || changed.has("searchable") || changed.has("variant")) &&
|
|
587
|
+
(this.#isDisabled || changed.has("searchable") || changed.has("variant"))) {
|
|
588
|
+
this.#close();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
updated(changed) {
|
|
592
|
+
if (changed.has("_open")) {
|
|
593
|
+
this.toggleAttribute("open", this._open);
|
|
594
|
+
if (this._open) {
|
|
595
|
+
window.addEventListener("mousedown", this.#onWindowMousedown, true);
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
window.removeEventListener("mousedown", this.#onWindowMousedown, true);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (changed.has("values") || changed.has("name"))
|
|
602
|
+
this.#syncFormValue();
|
|
603
|
+
if (changed.has("values") ||
|
|
604
|
+
changed.has("required") ||
|
|
605
|
+
changed.has("disabled") ||
|
|
606
|
+
changed.has("_formDisabled")) {
|
|
607
|
+
this.#syncValidity();
|
|
608
|
+
}
|
|
609
|
+
if ((changed.has("_activeIndex") || changed.has("_query") || changed.has("options")) &&
|
|
610
|
+
(this._open || this.variant === "list") &&
|
|
611
|
+
this._activeIndex >= 0) {
|
|
612
|
+
this.renderRoot
|
|
613
|
+
.querySelector(`#${this.#listboxId}-option-${this._activeIndex}`)
|
|
614
|
+
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Restores the selection captured at first render, per the form contract. */
|
|
618
|
+
formResetCallback() {
|
|
619
|
+
this.values = this.#defaultValues ? [...this.#defaultValues] : [];
|
|
620
|
+
this.#close();
|
|
621
|
+
}
|
|
622
|
+
/** Mirrors an ancestor fieldset's disabled state and closes the popover. */
|
|
623
|
+
formDisabledCallback(disabled) {
|
|
624
|
+
this._formDisabled = disabled;
|
|
625
|
+
if (disabled)
|
|
626
|
+
this.#close();
|
|
627
|
+
}
|
|
628
|
+
/** Restores the selection from the saved form state on navigation/autofill. */
|
|
629
|
+
formStateRestoreCallback(state) {
|
|
630
|
+
if (typeof state !== "string")
|
|
631
|
+
return;
|
|
632
|
+
try {
|
|
633
|
+
const parsed = JSON.parse(state);
|
|
634
|
+
if (Array.isArray(parsed)) {
|
|
635
|
+
this.values = parsed.filter((item) => typeof item === "string");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
/* ignore malformed restore state */
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
#syncFormValue() {
|
|
643
|
+
const values = this.values;
|
|
644
|
+
if (!this.name || values.length === 0) {
|
|
645
|
+
this.#internals.setFormValue(null);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const data = new FormData();
|
|
649
|
+
for (const value of values)
|
|
650
|
+
data.append(this.name, value);
|
|
651
|
+
this.#internals.setFormValue(data, JSON.stringify(values));
|
|
652
|
+
}
|
|
653
|
+
#syncValidity() {
|
|
654
|
+
const anchor = this.#anchorElement();
|
|
655
|
+
if (this.#isDisabled) {
|
|
656
|
+
this.#internals.setValidity({});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (this.required && this.values.length === 0) {
|
|
660
|
+
this.#internals.setValidity({ valueMissing: true }, "Please select at least one option.", anchor ?? undefined);
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
this.#internals.setValidity({});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
#anchorElement() {
|
|
667
|
+
return this.renderRoot.querySelector("button.trigger, input.search-input, ul.persistent");
|
|
668
|
+
}
|
|
669
|
+
#dedupe(values) {
|
|
670
|
+
const seen = new Set();
|
|
671
|
+
const result = [];
|
|
672
|
+
for (const value of values) {
|
|
673
|
+
if (seen.has(value))
|
|
674
|
+
continue;
|
|
675
|
+
seen.add(value);
|
|
676
|
+
result.push(value);
|
|
677
|
+
}
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
/** First option matching `value`, so duplicate values resolve deterministically. */
|
|
681
|
+
#optionByValue(value) {
|
|
682
|
+
return this.options.find((option) => option.value === value);
|
|
683
|
+
}
|
|
684
|
+
#labelFor(value) {
|
|
685
|
+
return this.#optionByValue(value)?.label ?? value;
|
|
686
|
+
}
|
|
687
|
+
#summaryText() {
|
|
688
|
+
if (this.values.length === 0)
|
|
689
|
+
return this.placeholder;
|
|
690
|
+
if (this.values.length === 1)
|
|
691
|
+
return this.#labelFor(this.values[0]);
|
|
692
|
+
return `${this.values.length} selected`;
|
|
693
|
+
}
|
|
694
|
+
#liveText() {
|
|
695
|
+
if (this.values.length === 0)
|
|
696
|
+
return "No options selected";
|
|
697
|
+
if (this.values.length === 1)
|
|
698
|
+
return `${this.#labelFor(this.values[0])} selected`;
|
|
699
|
+
return `${this.values.length} options selected`;
|
|
700
|
+
}
|
|
701
|
+
#visibleOptions() {
|
|
702
|
+
if (!this.searchable || this._query === null)
|
|
703
|
+
return this.options;
|
|
704
|
+
const needle = this._query.trim().toLocaleLowerCase();
|
|
705
|
+
if (!needle)
|
|
706
|
+
return this.options;
|
|
707
|
+
return this.options.filter((option) => option.label.toLocaleLowerCase().includes(needle));
|
|
708
|
+
}
|
|
709
|
+
/** Whether `value` is currently at the selection cap and cannot be added. */
|
|
710
|
+
#atCap(value) {
|
|
711
|
+
return (this.max > 0 && this.values.length >= this.max && !this.values.includes(value));
|
|
712
|
+
}
|
|
713
|
+
#isOptionDisabled(option) {
|
|
714
|
+
return Boolean(option.disabled) || this.#atCap(option.value) || this.#isDisabled;
|
|
715
|
+
}
|
|
716
|
+
#toggleOption(option) {
|
|
717
|
+
if (this.#isDisabled || option.disabled)
|
|
718
|
+
return;
|
|
719
|
+
if (this.values.includes(option.value)) {
|
|
720
|
+
this.#commit(this.values.filter((value) => value !== option.value));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (this.#atCap(option.value))
|
|
724
|
+
return;
|
|
725
|
+
this.#commit([...this.values, option.value]);
|
|
726
|
+
}
|
|
727
|
+
#removeValue(value) {
|
|
728
|
+
if (this.#isDisabled)
|
|
729
|
+
return;
|
|
730
|
+
if (!this.values.includes(value))
|
|
731
|
+
return;
|
|
732
|
+
this.#commit(this.values.filter((existing) => existing !== value));
|
|
733
|
+
}
|
|
734
|
+
#commit(next) {
|
|
735
|
+
this.values = this.#dedupe(next);
|
|
736
|
+
this.dispatchEvent(new CustomEvent("change", {
|
|
737
|
+
detail: { values: [...this.values] },
|
|
738
|
+
bubbles: true,
|
|
739
|
+
composed: true,
|
|
740
|
+
}));
|
|
741
|
+
}
|
|
742
|
+
#onWindowMousedown;
|
|
743
|
+
#onFocusOut;
|
|
744
|
+
#toggle() {
|
|
745
|
+
if (this.#isDisabled)
|
|
746
|
+
return;
|
|
747
|
+
if (this._open)
|
|
748
|
+
this.#close();
|
|
749
|
+
else
|
|
750
|
+
this.#open();
|
|
751
|
+
}
|
|
752
|
+
#open() {
|
|
753
|
+
if (this.#isDisabled)
|
|
754
|
+
return;
|
|
755
|
+
this._query = null;
|
|
756
|
+
this._open = true;
|
|
757
|
+
this._activeIndex = this.#initialActiveIndex();
|
|
758
|
+
}
|
|
759
|
+
#close() {
|
|
760
|
+
this._open = false;
|
|
761
|
+
this._query = null;
|
|
762
|
+
this._activeIndex = -1;
|
|
763
|
+
this.#searchSelection = null;
|
|
764
|
+
}
|
|
765
|
+
#initialActiveIndex() {
|
|
766
|
+
const options = this.#visibleOptions();
|
|
767
|
+
if (options.length === 0)
|
|
768
|
+
return -1;
|
|
769
|
+
if (this._query !== null)
|
|
770
|
+
return 0;
|
|
771
|
+
const selectedIndex = options.findIndex((option) => this.values.includes(option.value));
|
|
772
|
+
return selectedIndex >= 0 ? selectedIndex : 0;
|
|
773
|
+
}
|
|
774
|
+
#moveActive(delta) {
|
|
775
|
+
const options = this.#visibleOptions();
|
|
776
|
+
if (options.length === 0)
|
|
777
|
+
return;
|
|
778
|
+
const n = options.length;
|
|
779
|
+
this._activeIndex = (this._activeIndex + delta + n) % n;
|
|
780
|
+
}
|
|
781
|
+
#toggleActive() {
|
|
782
|
+
const options = this.#visibleOptions();
|
|
783
|
+
const option = options[this._activeIndex] ?? options[0];
|
|
784
|
+
if (option)
|
|
785
|
+
this.#toggleOption(option);
|
|
786
|
+
}
|
|
787
|
+
#onTriggerKeydown(e) {
|
|
788
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ") {
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
if (!this._open) {
|
|
791
|
+
this.#open();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (e.key === "ArrowDown")
|
|
795
|
+
this.#moveActive(1);
|
|
796
|
+
else if (e.key === "ArrowUp")
|
|
797
|
+
this.#moveActive(-1);
|
|
798
|
+
else
|
|
799
|
+
this.#toggleActive();
|
|
800
|
+
}
|
|
801
|
+
else if ((e.key === "Home" || e.key === "End") && this._open) {
|
|
802
|
+
e.preventDefault();
|
|
803
|
+
const options = this.#visibleOptions();
|
|
804
|
+
this._activeIndex = e.key === "Home" ? 0 : options.length - 1;
|
|
805
|
+
}
|
|
806
|
+
else if (e.key === "Escape" && this._open) {
|
|
807
|
+
e.preventDefault();
|
|
808
|
+
this.#close();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
#onSearchFocus(e) {
|
|
812
|
+
if (this.#isDisabled)
|
|
813
|
+
return;
|
|
814
|
+
if (this.#blurTimer) {
|
|
815
|
+
clearTimeout(this.#blurTimer);
|
|
816
|
+
this.#blurTimer = null;
|
|
817
|
+
}
|
|
818
|
+
if (!this._open)
|
|
819
|
+
this.#open();
|
|
820
|
+
if (!this.#restoringSearchFocus)
|
|
821
|
+
e.currentTarget.select();
|
|
822
|
+
}
|
|
823
|
+
#onSearchClick() {
|
|
824
|
+
if (!this._open)
|
|
825
|
+
this.#open();
|
|
826
|
+
}
|
|
827
|
+
#onSearchMousedown(e) {
|
|
828
|
+
if (e.button !== 0)
|
|
829
|
+
e.preventDefault();
|
|
830
|
+
}
|
|
831
|
+
/** Seeds the active option when the persistent (non-searchable) list focuses. */
|
|
832
|
+
#onListFocus() {
|
|
833
|
+
if (this.#isDisabled || this.searchable)
|
|
834
|
+
return;
|
|
835
|
+
if (this._activeIndex < 0)
|
|
836
|
+
this._activeIndex = this.#initialActiveIndex();
|
|
837
|
+
}
|
|
838
|
+
/** Keyboard navigation for the focusable, non-searchable persistent list. */
|
|
839
|
+
#onListKeydown(e) {
|
|
840
|
+
if (this.#isDisabled || this.searchable)
|
|
841
|
+
return;
|
|
842
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
843
|
+
e.preventDefault();
|
|
844
|
+
if (this._activeIndex < 0)
|
|
845
|
+
this._activeIndex = this.#initialActiveIndex();
|
|
846
|
+
else
|
|
847
|
+
this.#moveActive(e.key === "ArrowDown" ? 1 : -1);
|
|
848
|
+
}
|
|
849
|
+
else if (e.key === "Home" || e.key === "End") {
|
|
850
|
+
e.preventDefault();
|
|
851
|
+
const options = this.#visibleOptions();
|
|
852
|
+
this._activeIndex = e.key === "Home" ? 0 : options.length - 1;
|
|
853
|
+
}
|
|
854
|
+
else if (e.key === "Enter" || e.key === " ") {
|
|
855
|
+
e.preventDefault();
|
|
856
|
+
this.#toggleActive();
|
|
857
|
+
}
|
|
858
|
+
// Escape intentionally does nothing: the list is never hidden.
|
|
859
|
+
}
|
|
860
|
+
/** Infix-filters the persistent list as the searchable-list query changes. */
|
|
861
|
+
#onListSearchInput(e) {
|
|
862
|
+
this._query = e.currentTarget.value;
|
|
863
|
+
const options = this.#visibleOptions();
|
|
864
|
+
this._activeIndex = options.length > 0 ? 0 : -1;
|
|
865
|
+
}
|
|
866
|
+
/** Keyboard navigation for the search field above a searchable persistent list. */
|
|
867
|
+
#onListSearchKeydown(e) {
|
|
868
|
+
if (e.isComposing || e.keyCode === 229 || this.#isComposing || this.#compositionJustEnded) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
872
|
+
e.preventDefault();
|
|
873
|
+
this.#moveActive(e.key === "ArrowDown" ? 1 : -1);
|
|
874
|
+
}
|
|
875
|
+
else if (e.key === "Home" || e.key === "End") {
|
|
876
|
+
e.preventDefault();
|
|
877
|
+
const options = this.#visibleOptions();
|
|
878
|
+
this._activeIndex = e.key === "Home" ? 0 : options.length - 1;
|
|
879
|
+
}
|
|
880
|
+
else if (e.key === "Enter") {
|
|
881
|
+
e.preventDefault();
|
|
882
|
+
this.#toggleActive();
|
|
883
|
+
}
|
|
884
|
+
else if (e.key === "Escape") {
|
|
885
|
+
e.preventDefault();
|
|
886
|
+
this._query = null;
|
|
887
|
+
const options = this.#visibleOptions();
|
|
888
|
+
this._activeIndex = options.length > 0 ? 0 : -1;
|
|
889
|
+
}
|
|
890
|
+
else if (e.key === "Backspace" &&
|
|
891
|
+
e.currentTarget.value === "" &&
|
|
892
|
+
this.values.length > 0) {
|
|
893
|
+
e.preventDefault();
|
|
894
|
+
this.#removeValue(this.values[this.values.length - 1]);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
#onSearchInput(e) {
|
|
898
|
+
this._query = e.currentTarget.value;
|
|
899
|
+
this._open = true;
|
|
900
|
+
const options = this.#visibleOptions();
|
|
901
|
+
this._activeIndex = options.length > 0 ? 0 : -1;
|
|
902
|
+
}
|
|
903
|
+
#onSearchKeydown(e) {
|
|
904
|
+
if (e.isComposing || e.keyCode === 229 || this.#isComposing || this.#compositionJustEnded) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
908
|
+
e.preventDefault();
|
|
909
|
+
if (!this._open) {
|
|
910
|
+
this.#open();
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
this.#moveActive(e.key === "ArrowDown" ? 1 : -1);
|
|
914
|
+
}
|
|
915
|
+
else if ((e.key === "Home" || e.key === "End") && this._open) {
|
|
916
|
+
e.preventDefault();
|
|
917
|
+
const options = this.#visibleOptions();
|
|
918
|
+
this._activeIndex = e.key === "Home" ? 0 : options.length - 1;
|
|
919
|
+
}
|
|
920
|
+
else if (e.key === "Enter" && this._open) {
|
|
921
|
+
e.preventDefault();
|
|
922
|
+
this.#toggleActive();
|
|
923
|
+
}
|
|
924
|
+
else if (e.key === "Escape" && this._open) {
|
|
925
|
+
e.preventDefault();
|
|
926
|
+
this.#close();
|
|
927
|
+
}
|
|
928
|
+
else if (e.key === "Backspace" &&
|
|
929
|
+
e.currentTarget.value === "" &&
|
|
930
|
+
this.values.length > 0) {
|
|
931
|
+
e.preventDefault();
|
|
932
|
+
this.#removeValue(this.values[this.values.length - 1]);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
#onSearchBlur() {
|
|
936
|
+
if (this.#blurTimer)
|
|
937
|
+
clearTimeout(this.#blurTimer);
|
|
938
|
+
this.#blurTimer = setTimeout(() => {
|
|
939
|
+
this.#blurTimer = null;
|
|
940
|
+
if (!this._open)
|
|
941
|
+
return;
|
|
942
|
+
if (!this.#listboxPointerDown && !this.#suppressNextBlur)
|
|
943
|
+
this.#close();
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
#focusSearchInput(e) {
|
|
947
|
+
if (e.button !== 0 ||
|
|
948
|
+
this.#isDisabled ||
|
|
949
|
+
e.composedPath()[0] instanceof HTMLInputElement) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
e.preventDefault();
|
|
953
|
+
const input = this.renderRoot.querySelector("input.search-input");
|
|
954
|
+
if (!this._open)
|
|
955
|
+
this.#open();
|
|
956
|
+
input?.focus();
|
|
957
|
+
input?.select();
|
|
958
|
+
}
|
|
959
|
+
#onCompositionStart() {
|
|
960
|
+
if (this.#compositionEndTimer)
|
|
961
|
+
clearTimeout(this.#compositionEndTimer);
|
|
962
|
+
this.#compositionEndTimer = null;
|
|
963
|
+
this.#compositionJustEnded = false;
|
|
964
|
+
this.#isComposing = true;
|
|
965
|
+
}
|
|
966
|
+
#onCompositionEnd() {
|
|
967
|
+
this.#isComposing = false;
|
|
968
|
+
this.#compositionJustEnded = true;
|
|
969
|
+
if (this.#compositionEndTimer)
|
|
970
|
+
clearTimeout(this.#compositionEndTimer);
|
|
971
|
+
this.#compositionEndTimer = setTimeout(() => {
|
|
972
|
+
this.#compositionJustEnded = false;
|
|
973
|
+
this.#compositionEndTimer = null;
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
#onListboxMousedown(e) {
|
|
977
|
+
if (e.button !== 0)
|
|
978
|
+
return;
|
|
979
|
+
this.#listboxPointerDown = true;
|
|
980
|
+
this.#suppressNextBlur = true;
|
|
981
|
+
const input = this.renderRoot.querySelector("input.search-input");
|
|
982
|
+
this.#searchSelection = input ? [input.selectionStart, input.selectionEnd] : null;
|
|
983
|
+
window.addEventListener("mouseup", () => {
|
|
984
|
+
this.#listboxPointerDown = false;
|
|
985
|
+
if (this._open) {
|
|
986
|
+
const searchInput = this.renderRoot.querySelector("input.search-input");
|
|
987
|
+
if (searchInput) {
|
|
988
|
+
this.#restoringSearchFocus = true;
|
|
989
|
+
searchInput.focus({ preventScroll: true });
|
|
990
|
+
this.#restoringSearchFocus = false;
|
|
991
|
+
const [start, end] = this.#searchSelection ?? [null, null];
|
|
992
|
+
if (start !== null && end !== null)
|
|
993
|
+
searchInput.setSelectionRange(start, end);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
this.#searchSelection = null;
|
|
997
|
+
setTimeout(() => {
|
|
998
|
+
this.#suppressNextBlur = false;
|
|
999
|
+
});
|
|
1000
|
+
}, { capture: true, once: true });
|
|
1001
|
+
}
|
|
1002
|
+
#onOptionMousedown(e, option) {
|
|
1003
|
+
if (e.button !== 0)
|
|
1004
|
+
return;
|
|
1005
|
+
const optionElement = e.currentTarget;
|
|
1006
|
+
const listbox = optionElement.parentElement;
|
|
1007
|
+
if (listbox) {
|
|
1008
|
+
const scrollbarWidth = listbox.offsetWidth - listbox.clientWidth;
|
|
1009
|
+
const bounds = listbox.getBoundingClientRect();
|
|
1010
|
+
const isRtl = getComputedStyle(listbox).direction === "rtl";
|
|
1011
|
+
const inScrollbarGutter = isRtl
|
|
1012
|
+
? e.clientX <= bounds.left + scrollbarWidth
|
|
1013
|
+
: e.clientX >= bounds.right - scrollbarWidth;
|
|
1014
|
+
if (scrollbarWidth > 0 && inScrollbarGutter)
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
e.preventDefault();
|
|
1018
|
+
if (this.#isOptionDisabled(option))
|
|
1019
|
+
return;
|
|
1020
|
+
this.#toggleOption(option);
|
|
1021
|
+
if (this.variant === "list" && !this.searchable && !this.#isDisabled) {
|
|
1022
|
+
this.renderRoot
|
|
1023
|
+
.querySelector("ul.persistent")
|
|
1024
|
+
?.focus({ preventScroll: true });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
#optionIconSize(option) {
|
|
1028
|
+
const size = option.iconSize ?? 16;
|
|
1029
|
+
return Number.isFinite(size) && size > 0 ? size : 16;
|
|
1030
|
+
}
|
|
1031
|
+
#renderOptionIcon(option) {
|
|
1032
|
+
if (!option.icon)
|
|
1033
|
+
return nothing;
|
|
1034
|
+
return html `
|
|
1035
|
+
<span
|
|
1036
|
+
class="option-icon"
|
|
1037
|
+
style=${`--multi-select-icon-size: ${this.#optionIconSize(option)}px`}
|
|
1038
|
+
aria-hidden="true"
|
|
1039
|
+
>
|
|
1040
|
+
${option.icon}
|
|
1041
|
+
</span>
|
|
1042
|
+
`;
|
|
1043
|
+
}
|
|
1044
|
+
/** The shared `<li role="option">` items for both variants' listboxes. */
|
|
1045
|
+
renderOptionItems(options) {
|
|
1046
|
+
return html `
|
|
1047
|
+
${options.map((option, index) => {
|
|
1048
|
+
const selected = this.values.includes(option.value);
|
|
1049
|
+
const disabled = this.#isOptionDisabled(option);
|
|
1050
|
+
return html `
|
|
1051
|
+
<li
|
|
1052
|
+
id=${`${this.#listboxId}-option-${index}`}
|
|
1053
|
+
role="option"
|
|
1054
|
+
aria-selected=${selected}
|
|
1055
|
+
aria-disabled=${disabled ? "true" : nothing}
|
|
1056
|
+
class=${index === this._activeIndex ? "active" : ""}
|
|
1057
|
+
@mousedown=${(e) => this.#onOptionMousedown(e, option)}
|
|
1058
|
+
>
|
|
1059
|
+
<span class="check ${selected ? "" : "hidden"}" aria-hidden="true">
|
|
1060
|
+
${iconCheckCircle(14)}
|
|
1061
|
+
</span>
|
|
1062
|
+
<span class="option-content">
|
|
1063
|
+
${this.#renderOptionIcon(option)}
|
|
1064
|
+
<span class="option-label">${option.label}</span>
|
|
1065
|
+
</span>
|
|
1066
|
+
</li>
|
|
1067
|
+
`;
|
|
1068
|
+
})}
|
|
1069
|
+
${options.length === 0
|
|
1070
|
+
? html `<li class="no-options" role="presentation">
|
|
1071
|
+
<span role="status">No options found</span>
|
|
1072
|
+
</li>`
|
|
1073
|
+
: nothing}
|
|
1074
|
+
`;
|
|
1075
|
+
}
|
|
1076
|
+
renderListbox() {
|
|
1077
|
+
if (!this._open)
|
|
1078
|
+
return nothing;
|
|
1079
|
+
const options = this.#visibleOptions();
|
|
1080
|
+
return html `
|
|
1081
|
+
<ul
|
|
1082
|
+
id=${this.#listboxId}
|
|
1083
|
+
class="options"
|
|
1084
|
+
role="listbox"
|
|
1085
|
+
aria-multiselectable="true"
|
|
1086
|
+
aria-label=${this.label ? `${this.label} options` : "Options"}
|
|
1087
|
+
@mousedown=${(e) => this.#onListboxMousedown(e)}
|
|
1088
|
+
>
|
|
1089
|
+
${this.renderOptionItems(options)}
|
|
1090
|
+
</ul>
|
|
1091
|
+
`;
|
|
1092
|
+
}
|
|
1093
|
+
renderChips() {
|
|
1094
|
+
if (!this.showChips || this.values.length === 0)
|
|
1095
|
+
return nothing;
|
|
1096
|
+
return html `
|
|
1097
|
+
<div class="chips">
|
|
1098
|
+
${this.values.map((value) => {
|
|
1099
|
+
const label = this.#labelFor(value);
|
|
1100
|
+
return html `
|
|
1101
|
+
<span class="chip">
|
|
1102
|
+
<span class="chip-label">${label}</span>
|
|
1103
|
+
<button
|
|
1104
|
+
type="button"
|
|
1105
|
+
class="chip-remove"
|
|
1106
|
+
aria-label=${`Remove ${label}`}
|
|
1107
|
+
?disabled=${this.#isDisabled}
|
|
1108
|
+
@click=${() => this.#removeValue(value)}
|
|
1109
|
+
>
|
|
1110
|
+
${iconX(18)}
|
|
1111
|
+
</button>
|
|
1112
|
+
</span>
|
|
1113
|
+
`;
|
|
1114
|
+
})}
|
|
1115
|
+
</div>
|
|
1116
|
+
`;
|
|
1117
|
+
}
|
|
1118
|
+
renderSearchTrigger() {
|
|
1119
|
+
const activeDescendant = this._open && this._activeIndex >= 0
|
|
1120
|
+
? `${this.#listboxId}-option-${this._activeIndex}`
|
|
1121
|
+
: nothing;
|
|
1122
|
+
return html `
|
|
1123
|
+
<div
|
|
1124
|
+
class="search-trigger ${this.#isDisabled ? "disabled" : ""}"
|
|
1125
|
+
@mousedown=${(e) => this.#focusSearchInput(e)}
|
|
1126
|
+
>
|
|
1127
|
+
<input
|
|
1128
|
+
type="text"
|
|
1129
|
+
class="search-input"
|
|
1130
|
+
role="combobox"
|
|
1131
|
+
aria-autocomplete="list"
|
|
1132
|
+
aria-haspopup="listbox"
|
|
1133
|
+
aria-expanded=${this._open}
|
|
1134
|
+
aria-controls=${this.#listboxId}
|
|
1135
|
+
aria-activedescendant=${activeDescendant}
|
|
1136
|
+
aria-label=${this.label || "Select options"}
|
|
1137
|
+
placeholder=${this.#summaryText()}
|
|
1138
|
+
autocomplete="off"
|
|
1139
|
+
?disabled=${this.#isDisabled}
|
|
1140
|
+
.value=${this._query ?? ""}
|
|
1141
|
+
@mousedown=${(e) => this.#onSearchMousedown(e)}
|
|
1142
|
+
@focus=${(e) => this.#onSearchFocus(e)}
|
|
1143
|
+
@click=${() => this.#onSearchClick()}
|
|
1144
|
+
@input=${(e) => this.#onSearchInput(e)}
|
|
1145
|
+
@compositionstart=${() => this.#onCompositionStart()}
|
|
1146
|
+
@compositionend=${() => this.#onCompositionEnd()}
|
|
1147
|
+
@keydown=${(e) => this.#onSearchKeydown(e)}
|
|
1148
|
+
@blur=${() => this.#onSearchBlur()}
|
|
1149
|
+
/>
|
|
1150
|
+
<span class="chevron">${iconChevronRight(14)}</span>
|
|
1151
|
+
</div>
|
|
1152
|
+
`;
|
|
1153
|
+
}
|
|
1154
|
+
renderButtonTrigger() {
|
|
1155
|
+
const activeDescendant = this._open && this._activeIndex >= 0
|
|
1156
|
+
? `${this.#listboxId}-option-${this._activeIndex}`
|
|
1157
|
+
: nothing;
|
|
1158
|
+
const empty = this.values.length === 0;
|
|
1159
|
+
return html `
|
|
1160
|
+
<button
|
|
1161
|
+
type="button"
|
|
1162
|
+
class="trigger"
|
|
1163
|
+
role="combobox"
|
|
1164
|
+
aria-haspopup="listbox"
|
|
1165
|
+
aria-expanded=${this._open}
|
|
1166
|
+
aria-controls=${this.#listboxId}
|
|
1167
|
+
aria-activedescendant=${activeDescendant}
|
|
1168
|
+
aria-label=${this.label || nothing}
|
|
1169
|
+
?disabled=${this.#isDisabled}
|
|
1170
|
+
@click=${() => this.#toggle()}
|
|
1171
|
+
@keydown=${(e) => this.#onTriggerKeydown(e)}
|
|
1172
|
+
>
|
|
1173
|
+
<span class="trigger-label ${empty ? "placeholder" : ""}">${this.#summaryText()}</span>
|
|
1174
|
+
<span class="chevron">${iconChevronRight(14)}</span>
|
|
1175
|
+
</button>
|
|
1176
|
+
`;
|
|
1177
|
+
}
|
|
1178
|
+
renderListSearch() {
|
|
1179
|
+
const activeDescendant = this._activeIndex >= 0 ? `${this.#listboxId}-option-${this._activeIndex}` : nothing;
|
|
1180
|
+
return html `
|
|
1181
|
+
<div class="search-field ${this.#isDisabled ? "disabled" : ""}">
|
|
1182
|
+
<input
|
|
1183
|
+
type="text"
|
|
1184
|
+
class="search-input list-search"
|
|
1185
|
+
role="combobox"
|
|
1186
|
+
aria-autocomplete="list"
|
|
1187
|
+
aria-haspopup="listbox"
|
|
1188
|
+
aria-expanded="true"
|
|
1189
|
+
aria-controls=${this.#listboxId}
|
|
1190
|
+
aria-activedescendant=${activeDescendant}
|
|
1191
|
+
aria-label=${this.label || "Select options"}
|
|
1192
|
+
placeholder=${this.#summaryText()}
|
|
1193
|
+
autocomplete="off"
|
|
1194
|
+
?disabled=${this.#isDisabled}
|
|
1195
|
+
.value=${this._query ?? ""}
|
|
1196
|
+
@input=${(e) => this.#onListSearchInput(e)}
|
|
1197
|
+
@compositionstart=${() => this.#onCompositionStart()}
|
|
1198
|
+
@compositionend=${() => this.#onCompositionEnd()}
|
|
1199
|
+
@keydown=${(e) => this.#onListSearchKeydown(e)}
|
|
1200
|
+
/>
|
|
1201
|
+
</div>
|
|
1202
|
+
`;
|
|
1203
|
+
}
|
|
1204
|
+
renderPersistentList() {
|
|
1205
|
+
const options = this.#visibleOptions();
|
|
1206
|
+
const focusable = !this.searchable && !this.#isDisabled;
|
|
1207
|
+
const activeDescendant = !this.searchable && this._activeIndex >= 0 && options.length > 0
|
|
1208
|
+
? `${this.#listboxId}-option-${this._activeIndex}`
|
|
1209
|
+
: nothing;
|
|
1210
|
+
return html `
|
|
1211
|
+
<ul
|
|
1212
|
+
id=${this.#listboxId}
|
|
1213
|
+
class="options persistent ${this.#isDisabled ? "disabled" : ""}"
|
|
1214
|
+
role="listbox"
|
|
1215
|
+
aria-multiselectable="true"
|
|
1216
|
+
aria-label=${this.label ? `${this.label} options` : "Options"}
|
|
1217
|
+
aria-disabled=${this.#isDisabled ? "true" : nothing}
|
|
1218
|
+
tabindex=${focusable ? "0" : nothing}
|
|
1219
|
+
aria-activedescendant=${activeDescendant}
|
|
1220
|
+
style=${`height: calc(${this.#rows} * 2rem + 0.5rem + 2px)`}
|
|
1221
|
+
@mousedown=${(e) => this.#onListboxMousedown(e)}
|
|
1222
|
+
@focus=${() => this.#onListFocus()}
|
|
1223
|
+
@keydown=${(e) => this.#onListKeydown(e)}
|
|
1224
|
+
>
|
|
1225
|
+
${this.renderOptionItems(options)}
|
|
1226
|
+
</ul>
|
|
1227
|
+
`;
|
|
1228
|
+
}
|
|
1229
|
+
renderListVariant() {
|
|
1230
|
+
return html `
|
|
1231
|
+
<div class="control list-control">
|
|
1232
|
+
${this.searchable ? this.renderListSearch() : nothing}
|
|
1233
|
+
${this.renderPersistentList()}
|
|
1234
|
+
</div>
|
|
1235
|
+
${this.renderChips()}
|
|
1236
|
+
<span class="sr-only" aria-live="polite">${this.#liveText()}</span>
|
|
1237
|
+
`;
|
|
1238
|
+
}
|
|
1239
|
+
render() {
|
|
1240
|
+
if (this.variant === "list")
|
|
1241
|
+
return this.renderListVariant();
|
|
1242
|
+
return html `
|
|
1243
|
+
<div class="control" @focusout=${(e) => this.#onFocusOut(e)}>
|
|
1244
|
+
${this.searchable ? this.renderSearchTrigger() : this.renderButtonTrigger()}
|
|
1245
|
+
${this.renderListbox()}
|
|
1246
|
+
</div>
|
|
1247
|
+
${this.renderChips()}
|
|
1248
|
+
<span class="sr-only" aria-live="polite">${this.#liveText()}</span>
|
|
1249
|
+
`;
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
__decorate([
|
|
1253
|
+
property({ attribute: false })
|
|
1254
|
+
], MultiSelect.prototype, "options", void 0);
|
|
1255
|
+
__decorate([
|
|
1256
|
+
property({ attribute: false })
|
|
1257
|
+
], MultiSelect.prototype, "values", void 0);
|
|
1258
|
+
__decorate([
|
|
1259
|
+
property()
|
|
1260
|
+
], MultiSelect.prototype, "name", void 0);
|
|
1261
|
+
__decorate([
|
|
1262
|
+
property()
|
|
1263
|
+
], MultiSelect.prototype, "label", void 0);
|
|
1264
|
+
__decorate([
|
|
1265
|
+
property()
|
|
1266
|
+
], MultiSelect.prototype, "placeholder", void 0);
|
|
1267
|
+
__decorate([
|
|
1268
|
+
property({ type: Boolean })
|
|
1269
|
+
], MultiSelect.prototype, "disabled", void 0);
|
|
1270
|
+
__decorate([
|
|
1271
|
+
property({ type: Boolean })
|
|
1272
|
+
], MultiSelect.prototype, "required", void 0);
|
|
1273
|
+
__decorate([
|
|
1274
|
+
property({ type: Boolean, reflect: true })
|
|
1275
|
+
], MultiSelect.prototype, "searchable", void 0);
|
|
1276
|
+
__decorate([
|
|
1277
|
+
property({ type: Number })
|
|
1278
|
+
], MultiSelect.prototype, "max", void 0);
|
|
1279
|
+
__decorate([
|
|
1280
|
+
property({ reflect: true })
|
|
1281
|
+
], MultiSelect.prototype, "variant", void 0);
|
|
1282
|
+
__decorate([
|
|
1283
|
+
property({ type: Number, attribute: "visible-rows" })
|
|
1284
|
+
], MultiSelect.prototype, "visibleRows", void 0);
|
|
1285
|
+
__decorate([
|
|
1286
|
+
property({ type: Boolean, attribute: "show-chips" })
|
|
1287
|
+
], MultiSelect.prototype, "showChips", void 0);
|
|
1288
|
+
__decorate([
|
|
1289
|
+
state()
|
|
1290
|
+
], MultiSelect.prototype, "_open", void 0);
|
|
1291
|
+
__decorate([
|
|
1292
|
+
state()
|
|
1293
|
+
], MultiSelect.prototype, "_activeIndex", void 0);
|
|
1294
|
+
__decorate([
|
|
1295
|
+
state()
|
|
1296
|
+
], MultiSelect.prototype, "_query", void 0);
|
|
1297
|
+
__decorate([
|
|
1298
|
+
state()
|
|
1299
|
+
], MultiSelect.prototype, "_formDisabled", void 0);
|
|
1300
|
+
MultiSelect = __decorate([
|
|
1301
|
+
customElement("multi-select")
|
|
1302
|
+
], MultiSelect);
|
|
1303
|
+
export { MultiSelect };
|
|
1304
|
+
//# sourceMappingURL=multi-select.js.map
|