@reliability-design/web 1.4.3 → 1.4.5

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 (48) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-autocomplete/README.md +1 -0
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +56 -1
  4. package/dist/components/rel-button/rel-button.ts +18 -2
  5. package/dist/components/rel-calendar/rel-calendar.ts +23 -1
  6. package/dist/components/rel-carousel/rel-carousel.ts +20 -0
  7. package/dist/components/rel-checkbox/README.md +69 -22
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +119 -9
  9. package/dist/components/rel-chip/rel-chip.ts +29 -0
  10. package/dist/components/rel-date-picker/rel-date-picker.ts +156 -13
  11. package/dist/components/rel-drawer/rel-drawer.ts +22 -58
  12. package/dist/components/rel-menu/rel-menu.ts +124 -15
  13. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  14. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  15. package/dist/components/rel-otp/rel-otp.ts +3 -3
  16. package/dist/components/rel-pagination/README.md +14 -0
  17. package/dist/components/rel-pagination/rel-pagination.ts +30 -14
  18. package/dist/components/rel-popover/rel-popover.ts +31 -3
  19. package/dist/components/rel-range-picker/rel-range-picker.ts +251 -3
  20. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  21. package/dist/components/rel-segmented-button/rel-segmented-button.ts +50 -12
  22. package/dist/components/rel-select/README.md +1 -0
  23. package/dist/components/rel-select/rel-select.ts +1385 -1170
  24. package/dist/components/rel-sheet/rel-sheet.ts +22 -1
  25. package/dist/components/rel-sidebar/rel-sidebar.ts +43 -39
  26. package/dist/components/rel-stepper/rel-stepper.ts +44 -1
  27. package/dist/components/rel-tab/rel-tab.ts +32 -7
  28. package/dist/components/rel-time-picker/rel-time-picker.ts +338 -206
  29. package/dist/components/rel-tooltip/rel-tooltip.ts +30 -0
  30. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  31. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  32. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  33. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  34. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  35. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  36. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  37. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  38. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  39. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +134 -0
  40. package/dist/components/rel-upload/rel-upload-actions.ts +163 -0
  41. package/dist/components/rel-upload/rel-upload.ts +943 -0
  42. package/dist/index.d.mts +491 -18
  43. package/dist/index.d.ts +491 -18
  44. package/dist/index.js +2748 -410
  45. package/dist/index.js.map +1 -1
  46. package/dist/index.mjs +2953 -627
  47. package/dist/index.mjs.map +1 -1
  48. package/package.json +1 -1
@@ -1,1170 +1,1385 @@
1
- import { LitElement, html, css, nothing, PropertyValues } from "lit";
2
- import { customElement, property, state, query } from "lit/decorators.js";
3
- import { repeat } from "lit/directives/repeat.js";
4
- import "@reliability-design/icons/rel-icon";
5
- import "../rel-chip/rel-chip.js";
6
- import "../rel-checkbox/rel-checkbox.js";
7
- import type { FieldLabelStyle } from "../rel-textfield/rel-textfield.js";
8
-
9
- export type SelectState = "default" | "error" | "disabled";
10
- export type SelectSize = "small" | "medium" | "large";
11
- export type SelectBorder = "rounded" | "sharp";
12
- /** Alias of the shared field type — kept so existing imports keep resolving. */
13
- export type SelectLabelStyle = FieldLabelStyle;
14
-
15
- export interface SelectOptionObject {
16
- value: string;
17
- label?: string;
18
- supportingText?: string;
19
- /** rel-icon name rendered as a prefix (leading icon) on the menu item. */
20
- icon?: string;
21
- }
22
- export type SelectOption = string | SelectOptionObject;
23
-
24
- interface NormalizedOption {
25
- value: string;
26
- label: string;
27
- supportingText: string;
28
- icon: string;
29
- }
30
-
31
- function normalizeOption(o: SelectOption): NormalizedOption {
32
- if (typeof o === "string") return { value: o, label: o, supportingText: "", icon: "" };
33
- return {
34
- value: o.value,
35
- label: o.label ?? o.value,
36
- supportingText: o.supportingText ?? "",
37
- icon: o.icon ?? "",
38
- };
39
- }
40
-
41
- @customElement("rel-select")
42
- export class RelSelect extends LitElement {
43
- @property({ type: String }) label: string = "";
44
-
45
- // Lit's default attribute name is the property name fully lowercased (no
46
- // hyphens) — e.g. labelStyle would otherwise only respond to "labelstyle".
47
- // Every multi-word property below gets an explicit kebab-case `attribute`
48
- // so it works the way HTML usage actually looks.
49
- @property({ type: String, attribute: "label-style", reflect: true })
50
- labelStyle: SelectLabelStyle = "default";
51
-
52
- /**
53
- * Accessible name for the trigger. Required when `labelStyle="none"`, since no
54
- * `<label>` renders and a placeholder is not a valid naming mechanism.
55
- */
56
- @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
57
- /** IDREF list of external element(s) naming the trigger. Wins over `fieldAriaLabel`. */
58
- @property({ type: String, attribute: "field-aria-labelledby" }) fieldAriaLabelledby?: string;
59
-
60
- @property({ type: Array }) options: SelectOption[] = [];
61
-
62
- /** Enables multi-select: selected values render as removable chips and options as checkboxes. */
63
- @property({ type: Boolean }) multiple: boolean = false;
64
-
65
- /** Selected value — single-select mode only. */
66
- @property({ type: String }) selected: string = "";
67
-
68
- /** Selected values — multi-select mode only. */
69
- @property({ type: Array, attribute: "selected-values" }) selectedValues: string[] = [];
70
-
71
- @property({ type: String }) state: SelectState = "default";
72
-
73
- @property({ type: String, attribute: "supporting-text" }) supportingText: string = "";
74
-
75
- @property({ type: String, reflect: true }) size: SelectSize = "medium";
76
-
77
- @property({ type: String }) border: SelectBorder = "rounded";
78
-
79
- @property({ type: String, attribute: "left-icon" }) leftIcon: string = "";
80
-
81
- @property({ type: String, attribute: "right-icon" }) rightIcon: string = "";
82
-
83
- @property({ type: String }) placeholder: string = "Select option";
84
-
85
- @property({ type: String, attribute: "search-placeholder" }) searchPlaceholder: string = "Search option";
86
-
87
- @property({ type: String, attribute: "select-all-label" }) selectAllLabel: string = "Select all";
88
-
89
- /**
90
- * Optional hard cap on visible chips. By default (no cap) the visible
91
- * count adapts to the trigger's width: chips collapse into the "+N" badge
92
- * one by one instead of wrapping to a second line.
93
- */
94
- @property({ type: Number, attribute: "max-visible-chips" }) maxVisibleChips: number = Infinity;
95
-
96
- // Boolean props default true and toggled off via ="false" are a classic Lit
97
- // trap: for type:Boolean, attribute *presence* (not its string value)
98
- // decides truthiness, so searchable="false" would still read as true. Named
99
- // negatively instead, so the natural HTML idiom — just adding the bare
100
- // attribute, no value — does what it looks like it does.
101
- /** Hides the in-panel search field. Figma's "Single Option" dropdown has no search bar at all — use this for short, non-searchable lists. */
102
- @property({ type: Boolean, attribute: "no-search" }) noSearch: boolean = false;
103
-
104
- @state() private open = false;
105
- @state() private highlightedIndex = -1;
106
- @state() private panelStyle = "";
107
- @state() private searchValue = "";
108
- @state() private internalState: "default" | "focused" = "default";
109
-
110
- /** How many chips currently fit in the trigger width (width-adaptive). */
111
- @state() private visibleChipCount = Infinity;
112
-
113
- @query(".select-trigger") private triggerEl!: HTMLButtonElement;
114
- @query(".search-input") private searchInputEl!: HTMLInputElement;
115
- @query(".select-listbox") private listboxEl!: HTMLDivElement;
116
- @query(".select-value") private valueEl!: HTMLElement;
117
- @query(".select-chips-measure") private chipsMeasureEl!: HTMLElement;
118
-
119
- /** Must match the `gap` of .select-chips / .select-chips-measure. */
120
- private static readonly CHIP_GAP = 6;
121
-
122
- private chipResizeObserver?: ResizeObserver;
123
-
124
- private uniqueId = `select-${Math.random().toString(36).slice(2)}`;
125
-
126
- static styles = css`
127
- :host {
128
- color: var(--rel-text-default);
129
- display: block;
130
- font-family: var(--rel-fonts-font-family);
131
- width: 100%;
132
- }
133
-
134
- .select-wrapper {
135
- display: flex;
136
- flex-direction: column;
137
- gap: var(--rel-spacing-2xs);
138
- width: 100%;
139
- }
140
-
141
- /* Dims everything inside uniformly (label, trigger, icons, value text,
142
- supporting text) — matches rel-button's :host([disabled]) opacity
143
- approach instead of relying on one barely-different background tint. */
144
- .select-wrapper.disabled {
145
- opacity: 0.5;
146
- }
147
-
148
- .select-label {
149
- color: var(--rel-select-label-color, var(--rel-text-label));
150
-
151
- font-family: var(--rel-body-sm-regular-font-family);
152
-
153
- font-weight: var(--rel-body-sm-regular-font-weight);
154
-
155
- font-size: var(--rel-body-sm-regular-font-size);
156
-
157
- line-height: var(--rel-body-sm-regular-line-height);
158
-
159
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
160
-
161
- }
162
-
163
- /* ── labelStyle="none" ──
164
- No label renders. An empty spacer holds the label row's full height so the
165
- trigger still bottom-aligns with labelled siblings in a form row.
166
- The reserve covers label line-height + the wrapper gap, and the negative
167
- margin cancels the flex gap that would otherwise sit below it — so
168
- --rel-field-label-reserve: 0 collapses the row to genuinely zero
169
- (toolbars, table cells), not just to the leftover gap. */
170
- .label-reserve {
171
- flex: none;
172
- height: var(
173
- --rel-field-label-reserve,
174
- calc(var(--rel-body-sm-regular-line-height) + var(--rel-spacing-2xs))
175
- );
176
- margin-bottom: calc(-1 * var(--rel-spacing-2xs));
177
- }
178
-
179
- /* Error only colors the border/focus ring and the supporting text below
180
- the field — label, floating label, placeholder, and icons stay normal. */
181
-
182
- /* Container */
183
- .select-container {
184
- position: relative;
185
- }
186
-
187
- /* Trigger button — visually a text field, semantically a combobox button */
188
- .select-trigger {
189
- display: flex;
190
- align-items: center;
191
- width: 100%;
192
- box-sizing: border-box;
193
- gap: 8px;
194
- background: var(--rel-select-bg, var(--rel-bg-neutral));
195
- border: 1px solid var(--rel-select-border-default, var(--rel-border-default));
196
- cursor: pointer;
197
- font-family: inherit;
198
- transition: border-color 0.15s ease;
199
- text-align: left;
200
- }
201
-
202
- .border-rounded .select-trigger {
203
- border-radius: var(--rel-select-radius, var(--rel-border-radius-xs));
204
- }
205
-
206
- .border-sharp .select-trigger {
207
- border-radius: 0;
208
- }
209
-
210
- .select-trigger:focus-visible,
211
- .select-wrapper.open .select-trigger {
212
- outline: none;
213
- border-color: var(--rel-select-border-focus, var(--rel-border-strong));
214
- }
215
-
216
- .select-wrapper.error .select-trigger {
217
- border-color: var(--rel-select-border-error, var(--rel-border-danger));
218
- }
219
-
220
- .select-wrapper.disabled .select-trigger {
221
- cursor: not-allowed;
222
- background: var(--rel-select-bg-disabled, var(--rel-bg-neutral-elevated-1));
223
- border-color: var(--rel-select-border-default, var(--rel-border-default));
224
- }
225
-
226
- /* Sizes */
227
- .select-wrapper.small .select-trigger {
228
- padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
229
- min-height: 40px;
230
- font-family: var(--rel-body-sm-regular-font-family);
231
- font-weight: var(--rel-body-sm-regular-font-weight);
232
- font-size: var(--rel-body-sm-regular-font-size);
233
- line-height: var(--rel-body-sm-regular-line-height);
234
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
235
- }
236
-
237
- .select-wrapper.medium .select-trigger {
238
- padding: 12px;
239
- min-height: 48px;
240
- font-family: var(--rel-body-md-regular-font-family);
241
- font-weight: var(--rel-body-md-regular-font-weight);
242
- font-size: var(--rel-body-md-regular-font-size);
243
- line-height: var(--rel-body-md-regular-line-height);
244
- letter-spacing: var(--rel-body-md-regular-letter-spacing);
245
- }
246
-
247
- .select-wrapper.large .select-trigger {
248
- padding: var(--rel-spacing-md);
249
- min-height: 56px;
250
- font-family: var(--rel-body-lg-regular-font-family);
251
- font-weight: var(--rel-body-lg-regular-font-weight);
252
- font-size: var(--rel-body-lg-regular-font-size);
253
- line-height: var(--rel-body-lg-regular-line-height);
254
- letter-spacing: var(--rel-body-lg-regular-letter-spacing);
255
- }
256
-
257
- /* Icons */
258
- .select-trigger .icon {
259
- display: flex;
260
- align-items: center;
261
- flex-shrink: 0;
262
- color: var(--rel-select-icon-color, var(--rel-text-default));
263
- }
264
-
265
- .select-trigger .icon.chevron {
266
- transition: transform 0.15s ease;
267
- }
268
-
269
- .select-wrapper.open .icon.chevron {
270
- transform: rotate(180deg);
271
- }
272
-
273
- .select-wrapper.disabled .icon {
274
- color: var(--rel-text-disabled);
275
- }
276
-
277
- /* Value area */
278
- .select-value {
279
- flex: 1;
280
- min-width: 0;
281
- display: flex;
282
- align-items: center;
283
- gap: 6px;
284
- flex-wrap: nowrap;
285
- color: var(--rel-select-value-color, var(--rel-text-default));
286
- overflow: hidden;
287
- }
288
-
289
- .select-value.placeholder {
290
- color: var(--rel-select-placeholder-color, var(--rel-text-placeholder));
291
- }
292
-
293
- .select-wrapper.disabled .select-value.placeholder {
294
- color: var(--rel-select-placeholder-disabled-color, var(--rel-text-disabled));
295
- }
296
-
297
- .select-value-text {
298
- overflow: hidden;
299
- text-overflow: ellipsis;
300
- white-space: nowrap;
301
- }
302
-
303
- /* Chips never wrap — the number of rendered chips adapts to the
304
- available width instead (see computeVisibleChips), collapsing the
305
- remainder into a trailing "+N" chip. overflow:hidden guards the one
306
- frame between a resize and the recomputation catching up. */
307
- .select-chips {
308
- display: flex;
309
- align-items: center;
310
- gap: 6px;
311
- flex-wrap: nowrap;
312
- min-width: 0;
313
- overflow: hidden;
314
- }
315
-
316
- /* Invisible copy of ALL selected chips (plus a worst-case "+N" chip),
317
- laid out unconstrained so computeVisibleChips can read each chip's
318
- natural width — including chips currently collapsed into "+N", whose
319
- widths are needed again when the field grows. */
320
- .select-chips-measure {
321
- position: absolute;
322
- top: 0;
323
- left: 0;
324
- display: flex;
325
- align-items: center;
326
- gap: 6px;
327
- width: max-content;
328
- visibility: hidden;
329
- pointer-events: none;
330
- }
331
-
332
- /* rel-chip's outer element is a tabindex="0" span, not a native button —
333
- a disabled native <button> ancestor doesn't suppress clicks on that
334
- automatically the way it would for a nested native form control, so
335
- this has to be switched off explicitly. The cursor still correctly
336
- falls through to the disabled trigger underneath. */
337
- .select-wrapper.disabled .select-chips {
338
- pointer-events: none;
339
- }
340
-
341
- /* Floating label — single element that animates between resting (inside,
342
- at the value position) and floated (small, on the border line), same
343
- mechanism as rel-textfield's [floatinglabel]. */
344
- .floating-label {
345
- position: absolute;
346
- left: 12px;
347
- top: 50%;
348
- transform: translateY(-50%);
349
- background: var(--rel-select-bg, var(--rel-bg-neutral));
350
- padding: 0 4px;
351
- color: var(--rel-select-placeholder-color, var(--rel-text-placeholder));
352
- pointer-events: none;
353
- z-index: 1;
354
- white-space: nowrap;
355
- transition: top 0.15s ease, left 0.15s ease, font-size 0.15s ease, color 0.15s ease;
356
- font-family: var(--rel-body-md-regular-font-family);
357
- font-weight: var(--rel-body-md-regular-font-weight);
358
- font-size: var(--rel-body-md-regular-font-size);
359
- line-height: var(--rel-body-md-regular-line-height);
360
- letter-spacing: var(--rel-body-md-regular-letter-spacing);
361
- }
362
-
363
- .floating-label.has-left-icon {
364
- left: 40px;
365
- }
366
-
367
- .select-wrapper.focused .floating-label,
368
- .select-wrapper.has-value .floating-label,
369
- .select-wrapper.open .floating-label {
370
- top: 0;
371
- left: 12px;
372
- font-size: var(--rel-body-sm-medium-font-size);
373
- line-height: var(--rel-body-sm-medium-line-height);
374
- letter-spacing: var(--rel-body-sm-medium-letter-spacing);
375
- font-weight: var(--rel-body-xs-medium-font-weight);
376
- color: var(--rel-select-label-color, var(--rel-text-label));
377
- }
378
-
379
- /* The real placeholder hides while the float-label rests in its spot,
380
- and fades in once the label has moved up to the border. */
381
- .select-wrapper.floating .select-value.placeholder {
382
- opacity: 0;
383
- transition: opacity 0.1s ease;
384
- }
385
-
386
- .select-wrapper.floating.focused .select-value.placeholder,
387
- .select-wrapper.floating.open .select-value.placeholder {
388
- opacity: 1;
389
- }
390
-
391
- /* Supporting text */
392
- .select-support {
393
- margin-top: var(--rel-spacing-3xs);
394
- font-size: 13px;
395
- color: var(--rel-select-supporting-color, var(--rel-text-caption));
396
- }
397
-
398
- .select-support.error {
399
- color: var(--rel-text-danger);
400
- }
401
-
402
- /* ── Dropdown listbox ── */
403
- .select-listbox {
404
- position: fixed;
405
- z-index: 1001;
406
- display: none;
407
- box-sizing: border-box;
408
- background: var(--rel-select-bg, var(--rel-bg-neutral));
409
- border: 1px solid var(--rel-select-menu-border, var(--rel-border-default));
410
- border-radius: var(--rel-select-menu-radius, var(--rel-border-radius-sm));
411
- /* A border alone doesn't read clearly against a similarly-light page,
412
- and a shadow alone barely shows on a dark background — together
413
- they hold up in both themes. */
414
- box-shadow: var(--rel-select-menu-shadow, var(--rel-shadows-shadow-300));
415
- max-height: 360px;
416
- overflow-y: auto;
417
- }
418
-
419
- .select-listbox[data-open] {
420
- display: block;
421
- }
422
-
423
- .select-search {
424
- position: sticky;
425
- top: 0;
426
- background: var(--rel-select-bg, var(--rel-bg-neutral));
427
- padding: var(--rel-spacing-xs);
428
- border-bottom: 1px solid var(--rel-select-divider, var(--rel-border-subtle));
429
- }
430
-
431
- /* Base = "small" textfield sizing, shared by both small AND medium
432
- select — only "large" select bumps the search field up to "medium". */
433
- .search-input-wrap {
434
- display: flex;
435
- align-items: center;
436
- gap: 8px;
437
- border: 1px solid var(--rel-select-search-border, var(--rel-border-default));
438
- border-radius: 4px;
439
- padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
440
- }
441
-
442
- .search-input-wrap .icon {
443
- display: flex;
444
- align-items: center;
445
- flex-shrink: 0;
446
- color: var(--rel-select-icon-color, var(--rel-text-default));
447
- }
448
-
449
- .search-input {
450
- flex: 1;
451
- min-width: 0;
452
- border: none;
453
- outline: none;
454
- background: transparent;
455
- color: var(--rel-select-value-color, var(--rel-text-default));
456
- font-family: var(--rel-body-sm-regular-font-family);
457
- font-weight: var(--rel-body-sm-regular-font-weight);
458
- font-size: var(--rel-body-sm-regular-font-size);
459
- line-height: var(--rel-body-sm-regular-line-height);
460
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
461
- }
462
-
463
- .search-input::placeholder {
464
- color: var(--rel-select-placeholder-color, var(--rel-text-placeholder));
465
- }
466
-
467
- :host([size="large"]) .search-input-wrap {
468
- padding: 12px;
469
- }
470
-
471
- :host([size="large"]) .search-input {
472
-
473
- font-family: var(--rel-body-md-regular-font-family);
474
-
475
- font-weight: var(--rel-body-md-regular-font-weight);
476
-
477
- font-size: var(--rel-body-md-regular-font-size);
478
-
479
- line-height: var(--rel-body-md-regular-line-height);
480
-
481
- letter-spacing: var(--rel-body-md-regular-letter-spacing);
482
-
483
- }
484
-
485
- .select-options {
486
- padding: 4px 0;
487
- }
488
-
489
- .select-option {
490
- display: flex;
491
- align-items: center;
492
- justify-content: space-between;
493
- gap: 12px;
494
- padding: var(--rel-spacing-xs) 12px;
495
- color: var(--rel-select-value-color, var(--rel-text-default));
496
- cursor: pointer;
497
- font-family: var(--rel-body-md-regular-font-family);
498
- font-weight: var(--rel-body-md-regular-font-weight);
499
- font-size: var(--rel-body-md-regular-font-size);
500
- line-height: var(--rel-body-md-regular-line-height);
501
- letter-spacing: var(--rel-body-md-regular-letter-spacing);
502
- }
503
-
504
- /* Groups the optional prefix icon with the label so the trailing
505
- check indicator stays pinned to the right edge of the row. */
506
- .select-option-content {
507
- flex: 1;
508
- min-width: 0;
509
- display: flex;
510
- align-items: center;
511
- gap: var(--rel-spacing-xs);
512
- }
513
-
514
- .select-option-icon {
515
- flex-shrink: 0;
516
- color: var(--rel-select-icon-color, var(--rel-text-default));
517
- }
518
-
519
- .select-option-label {
520
- overflow: hidden;
521
- text-overflow: ellipsis;
522
- white-space: nowrap;
523
- }
524
-
525
- .select-option-check {
526
- flex-shrink: 0;
527
- color: var(--rel-select-value-color, var(--rel-text-default));
528
- }
529
-
530
- .select-option:hover,
531
- .select-option.highlighted {
532
- background: var(--rel-select-option-hover, var(--rel-bg-neutral-elevated-1));
533
- }
534
-
535
- .select-option[aria-selected="true"] {
536
- font-weight: var(--rel-body-md-medium-font-weight);
537
- }
538
-
539
- :host([size="large"]) .select-option[aria-selected="true"] {
540
- font-weight: var(--rel-body-lg-medium-font-weight);
541
- }
542
-
543
- .checkbox-option {
544
- padding: 12px;
545
- }
546
-
547
- .checkbox-option:hover,
548
- .checkbox-option.highlighted {
549
- background: var(--rel-select-option-hover, var(--rel-bg-neutral-elevated-1));
550
- }
551
-
552
- .select-all-row {
553
- border-bottom: 1px solid var(--rel-select-divider, var(--rel-border-subtle));
554
- padding: 12px;
555
- }
556
-
557
- .select-empty {
558
- padding: var(--rel-spacing-xs) 12px;
559
- color: var(--rel-select-placeholder-color, var(--rel-text-body));
560
- font-family: var(--rel-body-sm-regular-font-family);
561
- font-weight: var(--rel-body-sm-regular-font-weight);
562
- font-size: var(--rel-body-sm-regular-font-size);
563
- line-height: var(--rel-body-sm-regular-line-height);
564
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
565
- }
566
-
567
- `;
568
-
569
- connectedCallback() {
570
- super.connectedCallback();
571
- // firstUpdated only runs once — re-observe if the element is re-attached.
572
- if (this.chipResizeObserver && this.triggerEl) {
573
- this.chipResizeObserver.observe(this.triggerEl);
574
- }
575
- }
576
-
577
- disconnectedCallback() {
578
- super.disconnectedCallback();
579
- this.removeGlobalListeners();
580
- this.chipResizeObserver?.disconnect();
581
- }
582
-
583
- protected firstUpdated() {
584
- this.chipResizeObserver = new ResizeObserver(() => this.computeVisibleChips());
585
- this.chipResizeObserver.observe(this.triggerEl);
586
- }
587
-
588
- protected willUpdate(changed: PropertyValues<this>) {
589
- if (changed.has("multiple") && this.multiple === false) {
590
- this.selectedValues = [];
591
- }
592
- }
593
-
594
- private _warnedNoName = false;
595
-
596
- protected updated(changed: PropertyValues<this>) {
597
- if (
598
- !this._warnedNoName &&
599
- this.labelStyle === "none" &&
600
- !this.fieldAriaLabel &&
601
- !this.fieldAriaLabelledby &&
602
- !this.getAttribute("aria-label")
603
- ) {
604
- this._warnedNoName = true;
605
- console.warn(
606
- '<rel-select label-style="none"> has no accessible name. Set ' +
607
- "`field-aria-label` or `field-aria-labelledby` — a placeholder is not a " +
608
- "valid naming mechanism.",
609
- this
610
- );
611
- }
612
-
613
- if (
614
- changed.has("selectedValues") ||
615
- changed.has("options") ||
616
- changed.has("multiple") ||
617
- changed.has("size")
618
- ) {
619
- // rAF: rel-chip renders in its own (microtask) update cycle, so its
620
- // width isn't measurable synchronously here — by the next frame it is.
621
- requestAnimationFrame(() => this.computeVisibleChips());
622
- }
623
- }
624
-
625
- /**
626
- * Width-adaptive chip collapsing: measures every selected chip in the
627
- * hidden .select-chips-measure row and works out how many fit in the value
628
- * area, always reserving room for the trailing "+N" chip when not all fit.
629
- * At least one chip stays visible no matter how narrow the field gets.
630
- */
631
- private computeVisibleChips() {
632
- if (!this.isConnected || !this.multiple || this.selectedValues.length === 0) return;
633
- const measure = this.chipsMeasureEl;
634
- const container = this.valueEl;
635
- if (!measure || !container) return;
636
-
637
- const available = container.clientWidth;
638
- if (available <= 0) return; // hidden (display:none) — RO will refire when visible
639
-
640
- const children = Array.from(measure.children) as HTMLElement[];
641
- if (children.length < 2) return;
642
-
643
- const gap = RelSelect.CHIP_GAP;
644
- // Last measure child is the worst-case "+N" chip (N = total selected,
645
- // i.e. the widest label the badge can ever need).
646
- const overflowWidth = children[children.length - 1].offsetWidth;
647
- const chipWidths = children.slice(0, -1).map((c) => c.offsetWidth);
648
- const total = chipWidths.reduce((sum, w) => sum + w, 0) + gap * (chipWidths.length - 1);
649
-
650
- let fit: number;
651
- if (total <= available) {
652
- fit = chipWidths.length;
653
- } else {
654
- fit = 0;
655
- let used = 0;
656
- for (let i = 0; i < chipWidths.length; i++) {
657
- const w = chipWidths[i] + (i > 0 ? gap : 0);
658
- if (used + w + gap + overflowWidth <= available) {
659
- used += w;
660
- fit = i + 1;
661
- } else {
662
- break;
663
- }
664
- }
665
- fit = Math.max(1, fit);
666
- }
667
-
668
- if (fit !== this.visibleChipCount) this.visibleChipCount = fit;
669
- }
670
-
671
- private get normalizedOptions(): NormalizedOption[] {
672
- return this.options.map(normalizeOption);
673
- }
674
-
675
- /** Options matching the current search text (substring match, case-insensitive). */
676
- private get filteredOptions(): NormalizedOption[] {
677
- const opts = this.normalizedOptions;
678
- if (!this.searchValue) return opts;
679
- const v = this.searchValue.toLowerCase();
680
- return opts.filter((o) => o.label.toLowerCase().includes(v));
681
- }
682
-
683
- private get hasValue(): boolean {
684
- return this.multiple ? this.selectedValues.length > 0 : !!this.selected;
685
- }
686
-
687
- /** Icon size scales with the select's own size, matching the trigger. */
688
- private get iconSize(): "xs" | "sm" | "md" {
689
- return this.size === "small" ? "xs" : this.size === "large" ? "md" : "sm";
690
- }
691
-
692
- /** The search field uses "small" sizing for both small and medium select — only "large" select bumps it to "medium". */
693
- private get searchIconSize(): "sm" | "md" {
694
- return this.size === "large" ? "md" : "sm";
695
- }
696
-
697
- private get selectAllStatus(): "checked" | "unchecked" | "indeterminate" {
698
- const filtered = this.filteredOptions;
699
- if (filtered.length === 0) return "unchecked";
700
- const selectedCount = filtered.filter((o) => this.selectedValues.includes(o.value)).length;
701
- if (selectedCount === 0) return "unchecked";
702
- if (selectedCount === filtered.length) return "checked";
703
- return "indeterminate";
704
- }
705
-
706
- private addGlobalListeners() {
707
- document.addEventListener("pointerdown", this.onOutsidePointerDown);
708
- window.addEventListener("resize", this.updatePosition);
709
- window.addEventListener("scroll", this.onPageScroll, true);
710
- }
711
-
712
- private removeGlobalListeners() {
713
- document.removeEventListener("pointerdown", this.onOutsidePointerDown);
714
- window.removeEventListener("resize", this.updatePosition);
715
- window.removeEventListener("scroll", this.onPageScroll, true);
716
- }
717
-
718
- private onOutsidePointerDown = (e: PointerEvent) => {
719
- if (!this.open) return;
720
- const path = e.composedPath();
721
- if (path.includes(this)) return;
722
- this.closeListbox();
723
- };
724
-
725
- // Continuously re-deriving a fixed-position panel's coordinates from
726
- // every page scroll event is fragile (it's what was causing the panel to
727
- // jump to the top of the viewport). Instead, close the panel on any
728
- // *outside* scroll, same as clicking outside — scrolling the option list
729
- // itself (target is the listbox) is the one case that should NOT close it.
730
- private onPageScroll = (e: Event) => {
731
- if (!this.open) return;
732
- if (e.composedPath()[0] === this.listboxEl) return;
733
- this.closeListbox();
734
- };
735
-
736
- private emitChange() {
737
- this.dispatchEvent(
738
- new CustomEvent("changed", {
739
- detail: { value: this.multiple ? this.selectedValues : this.selected },
740
- bubbles: true,
741
- composed: true,
742
- })
743
- );
744
- }
745
-
746
- private openListbox() {
747
- if (this.state === "disabled" || this.open) return;
748
- this.open = true;
749
- this.searchValue = "";
750
- // No pre-highlight — the first item showing a hover-style highlight
751
- // before the user has touched anything reads as an unintended default.
752
- // Highlight only kicks in once they actually hover or press an arrow key.
753
- this.highlightedIndex = -1;
754
- this.addGlobalListeners();
755
- requestAnimationFrame(() => {
756
- this.updatePosition();
757
- // preventScroll: focusing the search field must not let the browser
758
- // scroll the page into view — that would move the trigger (normal
759
- // flow) out from under the panel (fixed position, already computed
760
- // above), breaking the anchor. When there's no search field, focus
761
- // simply stays on the trigger, which already has the nav keydown
762
- // handling wired up for this case.
763
- if (!this.noSearch) {
764
- this.searchInputEl?.focus({ preventScroll: true });
765
- }
766
- });
767
- }
768
-
769
- private closeListbox() {
770
- if (!this.open) return;
771
- this.open = false;
772
- this.removeGlobalListeners();
773
- this.triggerEl?.focus();
774
- }
775
-
776
- private updatePosition = () => {
777
- if (!this.triggerEl || !this.listboxEl) return;
778
-
779
- this.listboxEl.setAttribute("data-open", "");
780
-
781
- const t = this.triggerEl.getBoundingClientRect();
782
- const c = this.listboxEl.getBoundingClientRect();
783
-
784
- let top = t.bottom + 4;
785
- if (top + c.height > window.innerHeight - 8) {
786
- top = Math.max(8, t.top - c.height - 4);
787
- }
788
-
789
- // Use t.width (the trigger's width, which is also what we set the panel's
790
- // own width to below) rather than c.width — c.width reflects the
791
- // *previous* render's panelStyle, so on the very next measurement it can
792
- // be a frame stale, nudging left by a few px even when there's no real
793
- // overflow. Checking against t.width directly removes that drift.
794
- let left = t.left;
795
- if (left + t.width > window.innerWidth - 8) {
796
- left = Math.max(8, window.innerWidth - t.width - 8);
797
- }
798
-
799
- this.panelStyle = `top:${top}px;left:${left}px;width:${t.width}px;`;
800
- };
801
-
802
- private selectSingleOption(option: NormalizedOption) {
803
- this.selected = option.value;
804
- if (this.state === "error") this.state = "default";
805
- this.closeListbox();
806
- this.emitChange();
807
- }
808
-
809
- private toggleMultiOption(option: NormalizedOption) {
810
- const idx = this.selectedValues.indexOf(option.value);
811
- this.selectedValues =
812
- idx >= 0
813
- ? this.selectedValues.filter((v) => v !== option.value)
814
- : [...this.selectedValues, option.value];
815
- if (this.state === "error") this.state = "default";
816
- this.emitChange();
817
- }
818
-
819
- private toggleSelectAll() {
820
- const filtered = this.filteredOptions;
821
- const status = this.selectAllStatus;
822
- if (status === "checked") {
823
- const filteredValues = new Set(filtered.map((o) => o.value));
824
- this.selectedValues = this.selectedValues.filter((v) => !filteredValues.has(v));
825
- } else {
826
- const toAdd = filtered.map((o) => o.value).filter((v) => !this.selectedValues.includes(v));
827
- this.selectedValues = [...this.selectedValues, ...toAdd];
828
- }
829
- if (this.state === "error") this.state = "default";
830
- this.emitChange();
831
- }
832
-
833
- private removeChip(value: string) {
834
- if (this.state === "disabled") return;
835
- this.selectedValues = this.selectedValues.filter((v) => v !== value);
836
- this.emitChange();
837
- }
838
-
839
- private onTriggerClick() {
840
- if (this.open) this.closeListbox();
841
- else this.openListbox();
842
- }
843
-
844
- private onTriggerKeyDown(e: KeyboardEvent) {
845
- if (this.state === "disabled") return;
846
- if (!this.open) {
847
- if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
848
- e.preventDefault();
849
- this.openListbox();
850
- }
851
- return;
852
- }
853
- // No search field to hold focus/keydown in this mode — the trigger
854
- // itself keeps focus while open, so it needs the same nav handling.
855
- if (this.noSearch) this.handleListNavigation(e);
856
- }
857
-
858
- private onSearchInput = (e: InputEvent) => {
859
- this.searchValue = (e.target as HTMLInputElement).value;
860
- this.highlightedIndex = this.filteredOptions.length > 0 ? 0 : -1;
861
- requestAnimationFrame(() => this.updatePosition());
862
- };
863
-
864
- private onSearchKeyDown(e: KeyboardEvent) {
865
- this.handleListNavigation(e);
866
- }
867
-
868
- private handleListNavigation(e: KeyboardEvent) {
869
- const filtered = this.filteredOptions;
870
- switch (e.key) {
871
- case "ArrowDown":
872
- e.preventDefault();
873
- this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
874
- break;
875
- case "ArrowUp":
876
- e.preventDefault();
877
- this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
878
- break;
879
- case "Enter":
880
- e.preventDefault();
881
- if (this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
882
- const opt = filtered[this.highlightedIndex];
883
- if (this.multiple) this.toggleMultiOption(opt);
884
- else this.selectSingleOption(opt);
885
- }
886
- break;
887
- case "Escape":
888
- e.preventDefault();
889
- this.closeListbox();
890
- break;
891
- case "Tab":
892
- this.closeListbox();
893
- break;
894
- }
895
- }
896
-
897
- private renderChips() {
898
- const values = this.selectedValues;
899
- const visible = values.slice(0, Math.min(this.visibleChipCount, this.maxVisibleChips));
900
- const overflow = values.length - visible.length;
901
- const opts = this.normalizedOptions;
902
-
903
- return html`
904
- <span class="select-chips">
905
- ${repeat(
906
- visible,
907
- (v) => v,
908
- (v) => {
909
- const opt = opts.find((o) => o.value === v);
910
- // Keyed by value: rel-chip tracks its own "removed" flag
911
- // internally once its (x) is clicked, so reusing DOM nodes
912
- // positionally (plain .map()) could hand a freshly-shown value
913
- // a node that still remembers being removed as a *different*
914
- // value. Keying forces a fresh node per value instead.
915
- return html`
916
- <rel-chip
917
- label="${opt?.label ?? v}"
918
- size="sm"
919
- variant="subtle"
920
- border="default"
921
- ?removable=${this.state !== "disabled"}
922
- @click=${(e: Event) => e.stopPropagation()}
923
- @removed=${(e: Event) => {
924
- e.stopPropagation();
925
- this.removeChip(v);
926
- }}
927
- ></rel-chip>
928
- `;
929
- }
930
- )}
931
- ${overflow > 0
932
- ? html`<rel-chip label="+${overflow}" size="sm" variant="fill" border="default"></rel-chip>`
933
- : nothing}
934
- </span>
935
- `;
936
- }
937
-
938
- /**
939
- * Hidden, unconstrained copy of every selected chip used purely for width
940
- * measurement (see computeVisibleChips). Chips mirror the visible ones'
941
- * width-affecting props (removable adds the ✕ icon). The trailing chip is
942
- * the widest "+N" badge that could ever be needed.
943
- */
944
- private renderChipsMeasure() {
945
- const values = this.selectedValues;
946
- const opts = this.normalizedOptions;
947
- return html`
948
- <span class="select-chips-measure" aria-hidden="true">
949
- ${values.map((v) => {
950
- const opt = opts.find((o) => o.value === v);
951
- return html`
952
- <rel-chip
953
- label="${opt?.label ?? v}"
954
- size="sm"
955
- variant="subtle"
956
- border="default"
957
- ?removable=${this.state !== "disabled"}
958
- ></rel-chip>
959
- `;
960
- })}
961
- <rel-chip label="+${values.length}" size="sm" variant="fill" border="default"></rel-chip>
962
- </span>
963
- `;
964
- }
965
-
966
- render() {
967
- const opts = this.normalizedOptions;
968
- const filtered = this.filteredOptions;
969
- const isDisabled = this.state === "disabled";
970
- const labelHidden = this.labelStyle === "none";
971
- const showFloatingChrome = this.labelStyle === "floating" && !!this.label;
972
- // With no visible label the ARIA name comes from fieldAriaLabel instead.
973
- const accessibleName = (labelHidden ? this.fieldAriaLabel : this.label) || "";
974
-
975
- const wrapperClasses = [
976
- "select-wrapper",
977
- this.size,
978
- this.state,
979
- `border-${this.border}`,
980
- showFloatingChrome ? "floating" : "",
981
- this.internalState === "focused" ? "focused" : "",
982
- this.hasValue ? "has-value" : "",
983
- this.open ? "open" : "",
984
- ]
985
- .filter(Boolean)
986
- .join(" ");
987
-
988
- const listboxId = `${this.uniqueId}-listbox`;
989
- const activeOptionId =
990
- this.open && this.highlightedIndex >= 0 ? `${this.uniqueId}-option-${this.highlightedIndex}` : "";
991
-
992
- const selectedSingle = opts.find((o) => o.value === this.selected);
993
-
994
- return html`
995
- <div class="${wrapperClasses}">
996
- ${labelHidden
997
- ? html`<div class="label-reserve" aria-hidden="true"></div>`
998
- : !showFloatingChrome && this.label
999
- ? html`<label part="label" class="select-label" for="${this.uniqueId}">${this.label}</label>`
1000
- : ""}
1001
-
1002
- <div class="select-container">
1003
- ${showFloatingChrome
1004
- ? html`<span class="floating-label${this.leftIcon ? " has-left-icon" : ""}"
1005
- >${this.label}</span
1006
- >`
1007
- : ""}
1008
-
1009
- <button
1010
- id="${this.uniqueId}"
1011
- type="button"
1012
- part="trigger" class="select-trigger"
1013
- role="combobox"
1014
- aria-haspopup="listbox"
1015
- aria-expanded="${this.open}"
1016
- aria-controls="${listboxId}"
1017
- aria-activedescendant="${activeOptionId}"
1018
- aria-invalid="${this.state === "error"}"
1019
- aria-label="${this.fieldAriaLabelledby ? nothing : accessibleName || this.placeholder}"
1020
- aria-labelledby="${this.fieldAriaLabelledby ?? nothing}"
1021
- ?disabled="${isDisabled}"
1022
- @click="${this.onTriggerClick}"
1023
- @keydown="${this.onTriggerKeyDown}"
1024
- @focus="${() => (this.internalState = "focused")}"
1025
- @blur="${() => (this.internalState = "default")}"
1026
- >
1027
- ${this.leftIcon
1028
- ? html`<rel-icon name="${this.leftIcon}" size="${this.iconSize}" class="icon left"></rel-icon>`
1029
- : ""}
1030
-
1031
- ${this.multiple
1032
- ? this.selectedValues.length > 0
1033
- ? html`<span part="value" class="select-value">${this.renderChips()}</span>`
1034
- : html`<span part="value" class="select-value placeholder">
1035
- <span class="select-value-text">${this.placeholder}</span>
1036
- </span>`
1037
- : html`<span part="value" class="select-value ${selectedSingle ? "" : "placeholder"}">
1038
- <span class="select-value-text"
1039
- >${selectedSingle ? selectedSingle.label : this.placeholder}</span
1040
- >
1041
- </span>`}
1042
-
1043
- <rel-icon
1044
- name="${this.rightIcon || "chevron-down"}"
1045
- size="${this.iconSize}"
1046
- class="icon ${this.rightIcon ? "" : "chevron"}"
1047
- ></rel-icon>
1048
- </button>
1049
-
1050
- ${this.multiple && this.selectedValues.length > 0 ? this.renderChipsMeasure() : nothing}
1051
- </div>
1052
-
1053
- ${this.supportingText
1054
- ? html`<span part="helper" class="select-support ${this.state === "error" ? "error" : ""}">
1055
- ${this.supportingText}
1056
- </span>`
1057
- : ""}
1058
- </div>
1059
-
1060
- <div
1061
- id="${listboxId}"
1062
- part="panel list" class="select-listbox"
1063
- role="listbox"
1064
- aria-multiselectable="${this.multiple}"
1065
- aria-label="${accessibleName || "Options"}"
1066
- style="${this.panelStyle}"
1067
- ?data-open="${this.open}"
1068
- >
1069
- ${!this.noSearch
1070
- ? html`<div class="select-search">
1071
- <div class="search-input-wrap">
1072
- <rel-icon name="search" size="${this.searchIconSize}" class="icon"></rel-icon>
1073
- <input
1074
- part="input" class="search-input"
1075
- type="text"
1076
- placeholder="${this.searchPlaceholder}"
1077
- .value="${this.searchValue}"
1078
- @input="${this.onSearchInput}"
1079
- @keydown="${(e: KeyboardEvent) => this.onSearchKeyDown(e)}"
1080
- />
1081
- </div>
1082
- </div>`
1083
- : ""}
1084
-
1085
- ${filtered.length === 0
1086
- ? html`<div class="select-empty">
1087
- ${opts.length === 0 ? "No options available" : "No matches found"}
1088
- </div>`
1089
- : this.multiple
1090
- ? html`
1091
- <div class="select-all-row">
1092
- <rel-checkbox
1093
- label="${this.selectAllLabel}"
1094
- status="${this.selectAllStatus}"
1095
- @rel-change=${(e: Event) => {
1096
- e.stopPropagation();
1097
- this.toggleSelectAll();
1098
- }}
1099
- ></rel-checkbox>
1100
- </div>
1101
- <div class="select-options">
1102
- ${repeat(
1103
- filtered,
1104
- (option) => option.value,
1105
- (option, index) => html`
1106
- <div
1107
- id="${this.uniqueId}-option-${index}"
1108
- class="checkbox-option ${index === this.highlightedIndex ? "highlighted" : ""}"
1109
- role="option"
1110
- aria-selected="${this.selectedValues.includes(option.value)}"
1111
- @pointerenter="${() => (this.highlightedIndex = index)}"
1112
- >
1113
- <rel-checkbox
1114
- label="${option.label}"
1115
- icon="${option.icon}"
1116
- supportingtext="${option.supportingText}"
1117
- status="${this.selectedValues.includes(option.value) ? "checked" : "unchecked"}"
1118
- @rel-change=${(e: Event) => {
1119
- e.stopPropagation();
1120
- this.toggleMultiOption(option);
1121
- }}
1122
- ></rel-checkbox>
1123
- </div>
1124
- `
1125
- )}
1126
- </div>
1127
- `
1128
- : html`
1129
- <div class="select-options">
1130
- ${repeat(
1131
- filtered,
1132
- (option) => option.value,
1133
- (option, index) => html`
1134
- <div
1135
- id="${this.uniqueId}-option-${index}"
1136
- part="option" class="select-option ${index === this.highlightedIndex ? "highlighted" : ""}"
1137
- role="option"
1138
- aria-selected="${option.value === this.selected}"
1139
- @mousedown="${(e: Event) => e.preventDefault()}"
1140
- @click="${() => this.selectSingleOption(option)}"
1141
- @pointerenter="${() => (this.highlightedIndex = index)}"
1142
- >
1143
- <span class="select-option-content">
1144
- ${option.icon
1145
- ? html`<rel-icon
1146
- name="${option.icon}"
1147
- size="sm"
1148
- class="select-option-icon"
1149
- ></rel-icon>`
1150
- : nothing}
1151
- <span class="select-option-label">${option.label}</span>
1152
- </span>
1153
- ${option.value === this.selected
1154
- ? html`<rel-icon name="check" size="sm" class="select-option-check"></rel-icon>`
1155
- : ""}
1156
- </div>
1157
- `
1158
- )}
1159
- </div>
1160
- `}
1161
- </div>
1162
- `;
1163
- }
1164
- }
1165
-
1166
- declare global {
1167
- interface HTMLElementTagNameMap {
1168
- "rel-select": RelSelect;
1169
- }
1170
- }
1
+ import { LitElement, html, css, nothing, PropertyValues } from "lit";
2
+ import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { repeat } from "lit/directives/repeat.js";
4
+ import "@reliability-design/icons/rel-icon";
5
+ import "../rel-chip/rel-chip.js";
6
+ import "../rel-checkbox/rel-checkbox.js";
7
+ import type { FieldLabelStyle } from "../rel-textfield/rel-textfield.js";
8
+
9
+ export type SelectState = "default" | "error" | "disabled";
10
+ export type SelectSize = "small" | "medium" | "large";
11
+ export type SelectBorder = "rounded" | "sharp";
12
+ /** Alias of the shared field type — kept so existing imports keep resolving. */
13
+ export type SelectLabelStyle = FieldLabelStyle;
14
+
15
+ export interface SelectOptionObject {
16
+ value: string;
17
+ label?: string;
18
+ supportingText?: string;
19
+ /** rel-icon name rendered as a prefix (leading icon) on the menu item. */
20
+ icon?: string;
21
+ }
22
+ export type SelectOption = string | SelectOptionObject;
23
+
24
+ interface NormalizedOption {
25
+ value: string;
26
+ label: string;
27
+ supportingText: string;
28
+ icon: string;
29
+ }
30
+
31
+ function normalizeOption(o: SelectOption): NormalizedOption {
32
+ if (typeof o === "string") return { value: o, label: o, supportingText: "", icon: "" };
33
+ return {
34
+ value: o.value,
35
+ label: o.label ?? o.value,
36
+ supportingText: o.supportingText ?? "",
37
+ icon: o.icon ?? "",
38
+ };
39
+ }
40
+
41
+ @customElement("rel-select")
42
+ export class RelSelect extends LitElement {
43
+ @property({ type: String }) label: string = "";
44
+
45
+ // Lit's default attribute name is the property name fully lowercased (no
46
+ // hyphens) — e.g. labelStyle would otherwise only respond to "labelstyle".
47
+ // Every multi-word property below gets an explicit kebab-case `attribute`
48
+ // so it works the way HTML usage actually looks.
49
+ @property({ type: String, attribute: "label-style", reflect: true })
50
+ labelStyle: SelectLabelStyle = "default";
51
+
52
+ /**
53
+ * Accessible name for the trigger. Required when `labelStyle="none"`, since no
54
+ * `<label>` renders and a placeholder is not a valid naming mechanism.
55
+ */
56
+ @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
57
+ /** IDREF list of external element(s) naming the trigger. Wins over `fieldAriaLabel`. */
58
+ @property({ type: String, attribute: "field-aria-labelledby" }) fieldAriaLabelledby?: string;
59
+
60
+ @property({ type: Array }) options: SelectOption[] = [];
61
+
62
+ /** Enables multi-select: selected values render as removable chips and options as checkboxes. */
63
+ @property({ type: Boolean }) multiple: boolean = false;
64
+
65
+ /** Selected value — single-select mode only. */
66
+ @property({ type: String }) selected: string = "";
67
+
68
+ /** Selected values — multi-select mode only. */
69
+ @property({ type: Array, attribute: "selected-values" }) selectedValues: string[] = [];
70
+
71
+ @property({ type: String }) state: SelectState = "default";
72
+
73
+ @property({ type: String, attribute: "supporting-text" }) supportingText: string = "";
74
+
75
+ @property({ type: String, reflect: true }) size: SelectSize = "medium";
76
+
77
+ @property({ type: String }) border: SelectBorder = "rounded";
78
+
79
+ @property({ type: String, attribute: "left-icon" }) leftIcon: string = "";
80
+
81
+ @property({ type: String, attribute: "right-icon" }) rightIcon: string = "";
82
+
83
+ @property({ type: String }) placeholder: string = "Select option";
84
+
85
+ @property({ type: String, attribute: "search-placeholder" }) searchPlaceholder: string = "Search option";
86
+
87
+ @property({ type: String, attribute: "select-all-label" }) selectAllLabel: string = "Select all";
88
+
89
+ /**
90
+ * Optional hard cap on visible chips. By default (no cap) the visible
91
+ * count adapts to the trigger's width: chips collapse into the "+N" badge
92
+ * one by one instead of wrapping to a second line.
93
+ */
94
+ @property({ type: Number, attribute: "max-visible-chips" }) maxVisibleChips: number = Infinity;
95
+
96
+ // Boolean props default true and toggled off via ="false" are a classic Lit
97
+ // trap: for type:Boolean, attribute *presence* (not its string value)
98
+ // decides truthiness, so searchable="false" would still read as true. Named
99
+ // negatively instead, so the natural HTML idiom — just adding the bare
100
+ // attribute, no value — does what it looks like it does.
101
+ /** Hides the in-panel search field. Figma's "Single Option" dropdown has no search bar at all — use this for short, non-searchable lists. */
102
+ @property({ type: Boolean, attribute: "no-search" }) noSearch: boolean = false;
103
+
104
+ @state() private open = false;
105
+ @state() private highlightedIndex = -1;
106
+ /** Virtual highlight on the Select-All header row (multi mode). */
107
+ @state() private selectAllHighlighted = false;
108
+
109
+ private typeaheadBuffer = "";
110
+ private typeaheadTimer: number | undefined;
111
+ @state() private panelStyle = "";
112
+ @state() private searchValue = "";
113
+ @state() private internalState: "default" | "focused" = "default";
114
+
115
+ /** How many chips currently fit in the trigger width (width-adaptive). */
116
+ @state() private visibleChipCount = Infinity;
117
+
118
+ @query(".select-trigger") private triggerEl!: HTMLButtonElement;
119
+ @query(".search-input") private searchInputEl!: HTMLInputElement;
120
+ @query(".select-listbox") private listboxEl!: HTMLDivElement;
121
+ @query(".select-value") private valueEl!: HTMLElement;
122
+ @query(".select-chips-measure") private chipsMeasureEl!: HTMLElement;
123
+
124
+ /** Must match the `gap` of .select-chips / .select-chips-measure. */
125
+ private static readonly CHIP_GAP = 6;
126
+
127
+ private chipResizeObserver?: ResizeObserver;
128
+
129
+ private uniqueId = `select-${Math.random().toString(36).slice(2)}`;
130
+
131
+ static styles = css`
132
+ :host {
133
+ color: var(--rel-text-default);
134
+ display: block;
135
+ font-family: var(--rel-fonts-font-family);
136
+ width: 100%;
137
+ }
138
+
139
+ .select-wrapper {
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: var(--rel-spacing-2xs);
143
+ width: 100%;
144
+ }
145
+
146
+ /* Dims everything inside uniformly (label, trigger, icons, value text,
147
+ supporting text) — matches rel-button's :host([disabled]) opacity
148
+ approach instead of relying on one barely-different background tint. */
149
+ .select-wrapper.disabled {
150
+ opacity: 0.5;
151
+ }
152
+
153
+ .select-label {
154
+ color: var(--rel-select-label-color, var(--rel-text-label));
155
+
156
+ font-family: var(--rel-body-sm-regular-font-family);
157
+
158
+ font-weight: var(--rel-body-sm-regular-font-weight);
159
+
160
+ font-size: var(--rel-body-sm-regular-font-size);
161
+
162
+ line-height: var(--rel-body-sm-regular-line-height);
163
+
164
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
165
+
166
+ }
167
+
168
+ /* ── labelStyle="none" ──
169
+ No label renders. An empty spacer holds the label row's full height so the
170
+ trigger still bottom-aligns with labelled siblings in a form row.
171
+ The reserve covers label line-height + the wrapper gap, and the negative
172
+ margin cancels the flex gap that would otherwise sit below it — so
173
+ --rel-field-label-reserve: 0 collapses the row to genuinely zero
174
+ (toolbars, table cells), not just to the leftover gap. */
175
+ .label-reserve {
176
+ flex: none;
177
+ height: var(
178
+ --rel-field-label-reserve,
179
+ calc(var(--rel-body-sm-regular-line-height) + var(--rel-spacing-2xs))
180
+ );
181
+ margin-bottom: calc(-1 * var(--rel-spacing-2xs));
182
+ }
183
+
184
+ /* Error only colors the border/focus ring and the supporting text below
185
+ the field — label, floating label, placeholder, and icons stay normal. */
186
+
187
+ /* Container */
188
+ .select-container {
189
+ position: relative;
190
+ }
191
+
192
+ /* Trigger button — visually a text field, semantically a combobox button */
193
+ .select-trigger {
194
+ display: flex;
195
+ align-items: center;
196
+ width: 100%;
197
+ box-sizing: border-box;
198
+ gap: 8px;
199
+ background: var(--rel-select-bg, var(--rel-bg-neutral));
200
+ border: 1px solid var(--rel-select-border-default, var(--rel-border-default));
201
+ cursor: pointer;
202
+ font-family: inherit;
203
+ transition: border-color 0.15s ease;
204
+ text-align: left;
205
+ }
206
+
207
+ .border-rounded .select-trigger {
208
+ border-radius: var(--rel-select-radius, var(--rel-border-radius-xs));
209
+ }
210
+
211
+ .border-sharp .select-trigger {
212
+ border-radius: 0;
213
+ }
214
+
215
+ .select-trigger:focus-visible,
216
+ .select-wrapper.open .select-trigger {
217
+ outline: none;
218
+ border-color: var(--rel-select-border-focus, var(--rel-border-strong));
219
+ }
220
+
221
+ .select-wrapper.error .select-trigger {
222
+ border-color: var(--rel-select-border-error, var(--rel-border-danger));
223
+ }
224
+
225
+ .select-wrapper.disabled .select-trigger {
226
+ cursor: not-allowed;
227
+ background: var(--rel-select-bg-disabled, var(--rel-bg-neutral-elevated-1));
228
+ border-color: var(--rel-select-border-default, var(--rel-border-default));
229
+ }
230
+
231
+ /* Sizes */
232
+ .select-wrapper.small .select-trigger {
233
+ padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
234
+ min-height: 40px;
235
+ font-family: var(--rel-body-sm-regular-font-family);
236
+ font-weight: var(--rel-body-sm-regular-font-weight);
237
+ font-size: var(--rel-body-sm-regular-font-size);
238
+ line-height: var(--rel-body-sm-regular-line-height);
239
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
240
+ }
241
+
242
+ .select-wrapper.medium .select-trigger {
243
+ padding: 12px;
244
+ min-height: 48px;
245
+ font-family: var(--rel-body-md-regular-font-family);
246
+ font-weight: var(--rel-body-md-regular-font-weight);
247
+ font-size: var(--rel-body-md-regular-font-size);
248
+ line-height: var(--rel-body-md-regular-line-height);
249
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
250
+ }
251
+
252
+ .select-wrapper.large .select-trigger {
253
+ padding: var(--rel-spacing-md);
254
+ min-height: 56px;
255
+ font-family: var(--rel-body-lg-regular-font-family);
256
+ font-weight: var(--rel-body-lg-regular-font-weight);
257
+ font-size: var(--rel-body-lg-regular-font-size);
258
+ line-height: var(--rel-body-lg-regular-line-height);
259
+ letter-spacing: var(--rel-body-lg-regular-letter-spacing);
260
+ }
261
+
262
+ /* Icons */
263
+ .select-trigger .icon {
264
+ display: flex;
265
+ align-items: center;
266
+ flex-shrink: 0;
267
+ color: var(--rel-select-icon-color, var(--rel-text-default));
268
+ }
269
+
270
+ .select-trigger .icon.chevron {
271
+ transition: transform 0.15s ease;
272
+ }
273
+
274
+ .select-wrapper.open .icon.chevron {
275
+ transform: rotate(180deg);
276
+ }
277
+
278
+ .select-wrapper.disabled .icon {
279
+ color: var(--rel-text-disabled);
280
+ }
281
+
282
+ /* Value area */
283
+ .select-value {
284
+ flex: 1;
285
+ min-width: 0;
286
+ display: flex;
287
+ align-items: center;
288
+ gap: 6px;
289
+ flex-wrap: nowrap;
290
+ color: var(--rel-select-value-color, var(--rel-text-default));
291
+ overflow: hidden;
292
+ }
293
+
294
+ .select-value.placeholder {
295
+ color: var(--rel-select-placeholder-color, var(--rel-text-placeholder));
296
+ }
297
+
298
+ .select-wrapper.disabled .select-value.placeholder {
299
+ color: var(--rel-select-placeholder-disabled-color, var(--rel-text-disabled));
300
+ }
301
+
302
+ .select-value-text {
303
+ overflow: hidden;
304
+ text-overflow: ellipsis;
305
+ white-space: nowrap;
306
+ }
307
+
308
+ /* Chips never wrap — the number of rendered chips adapts to the
309
+ available width instead (see computeVisibleChips), collapsing the
310
+ remainder into a trailing "+N" chip. overflow:hidden guards the one
311
+ frame between a resize and the recomputation catching up. */
312
+ .select-chips {
313
+ display: flex;
314
+ align-items: center;
315
+ gap: 6px;
316
+ flex-wrap: nowrap;
317
+ min-width: 0;
318
+ overflow: hidden;
319
+ }
320
+
321
+ /* Invisible copy of ALL selected chips (plus a worst-case "+N" chip),
322
+ laid out unconstrained so computeVisibleChips can read each chip's
323
+ natural width — including chips currently collapsed into "+N", whose
324
+ widths are needed again when the field grows. */
325
+ .select-chips-measure {
326
+ position: absolute;
327
+ top: 0;
328
+ left: 0;
329
+ display: flex;
330
+ align-items: center;
331
+ gap: 6px;
332
+ width: max-content;
333
+ visibility: hidden;
334
+ pointer-events: none;
335
+ }
336
+
337
+ /* rel-chip's outer element is a tabindex="0" span, not a native button —
338
+ a disabled native <button> ancestor doesn't suppress clicks on that
339
+ automatically the way it would for a nested native form control, so
340
+ this has to be switched off explicitly. The cursor still correctly
341
+ falls through to the disabled trigger underneath. */
342
+ .select-wrapper.disabled .select-chips {
343
+ pointer-events: none;
344
+ }
345
+
346
+ /* Floating label — single element that animates between resting (inside,
347
+ at the value position) and floated (small, on the border line), same
348
+ mechanism as rel-textfield's [floatinglabel]. */
349
+ .floating-label {
350
+ position: absolute;
351
+ left: 12px;
352
+ top: 50%;
353
+ transform: translateY(-50%);
354
+ background: var(--rel-select-bg, var(--rel-bg-neutral));
355
+ padding: 0 4px;
356
+ color: var(--rel-select-placeholder-color, var(--rel-text-placeholder));
357
+ pointer-events: none;
358
+ z-index: 1;
359
+ white-space: nowrap;
360
+ transition: top 0.15s ease, left 0.15s ease, font-size 0.15s ease, color 0.15s ease;
361
+ font-family: var(--rel-body-md-regular-font-family);
362
+ font-weight: var(--rel-body-md-regular-font-weight);
363
+ font-size: var(--rel-body-md-regular-font-size);
364
+ line-height: var(--rel-body-md-regular-line-height);
365
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
366
+ }
367
+
368
+ .floating-label.has-left-icon {
369
+ left: 40px;
370
+ }
371
+
372
+ .select-wrapper.focused .floating-label,
373
+ .select-wrapper.has-value .floating-label,
374
+ .select-wrapper.open .floating-label {
375
+ top: 0;
376
+ left: 12px;
377
+ font-size: var(--rel-body-sm-medium-font-size);
378
+ line-height: var(--rel-body-sm-medium-line-height);
379
+ letter-spacing: var(--rel-body-sm-medium-letter-spacing);
380
+ font-weight: var(--rel-body-xs-medium-font-weight);
381
+ color: var(--rel-select-label-color, var(--rel-text-label));
382
+ }
383
+
384
+ /* The real placeholder hides while the float-label rests in its spot,
385
+ and fades in once the label has moved up to the border. */
386
+ .select-wrapper.floating .select-value.placeholder {
387
+ opacity: 0;
388
+ transition: opacity 0.1s ease;
389
+ }
390
+
391
+ .select-wrapper.floating.focused .select-value.placeholder,
392
+ .select-wrapper.floating.open .select-value.placeholder {
393
+ opacity: 1;
394
+ }
395
+
396
+ /* Supporting text */
397
+ .select-support {
398
+ margin-top: var(--rel-spacing-3xs);
399
+ font-size: 13px;
400
+ color: var(--rel-select-supporting-color, var(--rel-text-caption));
401
+ }
402
+
403
+ .select-support.error {
404
+ color: var(--rel-text-danger);
405
+ }
406
+
407
+ /* ── Dropdown listbox ── */
408
+ .select-listbox {
409
+ position: fixed;
410
+ z-index: 1001;
411
+ display: none;
412
+ /* Reset native [popover] UA styles (the listbox is promoted to the
413
+ top layer so transformed ancestors — e.g. a centered rel-modal
414
+ panel — can't hijack its containing block or clip it). */
415
+ inset: auto;
416
+ margin: 0;
417
+ padding: 0;
418
+ width: auto;
419
+ height: auto;
420
+ color: inherit;
421
+ box-sizing: border-box;
422
+ background: var(--rel-select-bg, var(--rel-bg-neutral));
423
+ border: 1px solid var(--rel-select-menu-border, var(--rel-border-default));
424
+ border-radius: var(--rel-select-menu-radius, var(--rel-border-radius-sm));
425
+ /* A border alone doesn't read clearly against a similarly-light page,
426
+ and a shadow alone barely shows on a dark background — together
427
+ they hold up in both themes. */
428
+ box-shadow: var(--rel-select-menu-shadow, var(--rel-shadows-shadow-300));
429
+ max-height: 360px;
430
+ overflow-y: auto;
431
+ }
432
+
433
+ .select-listbox[data-open] {
434
+ display: block;
435
+ }
436
+
437
+ /* Search and Select All pin together while the options scroll beneath.
438
+ z-index is load-bearing: multiselect rows contain rel-checkbox, whose
439
+ .row wrapper is position:relative (Tab-scroll fix), so without it the
440
+ positioned rows paint over the sticky header in DOM order. */
441
+ .select-header {
442
+ position: sticky;
443
+ top: 0;
444
+ z-index: 1;
445
+ background: var(--rel-select-bg, var(--rel-bg-neutral));
446
+ }
447
+
448
+ .select-search {
449
+ padding: var(--rel-spacing-xs);
450
+ border-bottom: 1px solid var(--rel-select-divider, var(--rel-border-subtle));
451
+ }
452
+
453
+ /* Base = "small" textfield sizing, shared by both small AND medium
454
+ select — only "large" select bumps the search field up to "medium". */
455
+ .search-input-wrap {
456
+ display: flex;
457
+ align-items: center;
458
+ gap: 8px;
459
+ border: 1px solid var(--rel-select-search-border, var(--rel-border-default));
460
+ border-radius: 4px;
461
+ padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
462
+ }
463
+
464
+ .search-input-wrap .icon {
465
+ display: flex;
466
+ align-items: center;
467
+ flex-shrink: 0;
468
+ color: var(--rel-select-icon-color, var(--rel-text-default));
469
+ }
470
+
471
+ .search-input {
472
+ flex: 1;
473
+ min-width: 0;
474
+ border: none;
475
+ outline: none;
476
+ background: transparent;
477
+ color: var(--rel-select-value-color, var(--rel-text-default));
478
+ font-family: var(--rel-body-sm-regular-font-family);
479
+ font-weight: var(--rel-body-sm-regular-font-weight);
480
+ font-size: var(--rel-body-sm-regular-font-size);
481
+ line-height: var(--rel-body-sm-regular-line-height);
482
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
483
+ }
484
+
485
+ .search-input::placeholder {
486
+ color: var(--rel-select-placeholder-color, var(--rel-text-placeholder));
487
+ }
488
+
489
+ :host([size="large"]) .search-input-wrap {
490
+ padding: 12px;
491
+ }
492
+
493
+ :host([size="large"]) .search-input {
494
+
495
+ font-family: var(--rel-body-md-regular-font-family);
496
+
497
+ font-weight: var(--rel-body-md-regular-font-weight);
498
+
499
+ font-size: var(--rel-body-md-regular-font-size);
500
+
501
+ line-height: var(--rel-body-md-regular-line-height);
502
+
503
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
504
+
505
+ }
506
+
507
+ .select-options {
508
+ padding: 4px 0;
509
+ }
510
+
511
+ .select-option {
512
+ display: flex;
513
+ align-items: center;
514
+ justify-content: space-between;
515
+ gap: 12px;
516
+ padding: var(--rel-spacing-xs) 12px;
517
+ color: var(--rel-select-value-color, var(--rel-text-default));
518
+ cursor: pointer;
519
+ font-family: var(--rel-body-md-regular-font-family);
520
+ font-weight: var(--rel-body-md-regular-font-weight);
521
+ font-size: var(--rel-body-md-regular-font-size);
522
+ line-height: var(--rel-body-md-regular-line-height);
523
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
524
+ }
525
+
526
+ /* Groups the optional prefix icon with the label so the trailing
527
+ check indicator stays pinned to the right edge of the row. */
528
+ .select-option-content {
529
+ flex: 1;
530
+ min-width: 0;
531
+ display: flex;
532
+ align-items: center;
533
+ gap: var(--rel-spacing-xs);
534
+ }
535
+
536
+ .select-option-icon {
537
+ flex-shrink: 0;
538
+ color: var(--rel-select-icon-color, var(--rel-text-default));
539
+ }
540
+
541
+ .select-option-label {
542
+ overflow: hidden;
543
+ text-overflow: ellipsis;
544
+ white-space: nowrap;
545
+ }
546
+
547
+ .select-option-check {
548
+ flex-shrink: 0;
549
+ color: var(--rel-select-value-color, var(--rel-text-default));
550
+ }
551
+
552
+ .select-option:hover,
553
+ .select-option.highlighted {
554
+ background: var(--rel-select-option-hover, var(--rel-bg-neutral-elevated-1));
555
+ }
556
+
557
+ .select-option[aria-selected="true"] {
558
+ font-weight: var(--rel-body-md-medium-font-weight);
559
+ }
560
+
561
+ :host([size="large"]) .select-option[aria-selected="true"] {
562
+ font-weight: var(--rel-body-lg-medium-font-weight);
563
+ }
564
+
565
+ .checkbox-option {
566
+ padding: 12px;
567
+ }
568
+
569
+ .checkbox-option:hover,
570
+ .checkbox-option.highlighted {
571
+ background: var(--rel-select-option-hover, var(--rel-bg-neutral-elevated-1));
572
+ }
573
+
574
+ .select-all-row {
575
+ border-bottom: 1px solid var(--rel-select-divider, var(--rel-border-subtle));
576
+ padding: 12px;
577
+ }
578
+
579
+ .select-all-row.highlighted {
580
+ background: var(--rel-select-option-hover, var(--rel-bg-neutral-elevated-1));
581
+ }
582
+
583
+ .select-empty {
584
+ padding: var(--rel-spacing-xs) 12px;
585
+ color: var(--rel-select-placeholder-color, var(--rel-text-body));
586
+ font-family: var(--rel-body-sm-regular-font-family);
587
+ font-weight: var(--rel-body-sm-regular-font-weight);
588
+ font-size: var(--rel-body-sm-regular-font-size);
589
+ line-height: var(--rel-body-sm-regular-line-height);
590
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
591
+ }
592
+
593
+ `;
594
+
595
+ connectedCallback() {
596
+ super.connectedCallback();
597
+ // firstUpdated only runs once — re-observe if the element is re-attached.
598
+ if (this.chipResizeObserver && this.triggerEl) {
599
+ this.chipResizeObserver.observe(this.triggerEl);
600
+ }
601
+ }
602
+
603
+ disconnectedCallback() {
604
+ super.disconnectedCallback();
605
+ this.removeGlobalListeners();
606
+ this.hideNativePopover();
607
+ this.chipResizeObserver?.disconnect();
608
+ }
609
+
610
+ protected firstUpdated() {
611
+ this.chipResizeObserver = new ResizeObserver(() => this.computeVisibleChips());
612
+ this.chipResizeObserver.observe(this.triggerEl);
613
+ }
614
+
615
+ protected willUpdate(changed: PropertyValues<this>) {
616
+ if (changed.has("multiple") && this.multiple === false) {
617
+ this.selectedValues = [];
618
+ }
619
+ }
620
+
621
+ private _warnedNoName = false;
622
+
623
+ protected updated(changed: PropertyValues<this>) {
624
+ if (
625
+ !this._warnedNoName &&
626
+ this.labelStyle === "none" &&
627
+ !this.fieldAriaLabel &&
628
+ !this.fieldAriaLabelledby &&
629
+ !this.getAttribute("aria-label")
630
+ ) {
631
+ this._warnedNoName = true;
632
+ console.warn(
633
+ '<rel-select label-style="none"> has no accessible name. Set ' +
634
+ "`field-aria-label` or `field-aria-labelledby` — a placeholder is not a " +
635
+ "valid naming mechanism.",
636
+ this
637
+ );
638
+ }
639
+
640
+ if (
641
+ changed.has("selectedValues") ||
642
+ changed.has("options") ||
643
+ changed.has("multiple") ||
644
+ changed.has("size")
645
+ ) {
646
+ // rAF: rel-chip renders in its own (microtask) update cycle, so its
647
+ // width isn't measurable synchronously here — by the next frame it is.
648
+ requestAnimationFrame(() => this.computeVisibleChips());
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Width-adaptive chip collapsing: measures every selected chip in the
654
+ * hidden .select-chips-measure row and works out how many fit in the value
655
+ * area, always reserving room for the trailing "+N" chip when not all fit.
656
+ * At least one chip stays visible no matter how narrow the field gets.
657
+ */
658
+ private computeVisibleChips() {
659
+ if (!this.isConnected || !this.multiple || this.selectedValues.length === 0) return;
660
+ const measure = this.chipsMeasureEl;
661
+ const container = this.valueEl;
662
+ if (!measure || !container) return;
663
+
664
+ const available = container.clientWidth;
665
+ if (available <= 0) return; // hidden (display:none) — RO will refire when visible
666
+
667
+ const children = Array.from(measure.children) as HTMLElement[];
668
+ if (children.length < 2) return;
669
+
670
+ const gap = RelSelect.CHIP_GAP;
671
+ // Last measure child is the worst-case "+N" chip (N = total selected,
672
+ // i.e. the widest label the badge can ever need).
673
+ const overflowWidth = children[children.length - 1].offsetWidth;
674
+ const chipWidths = children.slice(0, -1).map((c) => c.offsetWidth);
675
+ const total = chipWidths.reduce((sum, w) => sum + w, 0) + gap * (chipWidths.length - 1);
676
+
677
+ let fit: number;
678
+ if (total <= available) {
679
+ fit = chipWidths.length;
680
+ } else {
681
+ fit = 0;
682
+ let used = 0;
683
+ for (let i = 0; i < chipWidths.length; i++) {
684
+ const w = chipWidths[i] + (i > 0 ? gap : 0);
685
+ if (used + w + gap + overflowWidth <= available) {
686
+ used += w;
687
+ fit = i + 1;
688
+ } else {
689
+ break;
690
+ }
691
+ }
692
+ fit = Math.max(1, fit);
693
+ }
694
+
695
+ if (fit !== this.visibleChipCount) this.visibleChipCount = fit;
696
+ }
697
+
698
+ private get normalizedOptions(): NormalizedOption[] {
699
+ return this.options.map(normalizeOption);
700
+ }
701
+
702
+ /** Options matching the current search text (substring match, case-insensitive). */
703
+ private get filteredOptions(): NormalizedOption[] {
704
+ const opts = this.normalizedOptions;
705
+ if (!this.searchValue) return opts;
706
+ const v = this.searchValue.toLowerCase();
707
+ return opts.filter((o) => o.label.toLowerCase().includes(v));
708
+ }
709
+
710
+ private get hasValue(): boolean {
711
+ return this.multiple ? this.selectedValues.length > 0 : !!this.selected;
712
+ }
713
+
714
+ /** Icon size scales with the select's own size, matching the trigger. */
715
+ private get iconSize(): "xs" | "sm" | "md" {
716
+ return this.size === "small" ? "xs" : this.size === "large" ? "md" : "sm";
717
+ }
718
+
719
+ /** The search field uses "small" sizing for both small and medium select — only "large" select bumps it to "medium". */
720
+ private get searchIconSize(): "sm" | "md" {
721
+ return this.size === "large" ? "md" : "sm";
722
+ }
723
+
724
+ private get selectAllStatus(): "checked" | "unchecked" | "indeterminate" {
725
+ const filtered = this.filteredOptions;
726
+ if (filtered.length === 0) return "unchecked";
727
+ const selectedCount = filtered.filter((o) => this.selectedValues.includes(o.value)).length;
728
+ if (selectedCount === 0) return "unchecked";
729
+ if (selectedCount === filtered.length) return "checked";
730
+ return "indeterminate";
731
+ }
732
+
733
+ private addGlobalListeners() {
734
+ document.addEventListener("pointerdown", this.onOutsidePointerDown);
735
+ window.addEventListener("resize", this.updatePosition);
736
+ window.addEventListener("scroll", this.onPageScroll, true);
737
+ }
738
+
739
+ private removeGlobalListeners() {
740
+ document.removeEventListener("pointerdown", this.onOutsidePointerDown);
741
+ window.removeEventListener("resize", this.updatePosition);
742
+ window.removeEventListener("scroll", this.onPageScroll, true);
743
+ }
744
+
745
+ private onOutsidePointerDown = (e: PointerEvent) => {
746
+ if (!this.open) return;
747
+ const path = e.composedPath();
748
+ if (path.includes(this)) return;
749
+ this.closeListbox();
750
+ };
751
+
752
+ // Continuously re-deriving a fixed-position panel's coordinates from
753
+ // every page scroll event is fragile (it's what was causing the panel to
754
+ // jump to the top of the viewport). Instead, close the panel on any
755
+ // *outside* scroll, same as clicking outside — scrolling the option list
756
+ // itself (target is the listbox) is the one case that should NOT close it.
757
+ private onPageScroll = (e: Event) => {
758
+ if (!this.open) return;
759
+ if (e.composedPath()[0] === this.listboxEl) return;
760
+ this.closeListbox();
761
+ };
762
+
763
+ private emitChange() {
764
+ this.dispatchEvent(
765
+ new CustomEvent("changed", {
766
+ detail: { value: this.multiple ? this.selectedValues : this.selected },
767
+ bubbles: true,
768
+ composed: true,
769
+ })
770
+ );
771
+ }
772
+
773
+ private openListbox() {
774
+ if (this.state === "disabled" || this.open) return;
775
+ this.open = true;
776
+ this.searchValue = "";
777
+ // No pre-highlight — the first item showing a hover-style highlight
778
+ // before the user has touched anything reads as an unintended default.
779
+ // Highlight only kicks in once they actually hover or press an arrow key.
780
+ this.highlightedIndex = -1;
781
+ this.selectAllHighlighted = false;
782
+ this.addGlobalListeners();
783
+ requestAnimationFrame(() => {
784
+ this.showNativePopover();
785
+ this.updatePosition();
786
+ // preventScroll: focusing the search field must not let the browser
787
+ // scroll the page into view — that would move the trigger (normal
788
+ // flow) out from under the panel (fixed position, already computed
789
+ // above), breaking the anchor. When there's no search field, focus
790
+ // simply stays on the trigger, which already has the nav keydown
791
+ // handling wired up for this case.
792
+ if (!this.noSearch) {
793
+ this.searchInputEl?.focus({ preventScroll: true });
794
+ }
795
+ });
796
+ }
797
+
798
+ private closeListbox() {
799
+ if (!this.open) return;
800
+ this.open = false;
801
+ this.removeGlobalListeners();
802
+ this.hideNativePopover();
803
+ this.triggerEl?.focus();
804
+ }
805
+
806
+ /* A transformed ancestor (e.g. rel-modal's centered panel with
807
+ * translate(-50%,-50%)) becomes the containing block for position:fixed
808
+ * descendants, breaking viewport-based coordinates and letting the
809
+ * panel's overflow:hidden clip the listbox. Promoting the listbox to
810
+ * the browser top layer sidesteps both — same fix as rel-popover /
811
+ * rel-autocomplete. `manual` keeps light-dismiss and Escape handling
812
+ * under this component's own control. */
813
+
814
+ private showNativePopover() {
815
+ const p = this.listboxEl;
816
+ if (!p || typeof p.showPopover !== "function") return;
817
+ try {
818
+ if (!p.matches(":popover-open")) p.showPopover();
819
+ } catch {
820
+ /* older engines / detached element — fixed-position fallback applies */
821
+ }
822
+ }
823
+
824
+ private hideNativePopover() {
825
+ const p = this.listboxEl;
826
+ if (!p || typeof p.hidePopover !== "function") return;
827
+ try {
828
+ if (p.matches(":popover-open")) p.hidePopover();
829
+ } catch {
830
+ /* already hidden or unsupported — nothing to do */
831
+ }
832
+ }
833
+
834
+ private updatePosition = () => {
835
+ if (!this.triggerEl || !this.listboxEl) return;
836
+
837
+ this.listboxEl.setAttribute("data-open", "");
838
+
839
+ const t = this.triggerEl.getBoundingClientRect();
840
+ const c = this.listboxEl.getBoundingClientRect();
841
+
842
+ let top = t.bottom + 4;
843
+ if (top + c.height > window.innerHeight - 8) {
844
+ top = Math.max(8, t.top - c.height - 4);
845
+ }
846
+
847
+ // Use t.width (the trigger's width, which is also what we set the panel's
848
+ // own width to below) rather than c.width — c.width reflects the
849
+ // *previous* render's panelStyle, so on the very next measurement it can
850
+ // be a frame stale, nudging left by a few px even when there's no real
851
+ // overflow. Checking against t.width directly removes that drift.
852
+ let left = t.left;
853
+ if (left + t.width > window.innerWidth - 8) {
854
+ left = Math.max(8, window.innerWidth - t.width - 8);
855
+ }
856
+
857
+ // Without top-layer support a transformed ancestor may be the containing
858
+ // block for position:fixed, shifting the listbox away from the viewport
859
+ // coordinates computed above. Apply, re-measure, and cancel the offset.
860
+ this.listboxEl.style.top = `${top}px`;
861
+ this.listboxEl.style.left = `${left}px`;
862
+ this.listboxEl.style.width = `${t.width}px`;
863
+ const applied = this.listboxEl.getBoundingClientRect();
864
+ top -= applied.top - top;
865
+ left -= applied.left - left;
866
+ this.listboxEl.style.top = `${top}px`;
867
+ this.listboxEl.style.left = `${left}px`;
868
+
869
+ this.panelStyle = `top:${top}px;left:${left}px;width:${t.width}px;`;
870
+ };
871
+
872
+ private selectSingleOption(option: NormalizedOption) {
873
+ this.selected = option.value;
874
+ if (this.state === "error") this.state = "default";
875
+ this.closeListbox();
876
+ this.emitChange();
877
+ }
878
+
879
+ private toggleMultiOption(option: NormalizedOption) {
880
+ const idx = this.selectedValues.indexOf(option.value);
881
+ this.selectedValues =
882
+ idx >= 0
883
+ ? this.selectedValues.filter((v) => v !== option.value)
884
+ : [...this.selectedValues, option.value];
885
+ if (this.state === "error") this.state = "default";
886
+ this.emitChange();
887
+ }
888
+
889
+ private toggleSelectAll() {
890
+ const filtered = this.filteredOptions;
891
+ const status = this.selectAllStatus;
892
+ if (status === "checked") {
893
+ const filteredValues = new Set(filtered.map((o) => o.value));
894
+ this.selectedValues = this.selectedValues.filter((v) => !filteredValues.has(v));
895
+ } else {
896
+ const toAdd = filtered.map((o) => o.value).filter((v) => !this.selectedValues.includes(v));
897
+ this.selectedValues = [...this.selectedValues, ...toAdd];
898
+ }
899
+ if (this.state === "error") this.state = "default";
900
+ this.emitChange();
901
+ }
902
+
903
+ private removeChip(value: string) {
904
+ if (this.state === "disabled") return;
905
+ this.selectedValues = this.selectedValues.filter((v) => v !== value);
906
+ this.emitChange();
907
+ }
908
+
909
+ private onTriggerClick() {
910
+ if (this.open) this.closeListbox();
911
+ else this.openListbox();
912
+ }
913
+
914
+ private onTriggerKeyDown(e: KeyboardEvent) {
915
+ if (this.state === "disabled") return;
916
+
917
+ // Chips render inside the trigger <button>, so their remove buttons can
918
+ // never be tab stops — Backspace/Delete is the keyboard path for
919
+ // removing the most recent selection (standard multi-combobox behavior).
920
+ if (
921
+ this.multiple &&
922
+ (e.key === "Backspace" || e.key === "Delete") &&
923
+ this.selectedValues.length > 0
924
+ ) {
925
+ e.preventDefault();
926
+ this.removeChip(this.selectedValues[this.selectedValues.length - 1]);
927
+ return;
928
+ }
929
+
930
+ if (!this.open) {
931
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
932
+ e.preventDefault();
933
+ this.openListbox();
934
+ }
935
+ return;
936
+ }
937
+ // No search field to hold focus/keydown in this mode — the trigger
938
+ // itself keeps focus while open, so it needs the same nav handling.
939
+ if (this.noSearch) this.handleListNavigation(e);
940
+ }
941
+
942
+ private onSearchInput = (e: InputEvent) => {
943
+ this.searchValue = (e.target as HTMLInputElement).value;
944
+ this.highlightedIndex = this.filteredOptions.length > 0 ? 0 : -1;
945
+ this.selectAllHighlighted = false;
946
+ requestAnimationFrame(() => this.updatePosition());
947
+ };
948
+
949
+ private onSearchKeyDown(e: KeyboardEvent) {
950
+ // Backspace in an empty search field removes the last chip (see
951
+ // onTriggerKeyDown for the rationale).
952
+ if (
953
+ this.multiple &&
954
+ e.key === "Backspace" &&
955
+ this.searchValue === "" &&
956
+ this.selectedValues.length > 0
957
+ ) {
958
+ this.removeChip(this.selectedValues[this.selectedValues.length - 1]);
959
+ return;
960
+ }
961
+ this.handleListNavigation(e);
962
+ }
963
+
964
+ private handleListNavigation(e: KeyboardEvent) {
965
+ const filtered = this.filteredOptions;
966
+ // The Select-All row participates in arrow navigation as a virtual row
967
+ // above the first option — it lives in the panel header, outside the
968
+ // option list, and would otherwise be keyboard-unreachable.
969
+ const hasSelectAll = this.multiple && filtered.length > 0;
970
+
971
+ switch (e.key) {
972
+ case "ArrowDown":
973
+ e.preventDefault();
974
+ if (this.selectAllHighlighted) {
975
+ this.selectAllHighlighted = false;
976
+ this.highlightedIndex = filtered.length > 0 ? 0 : -1;
977
+ } else if (this.highlightedIndex === -1 && hasSelectAll) {
978
+ this.selectAllHighlighted = true;
979
+ } else {
980
+ this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
981
+ }
982
+ this.scrollHighlightedIntoView();
983
+ break;
984
+ case "ArrowUp":
985
+ e.preventDefault();
986
+ if (!this.selectAllHighlighted && this.highlightedIndex <= 0 && hasSelectAll) {
987
+ this.highlightedIndex = -1;
988
+ this.selectAllHighlighted = true;
989
+ } else {
990
+ this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
991
+ }
992
+ this.scrollHighlightedIntoView();
993
+ break;
994
+ case "Home":
995
+ if (filtered.length === 0) break;
996
+ e.preventDefault();
997
+ this.selectAllHighlighted = false;
998
+ this.highlightedIndex = 0;
999
+ this.scrollHighlightedIntoView();
1000
+ break;
1001
+ case "End":
1002
+ if (filtered.length === 0) break;
1003
+ e.preventDefault();
1004
+ this.selectAllHighlighted = false;
1005
+ this.highlightedIndex = filtered.length - 1;
1006
+ this.scrollHighlightedIntoView();
1007
+ break;
1008
+ case " ":
1009
+ // With a search field, Space types into it; only the no-search mode
1010
+ // (trigger holds focus) treats Space as toggle/select.
1011
+ if (!this.noSearch) return;
1012
+ e.preventDefault();
1013
+ this.activateHighlighted();
1014
+ break;
1015
+ case "Enter":
1016
+ e.preventDefault();
1017
+ this.activateHighlighted();
1018
+ break;
1019
+ case "Escape":
1020
+ e.preventDefault();
1021
+ this.closeListbox();
1022
+ break;
1023
+ case "Tab":
1024
+ this.closeListbox();
1025
+ break;
1026
+ default:
1027
+ this.handleTypeahead(e);
1028
+ break;
1029
+ }
1030
+ }
1031
+
1032
+ /** APG typeahead for the no-search mode (with a search field, typing
1033
+ * filters instead). Accumulates printable keys for 500 ms and jumps the
1034
+ * highlight to the next option whose label starts with the buffer. */
1035
+ private handleTypeahead(e: KeyboardEvent) {
1036
+ if (!this.noSearch) return;
1037
+ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
1038
+
1039
+ e.preventDefault();
1040
+ const isNewQuery = this.typeaheadBuffer === "";
1041
+ this.typeaheadBuffer += e.key.toLowerCase();
1042
+ clearTimeout(this.typeaheadTimer);
1043
+ this.typeaheadTimer = window.setTimeout(() => {
1044
+ this.typeaheadBuffer = "";
1045
+ }, 500);
1046
+
1047
+ const filtered = this.filteredOptions;
1048
+ if (filtered.length === 0) return;
1049
+
1050
+ // A fresh single-letter query starts searching AFTER the highlight (so
1051
+ // repeated presses cycle through matches); a growing buffer re-tests the
1052
+ // current one first.
1053
+ const start = Math.max(0, this.highlightedIndex) + (isNewQuery ? 1 : 0);
1054
+ for (let offset = 0; offset < filtered.length; offset++) {
1055
+ const idx = (start + offset) % filtered.length;
1056
+ if (filtered[idx].label.toLowerCase().startsWith(this.typeaheadBuffer)) {
1057
+ this.selectAllHighlighted = false;
1058
+ this.highlightedIndex = idx;
1059
+ this.scrollHighlightedIntoView();
1060
+ return;
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ private activateHighlighted() {
1066
+ if (this.selectAllHighlighted) {
1067
+ this.toggleSelectAll();
1068
+ return;
1069
+ }
1070
+ const filtered = this.filteredOptions;
1071
+ if (this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
1072
+ const opt = filtered[this.highlightedIndex];
1073
+ if (this.multiple) this.toggleMultiOption(opt);
1074
+ else this.selectSingleOption(opt);
1075
+ }
1076
+ }
1077
+
1078
+ private scrollHighlightedIntoView() {
1079
+ if (this.highlightedIndex < 0) return;
1080
+ requestAnimationFrame(() => {
1081
+ this.renderRoot
1082
+ .querySelector<HTMLElement>(`#${this.uniqueId}-option-${this.highlightedIndex}`)
1083
+ ?.scrollIntoView({ block: "nearest" });
1084
+ });
1085
+ }
1086
+
1087
+ private renderChips() {
1088
+ const values = this.selectedValues;
1089
+ const visible = values.slice(0, Math.min(this.visibleChipCount, this.maxVisibleChips));
1090
+ const overflow = values.length - visible.length;
1091
+ const opts = this.normalizedOptions;
1092
+
1093
+ return html`
1094
+ <span class="select-chips">
1095
+ ${repeat(
1096
+ visible,
1097
+ (v) => v,
1098
+ (v) => {
1099
+ const opt = opts.find((o) => o.value === v);
1100
+ // Keyed by value: rel-chip tracks its own "removed" flag
1101
+ // internally once its (x) is clicked, so reusing DOM nodes
1102
+ // positionally (plain .map()) could hand a freshly-shown value
1103
+ // a node that still remembers being removed as a *different*
1104
+ // value. Keying forces a fresh node per value instead.
1105
+ return html`
1106
+ <rel-chip
1107
+ label="${opt?.label ?? v}"
1108
+ size="sm"
1109
+ variant="subtle"
1110
+ border="default"
1111
+ ?removable=${this.state !== "disabled"}
1112
+ @click=${(e: Event) => e.stopPropagation()}
1113
+ @removed=${(e: Event) => {
1114
+ e.stopPropagation();
1115
+ this.removeChip(v);
1116
+ }}
1117
+ ></rel-chip>
1118
+ `;
1119
+ }
1120
+ )}
1121
+ ${overflow > 0
1122
+ ? html`<rel-chip label="+${overflow}" size="sm" variant="fill" border="default"></rel-chip>`
1123
+ : nothing}
1124
+ </span>
1125
+ `;
1126
+ }
1127
+
1128
+ /**
1129
+ * Hidden, unconstrained copy of every selected chip used purely for width
1130
+ * measurement (see computeVisibleChips). Chips mirror the visible ones'
1131
+ * width-affecting props (removable adds the ✕ icon). The trailing chip is
1132
+ * the widest "+N" badge that could ever be needed.
1133
+ */
1134
+ private renderChipsMeasure() {
1135
+ const values = this.selectedValues;
1136
+ const opts = this.normalizedOptions;
1137
+ return html`
1138
+ <span class="select-chips-measure" aria-hidden="true">
1139
+ ${values.map((v) => {
1140
+ const opt = opts.find((o) => o.value === v);
1141
+ return html`
1142
+ <rel-chip
1143
+ label="${opt?.label ?? v}"
1144
+ size="sm"
1145
+ variant="subtle"
1146
+ border="default"
1147
+ ?removable=${this.state !== "disabled"}
1148
+ ></rel-chip>
1149
+ `;
1150
+ })}
1151
+ <rel-chip label="+${values.length}" size="sm" variant="fill" border="default"></rel-chip>
1152
+ </span>
1153
+ `;
1154
+ }
1155
+
1156
+ render() {
1157
+ const opts = this.normalizedOptions;
1158
+ const filtered = this.filteredOptions;
1159
+ const isDisabled = this.state === "disabled";
1160
+ const labelHidden = this.labelStyle === "none";
1161
+ const showFloatingChrome = this.labelStyle === "floating" && !!this.label;
1162
+ // With no visible label the ARIA name comes from fieldAriaLabel instead.
1163
+ const accessibleName = (labelHidden ? this.fieldAriaLabel : this.label) || "";
1164
+
1165
+ const wrapperClasses = [
1166
+ "select-wrapper",
1167
+ this.size,
1168
+ this.state,
1169
+ `border-${this.border}`,
1170
+ showFloatingChrome ? "floating" : "",
1171
+ this.internalState === "focused" ? "focused" : "",
1172
+ this.hasValue ? "has-value" : "",
1173
+ this.open ? "open" : "",
1174
+ ]
1175
+ .filter(Boolean)
1176
+ .join(" ");
1177
+
1178
+ const listboxId = `${this.uniqueId}-listbox`;
1179
+ const activeOptionId = !this.open
1180
+ ? ""
1181
+ : this.selectAllHighlighted
1182
+ ? `${this.uniqueId}-select-all`
1183
+ : this.highlightedIndex >= 0
1184
+ ? `${this.uniqueId}-option-${this.highlightedIndex}`
1185
+ : "";
1186
+
1187
+ const selectedSingle = opts.find((o) => o.value === this.selected);
1188
+
1189
+ return html`
1190
+ <div class="${wrapperClasses}">
1191
+ ${labelHidden
1192
+ ? html`<div class="label-reserve" aria-hidden="true"></div>`
1193
+ : !showFloatingChrome && this.label
1194
+ ? html`<label part="label" class="select-label" for="${this.uniqueId}">${this.label}</label>`
1195
+ : ""}
1196
+
1197
+ <div class="select-container">
1198
+ ${showFloatingChrome
1199
+ ? html`<span class="floating-label${this.leftIcon ? " has-left-icon" : ""}"
1200
+ >${this.label}</span
1201
+ >`
1202
+ : ""}
1203
+
1204
+ <button
1205
+ id="${this.uniqueId}"
1206
+ type="button"
1207
+ part="trigger" class="select-trigger"
1208
+ role="combobox"
1209
+ aria-haspopup="listbox"
1210
+ aria-expanded="${this.open}"
1211
+ aria-controls="${listboxId}"
1212
+ aria-activedescendant="${activeOptionId}"
1213
+ aria-invalid="${this.state === "error"}"
1214
+ aria-label="${this.fieldAriaLabelledby ? nothing : accessibleName || this.placeholder}"
1215
+ aria-labelledby="${this.fieldAriaLabelledby ?? nothing}"
1216
+ ?disabled="${isDisabled}"
1217
+ @click="${this.onTriggerClick}"
1218
+ @keydown="${this.onTriggerKeyDown}"
1219
+ @focus="${() => (this.internalState = "focused")}"
1220
+ @blur="${() => (this.internalState = "default")}"
1221
+ >
1222
+ ${this.leftIcon
1223
+ ? html`<rel-icon name="${this.leftIcon}" size="${this.iconSize}" class="icon left"></rel-icon>`
1224
+ : ""}
1225
+
1226
+ ${this.multiple
1227
+ ? this.selectedValues.length > 0
1228
+ ? html`<span part="value" class="select-value">${this.renderChips()}</span>`
1229
+ : html`<span part="value" class="select-value placeholder">
1230
+ <span class="select-value-text">${this.placeholder}</span>
1231
+ </span>`
1232
+ : html`<span part="value" class="select-value ${selectedSingle ? "" : "placeholder"}">
1233
+ <span class="select-value-text"
1234
+ >${selectedSingle ? selectedSingle.label : this.placeholder}</span
1235
+ >
1236
+ </span>`}
1237
+
1238
+ <rel-icon
1239
+ name="${this.rightIcon || "chevron-down"}"
1240
+ size="${this.iconSize}"
1241
+ class="icon ${this.rightIcon ? "" : "chevron"}"
1242
+ ></rel-icon>
1243
+ </button>
1244
+
1245
+ ${this.multiple && this.selectedValues.length > 0 ? this.renderChipsMeasure() : nothing}
1246
+ </div>
1247
+
1248
+ ${this.supportingText
1249
+ ? html`<span part="helper" class="select-support ${this.state === "error" ? "error" : ""}">
1250
+ ${this.supportingText}
1251
+ </span>`
1252
+ : ""}
1253
+ </div>
1254
+
1255
+ <div
1256
+ id="${listboxId}"
1257
+ part="panel list" class="select-listbox"
1258
+ popover="manual"
1259
+ role="listbox"
1260
+ aria-multiselectable="${this.multiple}"
1261
+ aria-label="${accessibleName || "Options"}"
1262
+ style="${this.panelStyle}"
1263
+ ?data-open="${this.open}"
1264
+ >
1265
+ ${!this.noSearch || (this.multiple && filtered.length > 0)
1266
+ ? html`<div class="select-header">
1267
+ ${!this.noSearch
1268
+ ? html`<div class="select-search">
1269
+ <div class="search-input-wrap">
1270
+ <rel-icon name="search" size="${this.searchIconSize}" class="icon"></rel-icon>
1271
+ <input
1272
+ part="input" class="search-input"
1273
+ type="text"
1274
+ placeholder="${this.searchPlaceholder}"
1275
+ .value="${this.searchValue}"
1276
+ @input="${this.onSearchInput}"
1277
+ @keydown="${(e: KeyboardEvent) => this.onSearchKeyDown(e)}"
1278
+ />
1279
+ </div>
1280
+ </div>`
1281
+ : ""}
1282
+ ${this.multiple && filtered.length > 0
1283
+ ? html`<div
1284
+ id="${this.uniqueId}-select-all"
1285
+ class="select-all-row ${this.selectAllHighlighted ? "highlighted" : ""}"
1286
+ role="option"
1287
+ aria-selected="${this.selectAllStatus === "checked"}"
1288
+ @pointerenter="${() => {
1289
+ this.selectAllHighlighted = true;
1290
+ this.highlightedIndex = -1;
1291
+ }}"
1292
+ @pointerleave="${() => (this.selectAllHighlighted = false)}"
1293
+ >
1294
+ <rel-checkbox
1295
+ label="${this.selectAllLabel}"
1296
+ status="${this.selectAllStatus}"
1297
+ @rel-change=${(e: Event) => {
1298
+ e.stopPropagation();
1299
+ this.toggleSelectAll();
1300
+ }}
1301
+ ></rel-checkbox>
1302
+ </div>`
1303
+ : ""}
1304
+ </div>`
1305
+ : ""}
1306
+
1307
+ ${filtered.length === 0
1308
+ ? html`<div class="select-empty">
1309
+ ${opts.length === 0 ? "No options available" : "No matches found"}
1310
+ </div>`
1311
+ : this.multiple
1312
+ ? html`
1313
+ <div class="select-options">
1314
+ ${repeat(
1315
+ filtered,
1316
+ (option) => option.value,
1317
+ (option, index) => html`
1318
+ <div
1319
+ id="${this.uniqueId}-option-${index}"
1320
+ class="checkbox-option ${index === this.highlightedIndex ? "highlighted" : ""}"
1321
+ role="option"
1322
+ aria-selected="${this.selectedValues.includes(option.value)}"
1323
+ @pointerenter="${() => {
1324
+ this.highlightedIndex = index;
1325
+ this.selectAllHighlighted = false;
1326
+ }}"
1327
+ >
1328
+ <rel-checkbox
1329
+ label="${option.label}"
1330
+ icon="${option.icon}"
1331
+ supportingtext="${option.supportingText}"
1332
+ status="${this.selectedValues.includes(option.value) ? "checked" : "unchecked"}"
1333
+ @rel-change=${(e: Event) => {
1334
+ e.stopPropagation();
1335
+ this.toggleMultiOption(option);
1336
+ }}
1337
+ ></rel-checkbox>
1338
+ </div>
1339
+ `
1340
+ )}
1341
+ </div>
1342
+ `
1343
+ : html`
1344
+ <div class="select-options">
1345
+ ${repeat(
1346
+ filtered,
1347
+ (option) => option.value,
1348
+ (option, index) => html`
1349
+ <div
1350
+ id="${this.uniqueId}-option-${index}"
1351
+ part="option" class="select-option ${index === this.highlightedIndex ? "highlighted" : ""}"
1352
+ role="option"
1353
+ aria-selected="${option.value === this.selected}"
1354
+ @mousedown="${(e: Event) => e.preventDefault()}"
1355
+ @click="${() => this.selectSingleOption(option)}"
1356
+ @pointerenter="${() => (this.highlightedIndex = index)}"
1357
+ >
1358
+ <span class="select-option-content">
1359
+ ${option.icon
1360
+ ? html`<rel-icon
1361
+ name="${option.icon}"
1362
+ size="sm"
1363
+ class="select-option-icon"
1364
+ ></rel-icon>`
1365
+ : nothing}
1366
+ <span class="select-option-label">${option.label}</span>
1367
+ </span>
1368
+ ${option.value === this.selected
1369
+ ? html`<rel-icon name="check" size="sm" class="select-option-check"></rel-icon>`
1370
+ : ""}
1371
+ </div>
1372
+ `
1373
+ )}
1374
+ </div>
1375
+ `}
1376
+ </div>
1377
+ `;
1378
+ }
1379
+ }
1380
+
1381
+ declare global {
1382
+ interface HTMLElementTagNameMap {
1383
+ "rel-select": RelSelect;
1384
+ }
1385
+ }