@reliability-design/web 1.2.0 → 1.3.1

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 (41) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +289 -135
  2. package/dist/components/rel-alert/rel-alert.ts +8 -1
  3. package/dist/components/rel-avatar/README.md +15 -4
  4. package/dist/components/rel-avatar/rel-avatar.ts +138 -96
  5. package/dist/components/rel-badge/README.md +7 -0
  6. package/dist/components/rel-badge/rel-badge.ts +61 -8
  7. package/dist/components/rel-button/rel-button.ts +64 -7
  8. package/dist/components/rel-chip/README.md +7 -0
  9. package/dist/components/rel-chip/rel-chip.ts +250 -130
  10. package/dist/components/rel-date-picker/rel-date-picker.ts +116 -14
  11. package/dist/components/rel-drawer/rel-drawer.ts +5 -2
  12. package/dist/components/rel-menubar/rel-menubar.ts +1 -0
  13. package/dist/components/rel-pagination/rel-pagination.ts +442 -425
  14. package/dist/components/rel-radio/rel-radio.ts +16 -16
  15. package/dist/components/rel-range-picker/rel-range-picker.css +349 -349
  16. package/dist/components/rel-range-picker/rel-range-picker.ts +1080 -1079
  17. package/dist/components/rel-select/README.md +34 -28
  18. package/dist/components/rel-select/rel-select.ts +1015 -346
  19. package/dist/components/rel-sheet/rel-sheet.ts +6 -3
  20. package/dist/components/rel-skeleton/rel-skeleton.ts +139 -0
  21. package/dist/components/rel-tab/rel-tab.ts +10 -4
  22. package/dist/components/rel-textfield/rel-textfield.ts +637 -349
  23. package/dist/components/rel-toast/rel-toast.ts +1 -6
  24. package/dist/components/rel-topbar/rel-topbar-actions.ts +4 -0
  25. package/dist/components/rel-topbar/rel-topbar-brand.ts +4 -0
  26. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +4 -0
  27. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +4 -0
  28. package/dist/components/rel-topbar/rel-topbar-item.ts +4 -0
  29. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +4 -0
  30. package/dist/components/rel-topbar/rel-topbar-profile.ts +3 -0
  31. package/dist/components/rel-topbar/rel-topbar-utilities.ts +4 -0
  32. package/dist/components/rel-topbar/rel-topbar.ts +6 -0
  33. package/dist/index.d.mts +239 -22
  34. package/dist/index.d.ts +239 -22
  35. package/dist/index.js +2609 -981
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +2621 -994
  38. package/dist/index.mjs.map +1 -1
  39. package/dist/styles/vars.css +17 -0
  40. package/dist/{vars-I2PC74XD.css → vars-F54UGCHM.css} +17 -0
  41. package/package.json +1 -1
@@ -1,346 +1,1015 @@
1
- import { LitElement, html, css } from "lit";
2
- import { customElement, property } from "lit/decorators.js";
3
- import "../../styles/vars-v2.css";
4
- import "@reliability-design/icons/rel-icon";
5
-
6
- export type SelectState = "default" | "error" | "disabled";
7
- export type SelectSize = "small" | "medium" | "large";
8
- export type SelectBorder = "rounded" | "sharp";
9
-
10
- @customElement("rel-select")
11
- export class RelSelect extends LitElement {
12
- @property({ type: String }) label: string = "";
13
-
14
- @property({ type: Array }) options: string[] = [];
15
-
16
- @property({ type: String }) selected: string = "";
17
-
18
- @property({ type: String }) state: SelectState = "default";
19
-
20
- @property({ type: String }) supportingText: string = "";
21
-
22
- @property({ type: String }) size: SelectSize = "medium";
23
-
24
- @property({ type: String }) border: SelectBorder = "rounded";
25
-
26
- @property({ type: String }) leftIcon: string = "";
27
-
28
- @property({ type: String }) rightIcon: string = "";
29
-
30
- static styles = css`
31
- :host {
32
- display: block;
33
- font-family: var(--rel-fontFamilies-primary);
34
- width: 100%;
35
- }
36
-
37
- .select-wrapper {
38
- display: flex;
39
- flex-direction: column;
40
- width: 100%;
41
- }
42
-
43
- .select-label {
44
- font-size: var(--rel-fontSizes-sm);
45
- margin-bottom: var(--rel-spacing-space-3xs);
46
- font-weight: var(--rel-fontWeights-medium);
47
- color: var(--rel-color-text-heading);
48
- }
49
-
50
- .select-container {
51
- position: relative;
52
- display: flex;
53
- align-items: center;
54
- }
55
-
56
- .icon {
57
- position: absolute;
58
- display: flex;
59
- align-items: center;
60
- justify-content: center;
61
- pointer-events: none;
62
- top: 50%;
63
- transform: translateY(-50%);
64
- font-size: 16px;
65
- color: var(--rel-color-text-primary);
66
- }
67
-
68
- .icon.left {
69
- left: 10px;
70
- pointer-events: none;
71
- position: absolute;
72
- top: 50%;
73
- transform: translateY(-50%);
74
- }
75
-
76
- .icon.right {
77
- right: 36px;
78
- }
79
-
80
- .icon.chevron {
81
- right: 12px;
82
- pointer-events: none;
83
- position: absolute;
84
- top: 50%;
85
- transform: translateY(-50%);
86
- color: var(--rel-color-text-primary);
87
- }
88
-
89
- .select-error .icon.chevron {
90
- color: var(--rel-error-surface-default, #D50000);
91
- }
92
-
93
- .select-disabled .icon.chevron {
94
- color: var(--rel-color-text-disabled);
95
- }
96
-
97
- select {
98
- flex: 1;
99
- appearance: none;
100
- border: 1px solid var(--rel-color-border-default);
101
- background-color: var(--rel-color-bg-primary);
102
- font-family: var(--rel-fontFamilies-primary);
103
- cursor: pointer;
104
- transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, background-color 0.2s ease-in-out;
105
- color: var(--rel-color-text-heading);
106
- width: 100%;
107
- outline: none;
108
- }
109
-
110
- select:hover:not(:disabled) {
111
- border-color: var(--rel-color-text-primary);
112
- }
113
-
114
- select:focus-visible:not(:disabled) {
115
- border-color: var(--rel-color-border-focus);
116
- box-shadow: 0 0 0 2px var(--rel-color-border-focus);
117
- }
118
-
119
- /* States */
120
- select.error {
121
- border-color: var(--rel-error-surface-default, #D50000);
122
- border-width: 2px;
123
- }
124
-
125
- select.error:focus {
126
- box-shadow: 0 0 0 3px var(--rel-error-surface-light);
127
- }
128
-
129
- select:disabled {
130
- background-color: var(--rel-color-bg-tertiary);
131
- border-color: var(--rel-color-border-default);
132
- color: var(--rel-color-text-disabled);
133
- cursor: not-allowed;
134
- opacity: 0.6;
135
- }
136
-
137
- /* Sizes */
138
- .select-small select {
139
- font-size: var(--rel-fontSizes-xs);
140
- padding: var(--rel-spacing-space-3xs) 36px
141
- var(--rel-spacing-space-3xs) var(--rel-spacing-space-2xs);
142
- }
143
-
144
- .select-medium select {
145
- font-size: var(--rel-fontSizes-sm);
146
- padding: var(--rel-spacing-space-2xs) 40px
147
- var(--rel-spacing-space-2xs) var(--rel-spacing-space-xs);
148
- }
149
-
150
- .select-large select {
151
- font-size: var(--rel-fontSizes-md);
152
- padding: var(--rel-spacing-space-xs) 44px
153
- var(--rel-spacing-space-xs) var(--rel-spacing-space-sm);
154
- }
155
-
156
- /* With icons */
157
- .with-left-icon.select-small select {
158
- padding-left: var(--rel-spacing-space-xl);
159
- }
160
-
161
- .with-left-icon.select-medium select {
162
- padding-left: var(--rel-spacing-space-2xl);
163
- }
164
-
165
- .with-left-icon.select-large select {
166
- padding-left: var(--rel-spacing-space-3xl);
167
- }
168
-
169
- .with-right-icon.select-small select {
170
- padding-right: var(--rel-spacing-space-2xl);
171
- }
172
-
173
- .with-right-icon.select-medium select {
174
- padding-right: var(--rel-spacing-space-3xl);
175
- }
176
-
177
- .with-right-icon.select-large select {
178
- padding-right: 4rem;
179
- }
180
-
181
- /* Border */
182
- .border-rounded select {
183
- border-radius: var(--rel-borders-radius-border-radius-100);
184
- }
185
-
186
- .border-sharp select {
187
- border-radius: 0;
188
- }
189
-
190
- /* Supporting text */
191
- .supporting-text {
192
- font-size: var(--rel-fontSizes-xs);
193
- margin-top: var(--rel-spacing-space-3xs);
194
- color: var(--rel-color-text-placeholder);
195
- }
196
-
197
- .error-text {
198
- color: var(--rel-error-surface-default, #D50000);
199
- }
200
-
201
- .select-error .select-label {
202
- color: var(--rel-error-surface-default, #D50000);
203
- }
204
-
205
- /* Dark mode — flip select background, text, border */
206
- :host-context([data-theme="dark"]) select,
207
- :host-context(.dark) select {
208
- background-color: var(--rel-color-bg-secondary);
209
- color: #ffffff;
210
- border-color: var(--rel-color-border-default);
211
- }
212
-
213
- :host-context([data-theme="dark"]) select.error,
214
- :host-context(.dark) select.error {
215
- border-color: var(--rel-error-surface-default, #FF5252);
216
- border-width: 2px;
217
- }
218
-
219
- /* Error — placeholder text red */
220
- select.error::placeholder {
221
- color: var(--rel-error-surface-default, #D50000);
222
- }
223
-
224
- /* Error — selected/placeholder option text red */
225
- .select-error select {
226
- color: var(--rel-error-surface-default, #D50000);
227
- }
228
-
229
- /* Error — icon red */
230
- .select-error .icon,
231
- .select-error rel-icon {
232
- color: var(--rel-error-surface-default, #D50000);
233
- }
234
-
235
- :host-context([data-theme="dark"]) .select-error select,
236
- :host-context(.dark) .select-error select {
237
- color: var(--rel-error-surface-default, #FF5252);
238
- }
239
-
240
- :host-context([data-theme="dark"]) .select-error .icon,
241
- :host-context([data-theme="dark"]) .select-error rel-icon,
242
- :host-context(.dark) .select-error .icon,
243
- :host-context(.dark) .select-error rel-icon {
244
- color: var(--rel-error-surface-default, #FF5252);
245
- }
246
-
247
- /* Disabled label */
248
- .select-disabled .select-label {
249
- color: var(--rel-color-text-disabled);
250
- cursor: not-allowed;
251
- user-select: none;
252
- }
253
-
254
- `;
255
-
256
- connectedCallback() {
257
- super.connectedCallback();
258
- const leftIcon = this.getAttribute('left-icon');
259
- if (leftIcon) this.leftIcon = leftIcon;
260
- const rightIcon = this.getAttribute('right-icon');
261
- if (rightIcon) this.rightIcon = rightIcon;
262
- }
263
-
264
- private onChange(event: Event) {
265
- const value = (event.target as HTMLSelectElement).value;
266
- this.selected = value;
267
- this.dispatchEvent(
268
- new CustomEvent("changed", {
269
- detail: { value },
270
- bubbles: true,
271
- composed: true,
272
- })
273
- );
274
- }
275
-
276
- render() {
277
- const containerClasses = [
278
- "select-container",
279
- this.leftIcon ? "with-left-icon" : "",
280
- this.rightIcon ? "with-right-icon" : "",
281
- `select-${this.size}`,
282
- ]
283
- .filter(Boolean)
284
- .join(" ");
285
-
286
- const wrapperClasses = [
287
- "select-wrapper",
288
- `select-${this.state}`,
289
- `border-${this.border}`,
290
- ].join(" ");
291
-
292
- const selectClasses = [this.state === "error" ? "error" : ""]
293
- .filter(Boolean)
294
- .join(" ");
295
-
296
- const isDisabled = this.state === "disabled";
297
-
298
- return html`
299
- <div class="${wrapperClasses}">
300
- ${this.label
301
- ? html`<label class="select-label">${this.label}</label>`
302
- : ""}
303
-
304
- <div class="${containerClasses}">
305
- ${this.leftIcon
306
- ? html`<rel-icon name="${this.leftIcon}" size="sm" class="icon left"></rel-icon>`
307
- : ""}
308
-
309
- <select
310
- class="${selectClasses}"
311
- ?disabled="${isDisabled}"
312
- @change="${this.onChange}"
313
- .value="${this.selected}">
314
- <option value="" disabled ?selected="${!this.selected}" hidden>
315
- Select Option
316
- </option>
317
- ${this.options.map(
318
- (option) => html`
319
- <option value="${option}" ?selected="${option === this.selected}">
320
- ${option}
321
- </option>
322
- `
323
- )}
324
- </select>
325
-
326
- ${this.rightIcon
327
- ? html`<span class="icon right">${this.rightIcon}</span>`
328
- : ""}
329
-
330
- <rel-icon name="chevron-down" size="sm" class="icon chevron"></rel-icon>
331
- </div>
332
-
333
- ${this.supportingText
334
- ? html`
335
- <span
336
- class="supporting-text ${this.state === "error"
337
- ? "error-text"
338
- : ""}">
339
- ${this.supportingText}
340
- </span>
341
- `
342
- : ""}
343
- </div>
344
- `;
345
- }
346
- }
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 "../../styles/vars-v2.css";
5
+ import "@reliability-design/icons/rel-icon";
6
+ import "../rel-chip/rel-chip.js";
7
+ import "../rel-checkbox/rel-checkbox.js";
8
+
9
+ export type SelectState = "default" | "error" | "disabled";
10
+ export type SelectSize = "small" | "medium" | "large";
11
+ export type SelectBorder = "rounded" | "sharp";
12
+ export type SelectLabelStyle = "default" | "floating";
13
+
14
+ export interface SelectOptionObject {
15
+ value: string;
16
+ label?: string;
17
+ supportingText?: string;
18
+ }
19
+ export type SelectOption = string | SelectOptionObject;
20
+
21
+ interface NormalizedOption {
22
+ value: string;
23
+ label: string;
24
+ supportingText: string;
25
+ }
26
+
27
+ function normalizeOption(o: SelectOption): NormalizedOption {
28
+ if (typeof o === "string") return { value: o, label: o, supportingText: "" };
29
+ return { value: o.value, label: o.label ?? o.value, supportingText: o.supportingText ?? "" };
30
+ }
31
+
32
+ @customElement("rel-select")
33
+ export class RelSelect extends LitElement {
34
+ @property({ type: String }) label: string = "";
35
+
36
+ // Lit's default attribute name is the property name fully lowercased (no
37
+ // hyphens) — e.g. labelStyle would otherwise only respond to "labelstyle".
38
+ // Every multi-word property below gets an explicit kebab-case `attribute`
39
+ // so it works the way HTML usage actually looks.
40
+ @property({ type: String, attribute: "label-style" }) labelStyle: SelectLabelStyle = "default";
41
+
42
+ @property({ type: Array }) options: SelectOption[] = [];
43
+
44
+ /** Enables multi-select: selected values render as removable chips and options as checkboxes. */
45
+ @property({ type: Boolean }) multiple: boolean = false;
46
+
47
+ /** Selected value — single-select mode only. */
48
+ @property({ type: String }) selected: string = "";
49
+
50
+ /** Selected values — multi-select mode only. */
51
+ @property({ type: Array, attribute: "selected-values" }) selectedValues: string[] = [];
52
+
53
+ @property({ type: String }) state: SelectState = "default";
54
+
55
+ @property({ type: String, attribute: "supporting-text" }) supportingText: string = "";
56
+
57
+ @property({ type: String, reflect: true }) size: SelectSize = "medium";
58
+
59
+ @property({ type: String }) border: SelectBorder = "rounded";
60
+
61
+ @property({ type: String, attribute: "left-icon" }) leftIcon: string = "";
62
+
63
+ @property({ type: String, attribute: "right-icon" }) rightIcon: string = "";
64
+
65
+ @property({ type: String }) placeholder: string = "Select option";
66
+
67
+ @property({ type: String, attribute: "search-placeholder" }) searchPlaceholder: string = "Search option";
68
+
69
+ @property({ type: String, attribute: "select-all-label" }) selectAllLabel: string = "Select all";
70
+
71
+ /** Max selected chips shown before collapsing the rest into a "+N" badge. */
72
+ @property({ type: Number, attribute: "max-visible-chips" }) maxVisibleChips: number = 3;
73
+
74
+ // Boolean props default true and toggled off via ="false" are a classic Lit
75
+ // trap: for type:Boolean, attribute *presence* (not its string value)
76
+ // decides truthiness, so searchable="false" would still read as true. Named
77
+ // negatively instead, so the natural HTML idiom — just adding the bare
78
+ // attribute, no value — does what it looks like it does.
79
+ /** Hides the in-panel search field. Figma's "Single Option" dropdown has no search bar at all — use this for short, non-searchable lists. */
80
+ @property({ type: Boolean, attribute: "no-search" }) noSearch: boolean = false;
81
+
82
+ @state() private open = false;
83
+ @state() private highlightedIndex = -1;
84
+ @state() private panelStyle = "";
85
+ @state() private searchValue = "";
86
+ @state() private internalState: "default" | "focused" = "default";
87
+
88
+ @query(".select-trigger") private triggerEl!: HTMLButtonElement;
89
+ @query(".search-input") private searchInputEl!: HTMLInputElement;
90
+ @query(".select-listbox") private listboxEl!: HTMLDivElement;
91
+
92
+ private uniqueId = `select-${Math.random().toString(36).slice(2)}`;
93
+
94
+ static styles = css`
95
+ :host {
96
+ display: block;
97
+ font-family: var(--rel-fontFamilies-primary);
98
+ width: 100%;
99
+ }
100
+
101
+ .select-wrapper {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: var(--rel-spacing-space-2xs);
105
+ width: 100%;
106
+ }
107
+
108
+ /* Dims everything inside uniformly (label, trigger, icons, value text,
109
+ supporting text) — matches rel-button's :host([disabled]) opacity
110
+ approach instead of relying on one barely-different background tint. */
111
+ .select-wrapper.disabled {
112
+ opacity: 0.5;
113
+ }
114
+
115
+ .select-label {
116
+ font-size: var(--rel-fontSizes-sm, 14px);
117
+ font-weight: var(--rel-fontWeights-regular, 400);
118
+ color: var(--rel-select-label-color, #363636);
119
+ }
120
+
121
+ /* Error only colors the border/focus ring and the supporting text below
122
+ the field — label, floating label, placeholder, and icons stay normal. */
123
+
124
+ /* Container */
125
+ .select-container {
126
+ position: relative;
127
+ }
128
+
129
+ /* Trigger button — visually a text field, semantically a combobox button */
130
+ .select-trigger {
131
+ display: flex;
132
+ align-items: center;
133
+ width: 100%;
134
+ box-sizing: border-box;
135
+ gap: 8px;
136
+ background: var(--rel-select-bg, #ffffff);
137
+ border: 1px solid var(--rel-select-border-default, #e5e5e5);
138
+ cursor: pointer;
139
+ font-family: inherit;
140
+ transition: border-color 0.15s ease;
141
+ text-align: left;
142
+ }
143
+
144
+ .border-rounded .select-trigger {
145
+ border-radius: var(--rel-select-radius, 4px);
146
+ }
147
+
148
+ .border-sharp .select-trigger {
149
+ border-radius: 0;
150
+ }
151
+
152
+ .select-trigger:focus-visible,
153
+ .select-wrapper.open .select-trigger {
154
+ outline: none;
155
+ border-color: var(--rel-select-border-focus, #0b0b0b);
156
+ }
157
+
158
+ .select-wrapper.error .select-trigger {
159
+ border-color: var(--rel-select-border-error, #ce3131);
160
+ }
161
+
162
+ .select-wrapper.disabled .select-trigger {
163
+ cursor: not-allowed;
164
+ background: var(--rel-select-bg-disabled, #fafafa);
165
+ border-color: var(--rel-select-border-default, #e5e5e5);
166
+ }
167
+
168
+ /* Sizes */
169
+ .select-wrapper.small .select-trigger {
170
+ padding: var(--rel-spacing-space-xs) var(--rel-spacing-space-sm);
171
+ min-height: 40px;
172
+ font-size: var(--rel-fontSizes-sm, 14px);
173
+ }
174
+
175
+ .select-wrapper.medium .select-trigger {
176
+ padding: 12px;
177
+ min-height: 48px;
178
+ font-size: var(--rel-fontSizes-md, 16px);
179
+ }
180
+
181
+ .select-wrapper.large .select-trigger {
182
+ padding: var(--rel-spacing-space-md);
183
+ min-height: 56px;
184
+ font-size: var(--rel-fontSizes-lg, 18px);
185
+ }
186
+
187
+ /* Icons */
188
+ .select-trigger .icon {
189
+ display: flex;
190
+ align-items: center;
191
+ flex-shrink: 0;
192
+ color: var(--rel-select-icon-color, #0b0b0b);
193
+ }
194
+
195
+ .select-trigger .icon.chevron {
196
+ transition: transform 0.15s ease;
197
+ }
198
+
199
+ .select-wrapper.open .icon.chevron {
200
+ transform: rotate(180deg);
201
+ }
202
+
203
+ .select-wrapper.disabled .icon {
204
+ color: var(--rel-color-text-disabled, #a3a3a3);
205
+ }
206
+
207
+ /* Value area */
208
+ .select-value {
209
+ flex: 1;
210
+ min-width: 0;
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 6px;
214
+ flex-wrap: wrap;
215
+ color: var(--rel-select-value-color, #0b0b0b);
216
+ overflow: hidden;
217
+ }
218
+
219
+ .select-value.placeholder {
220
+ color: var(--rel-select-placeholder-color, #757575);
221
+ }
222
+
223
+ .select-wrapper.disabled .select-value.placeholder {
224
+ color: var(--rel-select-placeholder-disabled-color, #7d7d7d);
225
+ }
226
+
227
+ .select-value-text {
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ white-space: nowrap;
231
+ }
232
+
233
+ .select-chips {
234
+ display: flex;
235
+ align-items: center;
236
+ gap: 6px;
237
+ flex-wrap: wrap;
238
+ }
239
+
240
+ /* rel-chip's outer element is a tabindex="0" span, not a native button —
241
+ a disabled native <button> ancestor doesn't suppress clicks on that
242
+ automatically the way it would for a nested native form control, so
243
+ this has to be switched off explicitly. The cursor still correctly
244
+ falls through to the disabled trigger underneath. */
245
+ .select-wrapper.disabled .select-chips {
246
+ pointer-events: none;
247
+ }
248
+
249
+ /* Floating label — single element that animates between resting (inside,
250
+ at the value position) and floated (small, on the border line), same
251
+ mechanism as rel-textfield's [floatinglabel]. */
252
+ .floating-label {
253
+ position: absolute;
254
+ left: 12px;
255
+ top: 50%;
256
+ transform: translateY(-50%);
257
+ background: var(--rel-select-bg, #ffffff);
258
+ padding: 0 4px;
259
+ font-size: var(--rel-fontSizes-md, 16px);
260
+ font-weight: 400;
261
+ line-height: 1;
262
+ color: var(--rel-select-placeholder-color, #757575);
263
+ pointer-events: none;
264
+ z-index: 1;
265
+ white-space: nowrap;
266
+ transition: top 0.15s ease, left 0.15s ease, font-size 0.15s ease, color 0.15s ease;
267
+ }
268
+
269
+ .floating-label.has-left-icon {
270
+ left: 40px;
271
+ }
272
+
273
+ .select-wrapper.focused .floating-label,
274
+ .select-wrapper.has-value .floating-label,
275
+ .select-wrapper.open .floating-label {
276
+ top: 0;
277
+ left: 12px;
278
+ font-size: 12px;
279
+ color: var(--rel-select-label-color, #363636);
280
+ }
281
+
282
+ /* The real placeholder hides while the float-label rests in its spot,
283
+ and fades in once the label has moved up to the border. */
284
+ .select-wrapper.floating .select-value.placeholder {
285
+ opacity: 0;
286
+ transition: opacity 0.1s ease;
287
+ }
288
+
289
+ .select-wrapper.floating.focused .select-value.placeholder,
290
+ .select-wrapper.floating.open .select-value.placeholder {
291
+ opacity: 1;
292
+ }
293
+
294
+ /* Supporting text */
295
+ .select-support {
296
+ font-size: 13px;
297
+ color: var(--rel-select-supporting-color, #404040);
298
+ }
299
+
300
+ .select-support.error {
301
+ color: var(--rel-select-border-error, #ce3131);
302
+ }
303
+
304
+ /* ── Dropdown listbox ── */
305
+ .select-listbox {
306
+ position: fixed;
307
+ z-index: 1001;
308
+ display: none;
309
+ box-sizing: border-box;
310
+ background: var(--rel-select-bg, #ffffff);
311
+ border: 1px solid var(--rel-select-menu-border, #d4d4d4);
312
+ border-radius: var(--rel-select-menu-radius, 8px);
313
+ /* A border alone doesn't read clearly against a similarly-light page,
314
+ and a shadow alone barely shows on a dark background — together
315
+ they hold up in both themes. */
316
+ box-shadow: var(--rel-select-menu-shadow, 0 8px 24px rgba(11, 11, 11, 0.18));
317
+ max-height: 360px;
318
+ overflow-y: auto;
319
+ }
320
+
321
+ .select-listbox[data-open] {
322
+ display: block;
323
+ }
324
+
325
+ .select-search {
326
+ position: sticky;
327
+ top: 0;
328
+ background: var(--rel-select-bg, #ffffff);
329
+ padding: var(--rel-spacing-space-xs);
330
+ border-bottom: 1px solid var(--rel-select-divider, #f5f5f5);
331
+ }
332
+
333
+ /* Base = "small" textfield sizing, shared by both small AND medium
334
+ select — only "large" select bumps the search field up to "medium". */
335
+ .search-input-wrap {
336
+ display: flex;
337
+ align-items: center;
338
+ gap: 8px;
339
+ border: 1px solid var(--rel-select-search-border, #e5e5e5);
340
+ border-radius: 4px;
341
+ padding: var(--rel-spacing-space-xs) var(--rel-spacing-space-sm);
342
+ }
343
+
344
+ .search-input-wrap .icon {
345
+ display: flex;
346
+ align-items: center;
347
+ flex-shrink: 0;
348
+ color: var(--rel-select-icon-color, #0b0b0b);
349
+ }
350
+
351
+ .search-input {
352
+ flex: 1;
353
+ min-width: 0;
354
+ border: none;
355
+ outline: none;
356
+ background: transparent;
357
+ font-family: inherit;
358
+ font-size: var(--rel-fontSizes-sm, 14px);
359
+ color: var(--rel-select-value-color, #0b0b0b);
360
+ }
361
+
362
+ .search-input::placeholder {
363
+ color: var(--rel-select-placeholder-color, #757575);
364
+ }
365
+
366
+ :host([size="large"]) .search-input-wrap {
367
+ padding: 12px;
368
+ }
369
+
370
+ :host([size="large"]) .search-input {
371
+ font-size: var(--rel-fontSizes-md, 16px);
372
+ }
373
+
374
+ .select-options {
375
+ padding: 4px 0;
376
+ }
377
+
378
+ .select-option {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: space-between;
382
+ gap: 12px;
383
+ padding: var(--rel-spacing-space-xs) 12px;
384
+ font-size: var(--rel-fontSizes-md, 16px);
385
+ font-family: var(--rel-fontFamilies-primary);
386
+ color: var(--rel-select-value-color, #0b0b0b);
387
+ cursor: pointer;
388
+ }
389
+
390
+ .select-option-label {
391
+ overflow: hidden;
392
+ text-overflow: ellipsis;
393
+ white-space: nowrap;
394
+ }
395
+
396
+ .select-option-check {
397
+ flex-shrink: 0;
398
+ color: var(--rel-select-value-color, #0b0b0b);
399
+ }
400
+
401
+ .select-option:hover,
402
+ .select-option.highlighted {
403
+ background: var(--rel-select-option-hover, #ececec);
404
+ }
405
+
406
+ .select-option[aria-selected="true"] {
407
+ font-weight: var(--rel-fontWeights-medium);
408
+ }
409
+
410
+ .checkbox-option {
411
+ padding: 12px;
412
+ }
413
+
414
+ .checkbox-option:hover,
415
+ .checkbox-option.highlighted {
416
+ background: var(--rel-select-option-hover, #ececec);
417
+ }
418
+
419
+ .select-all-row {
420
+ border-bottom: 1px solid var(--rel-select-divider, #f5f5f5);
421
+ padding: 12px;
422
+ }
423
+
424
+ .select-empty {
425
+ padding: var(--rel-spacing-space-xs) 12px;
426
+ font-size: var(--rel-fontSizes-sm, 14px);
427
+ color: var(--rel-select-placeholder-color, #757575);
428
+ }
429
+
430
+ /* Dark mode */
431
+ /* No :not(.error) needed here — error no longer recolors label/floating
432
+ label, so there's nothing left for these to compete with. */
433
+ :host-context([data-theme="dark"]) .select-label,
434
+ [data-theme="dark"] .select-label {
435
+ color: var(--rel-select-label-color-dark, #f5f5f5);
436
+ }
437
+
438
+ /* :not(.error) — supporting text IS still red on error, and this rule's
439
+ extra :host-context specificity would otherwise outrank the
440
+ theme-agnostic .select-support.error rule. */
441
+ :host-context([data-theme="dark"]) .select-wrapper:not(.error) .select-support,
442
+ [data-theme="dark"] .select-wrapper:not(.error) .select-support {
443
+ color: var(--rel-select-supporting-color-dark, #b3b3b3);
444
+ }
445
+
446
+ :host-context([data-theme="dark"]) .select-wrapper.focused .floating-label,
447
+ [data-theme="dark"] .select-wrapper.focused .floating-label,
448
+ :host-context([data-theme="dark"]) .select-wrapper.has-value .floating-label,
449
+ [data-theme="dark"] .select-wrapper.has-value .floating-label,
450
+ :host-context([data-theme="dark"]) .select-wrapper.open .floating-label,
451
+ [data-theme="dark"] .select-wrapper.open .floating-label {
452
+ color: var(--rel-select-label-color-dark, #f5f5f5);
453
+ }
454
+
455
+ /* Background only here — kept unconditional so error-state triggers
456
+ still get the right dark background. border-color is split into its
457
+ own :not(.error) rule below: this rule's specificity (with the extra
458
+ :host-context) ties or beats the theme-agnostic error-border rule, and
459
+ since it's declared later in the file, an unconditional border-color
460
+ here would win the tie and show the default dark border instead of red
461
+ — regardless of open/focus state, i.e. even at rest. */
462
+ :host-context([data-theme="dark"]) .select-trigger,
463
+ [data-theme="dark"] .select-trigger,
464
+ :host-context([data-theme="dark"]) .floating-label,
465
+ [data-theme="dark"] .floating-label {
466
+ background: var(--rel-select-bg-dark, #1a1a1a);
467
+ }
468
+
469
+ :host-context([data-theme="dark"]) .select-wrapper:not(.error) .select-trigger,
470
+ [data-theme="dark"] .select-wrapper:not(.error) .select-trigger {
471
+ border-color: var(--rel-select-border-default-dark, #333333);
472
+ }
473
+
474
+ /* Own border token, a touch lighter than the trigger's — a shadow alone
475
+ barely registers on a dark background, so the panel leans more on this
476
+ border to separate itself from the page behind it. */
477
+ :host-context([data-theme="dark"]) .select-listbox,
478
+ [data-theme="dark"] .select-listbox {
479
+ background: var(--rel-select-bg-dark, #1a1a1a);
480
+ border-color: var(--rel-select-menu-border-dark, #454545);
481
+ }
482
+
483
+ :host-context([data-theme="dark"]) .select-wrapper.disabled .select-trigger,
484
+ [data-theme="dark"] .select-wrapper.disabled .select-trigger {
485
+ background: var(--rel-select-bg-disabled-dark, #141414);
486
+ }
487
+
488
+ /* :not(.error): same trap as the label rules — this rule's extra
489
+ :host-context specificity would otherwise outrank the theme-agnostic
490
+ .select-wrapper.error .select-trigger rule and show the focus-white
491
+ border instead of error-red the moment an error field is opened. */
492
+ :host-context([data-theme="dark"]) .select-wrapper:not(.error).open .select-trigger,
493
+ [data-theme="dark"] .select-wrapper:not(.error).open .select-trigger,
494
+ :host-context([data-theme="dark"]) .select-wrapper:not(.error) .select-trigger:focus-visible,
495
+ [data-theme="dark"] .select-wrapper:not(.error) .select-trigger:focus-visible {
496
+ border-color: var(--rel-select-border-focus-dark, #f5f5f5);
497
+ }
498
+
499
+ :host-context([data-theme="dark"]) .select-value,
500
+ [data-theme="dark"] .select-value,
501
+ :host-context([data-theme="dark"]) .search-input,
502
+ [data-theme="dark"] .search-input,
503
+ :host-context([data-theme="dark"]) .select-option,
504
+ [data-theme="dark"] .select-option,
505
+ :host-context([data-theme="dark"]) .select-option-check,
506
+ [data-theme="dark"] .select-option-check,
507
+ :host-context([data-theme="dark"]) .icon,
508
+ [data-theme="dark"] .icon {
509
+ color: var(--rel-select-value-color-dark, #f5f5f5);
510
+ }
511
+
512
+ :host-context([data-theme="dark"]) .select-option:hover,
513
+ [data-theme="dark"] .select-option:hover,
514
+ :host-context([data-theme="dark"]) .select-option.highlighted,
515
+ [data-theme="dark"] .select-option.highlighted,
516
+ :host-context([data-theme="dark"]) .checkbox-option:hover,
517
+ [data-theme="dark"] .checkbox-option:hover,
518
+ :host-context([data-theme="dark"]) .checkbox-option.highlighted,
519
+ [data-theme="dark"] .checkbox-option.highlighted {
520
+ background: var(--rel-select-option-hover-dark, #333333);
521
+ }
522
+
523
+ :host-context([data-theme="dark"]) .select-search,
524
+ [data-theme="dark"] .select-search {
525
+ background: var(--rel-select-bg-dark, #1a1a1a);
526
+ border-color: var(--rel-select-divider-dark, #333333);
527
+ }
528
+
529
+ :host-context([data-theme="dark"]) .select-all-row,
530
+ [data-theme="dark"] .select-all-row {
531
+ border-color: var(--rel-select-divider-dark, #333333);
532
+ }
533
+
534
+ :host-context([data-theme="dark"]) .select-value.placeholder,
535
+ [data-theme="dark"] .select-value.placeholder,
536
+ :host-context([data-theme="dark"]) .floating-label,
537
+ [data-theme="dark"] .floating-label,
538
+ :host-context([data-theme="dark"]) .search-input::placeholder,
539
+ [data-theme="dark"] .search-input::placeholder,
540
+ :host-context([data-theme="dark"]) .select-empty,
541
+ [data-theme="dark"] .select-empty {
542
+ color: var(--rel-select-placeholder-color-dark, #a3a3a3);
543
+ }
544
+
545
+ :host-context([data-theme="dark"]) .search-input-wrap,
546
+ [data-theme="dark"] .search-input-wrap {
547
+ border-color: var(--rel-select-search-border-dark, #333333);
548
+ }
549
+ `;
550
+
551
+ disconnectedCallback() {
552
+ super.disconnectedCallback();
553
+ this.removeGlobalListeners();
554
+ }
555
+
556
+ protected willUpdate(changed: PropertyValues<this>) {
557
+ if (changed.has("multiple") && this.multiple === false) {
558
+ this.selectedValues = [];
559
+ }
560
+ }
561
+
562
+ private get normalizedOptions(): NormalizedOption[] {
563
+ return this.options.map(normalizeOption);
564
+ }
565
+
566
+ /** Options matching the current search text (substring match, case-insensitive). */
567
+ private get filteredOptions(): NormalizedOption[] {
568
+ const opts = this.normalizedOptions;
569
+ if (!this.searchValue) return opts;
570
+ const v = this.searchValue.toLowerCase();
571
+ return opts.filter((o) => o.label.toLowerCase().includes(v));
572
+ }
573
+
574
+ private get hasValue(): boolean {
575
+ return this.multiple ? this.selectedValues.length > 0 : !!this.selected;
576
+ }
577
+
578
+ /** Icon size scales with the select's own size, matching the trigger. */
579
+ private get iconSize(): "xs" | "sm" | "md" {
580
+ return this.size === "small" ? "xs" : this.size === "large" ? "md" : "sm";
581
+ }
582
+
583
+ /** The search field uses "small" sizing for both small and medium select — only "large" select bumps it to "medium". */
584
+ private get searchIconSize(): "sm" | "md" {
585
+ return this.size === "large" ? "md" : "sm";
586
+ }
587
+
588
+ private get selectAllStatus(): "checked" | "unchecked" | "indeterminate" {
589
+ const filtered = this.filteredOptions;
590
+ if (filtered.length === 0) return "unchecked";
591
+ const selectedCount = filtered.filter((o) => this.selectedValues.includes(o.value)).length;
592
+ if (selectedCount === 0) return "unchecked";
593
+ if (selectedCount === filtered.length) return "checked";
594
+ return "indeterminate";
595
+ }
596
+
597
+ private addGlobalListeners() {
598
+ document.addEventListener("pointerdown", this.onOutsidePointerDown);
599
+ window.addEventListener("resize", this.updatePosition);
600
+ window.addEventListener("scroll", this.onPageScroll, true);
601
+ }
602
+
603
+ private removeGlobalListeners() {
604
+ document.removeEventListener("pointerdown", this.onOutsidePointerDown);
605
+ window.removeEventListener("resize", this.updatePosition);
606
+ window.removeEventListener("scroll", this.onPageScroll, true);
607
+ }
608
+
609
+ private onOutsidePointerDown = (e: PointerEvent) => {
610
+ if (!this.open) return;
611
+ const path = e.composedPath();
612
+ if (path.includes(this)) return;
613
+ this.closeListbox();
614
+ };
615
+
616
+ // Continuously re-deriving a fixed-position panel's coordinates from
617
+ // every page scroll event is fragile (it's what was causing the panel to
618
+ // jump to the top of the viewport). Instead, close the panel on any
619
+ // *outside* scroll, same as clicking outside — scrolling the option list
620
+ // itself (target is the listbox) is the one case that should NOT close it.
621
+ private onPageScroll = (e: Event) => {
622
+ if (!this.open) return;
623
+ if (e.composedPath()[0] === this.listboxEl) return;
624
+ this.closeListbox();
625
+ };
626
+
627
+ private emitChange() {
628
+ this.dispatchEvent(
629
+ new CustomEvent("changed", {
630
+ detail: { value: this.multiple ? this.selectedValues : this.selected },
631
+ bubbles: true,
632
+ composed: true,
633
+ })
634
+ );
635
+ }
636
+
637
+ private openListbox() {
638
+ if (this.state === "disabled" || this.open) return;
639
+ this.open = true;
640
+ this.searchValue = "";
641
+ // No pre-highlight — the first item showing a hover-style highlight
642
+ // before the user has touched anything reads as an unintended default.
643
+ // Highlight only kicks in once they actually hover or press an arrow key.
644
+ this.highlightedIndex = -1;
645
+ this.addGlobalListeners();
646
+ requestAnimationFrame(() => {
647
+ this.updatePosition();
648
+ // preventScroll: focusing the search field must not let the browser
649
+ // scroll the page into view — that would move the trigger (normal
650
+ // flow) out from under the panel (fixed position, already computed
651
+ // above), breaking the anchor. When there's no search field, focus
652
+ // simply stays on the trigger, which already has the nav keydown
653
+ // handling wired up for this case.
654
+ if (!this.noSearch) {
655
+ this.searchInputEl?.focus({ preventScroll: true });
656
+ }
657
+ });
658
+ }
659
+
660
+ private closeListbox() {
661
+ if (!this.open) return;
662
+ this.open = false;
663
+ this.removeGlobalListeners();
664
+ this.triggerEl?.focus();
665
+ }
666
+
667
+ private updatePosition = () => {
668
+ if (!this.triggerEl || !this.listboxEl) return;
669
+
670
+ this.listboxEl.setAttribute("data-open", "");
671
+
672
+ const t = this.triggerEl.getBoundingClientRect();
673
+ const c = this.listboxEl.getBoundingClientRect();
674
+
675
+ let top = t.bottom + 4;
676
+ if (top + c.height > window.innerHeight - 8) {
677
+ top = Math.max(8, t.top - c.height - 4);
678
+ }
679
+
680
+ // Use t.width (the trigger's width, which is also what we set the panel's
681
+ // own width to below) rather than c.width — c.width reflects the
682
+ // *previous* render's panelStyle, so on the very next measurement it can
683
+ // be a frame stale, nudging left by a few px even when there's no real
684
+ // overflow. Checking against t.width directly removes that drift.
685
+ let left = t.left;
686
+ if (left + t.width > window.innerWidth - 8) {
687
+ left = Math.max(8, window.innerWidth - t.width - 8);
688
+ }
689
+
690
+ this.panelStyle = `top:${top}px;left:${left}px;width:${t.width}px;`;
691
+ };
692
+
693
+ private selectSingleOption(option: NormalizedOption) {
694
+ this.selected = option.value;
695
+ if (this.state === "error") this.state = "default";
696
+ this.closeListbox();
697
+ this.emitChange();
698
+ }
699
+
700
+ private toggleMultiOption(option: NormalizedOption) {
701
+ const idx = this.selectedValues.indexOf(option.value);
702
+ this.selectedValues =
703
+ idx >= 0
704
+ ? this.selectedValues.filter((v) => v !== option.value)
705
+ : [...this.selectedValues, option.value];
706
+ if (this.state === "error") this.state = "default";
707
+ this.emitChange();
708
+ }
709
+
710
+ private toggleSelectAll() {
711
+ const filtered = this.filteredOptions;
712
+ const status = this.selectAllStatus;
713
+ if (status === "checked") {
714
+ const filteredValues = new Set(filtered.map((o) => o.value));
715
+ this.selectedValues = this.selectedValues.filter((v) => !filteredValues.has(v));
716
+ } else {
717
+ const toAdd = filtered.map((o) => o.value).filter((v) => !this.selectedValues.includes(v));
718
+ this.selectedValues = [...this.selectedValues, ...toAdd];
719
+ }
720
+ if (this.state === "error") this.state = "default";
721
+ this.emitChange();
722
+ }
723
+
724
+ private removeChip(value: string) {
725
+ if (this.state === "disabled") return;
726
+ this.selectedValues = this.selectedValues.filter((v) => v !== value);
727
+ this.emitChange();
728
+ }
729
+
730
+ private onTriggerClick() {
731
+ if (this.open) this.closeListbox();
732
+ else this.openListbox();
733
+ }
734
+
735
+ private onTriggerKeyDown(e: KeyboardEvent) {
736
+ if (this.state === "disabled") return;
737
+ if (!this.open) {
738
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
739
+ e.preventDefault();
740
+ this.openListbox();
741
+ }
742
+ return;
743
+ }
744
+ // No search field to hold focus/keydown in this mode — the trigger
745
+ // itself keeps focus while open, so it needs the same nav handling.
746
+ if (this.noSearch) this.handleListNavigation(e);
747
+ }
748
+
749
+ private onSearchInput = (e: InputEvent) => {
750
+ this.searchValue = (e.target as HTMLInputElement).value;
751
+ this.highlightedIndex = this.filteredOptions.length > 0 ? 0 : -1;
752
+ requestAnimationFrame(() => this.updatePosition());
753
+ };
754
+
755
+ private onSearchKeyDown(e: KeyboardEvent) {
756
+ this.handleListNavigation(e);
757
+ }
758
+
759
+ private handleListNavigation(e: KeyboardEvent) {
760
+ const filtered = this.filteredOptions;
761
+ switch (e.key) {
762
+ case "ArrowDown":
763
+ e.preventDefault();
764
+ this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
765
+ break;
766
+ case "ArrowUp":
767
+ e.preventDefault();
768
+ this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
769
+ break;
770
+ case "Enter":
771
+ e.preventDefault();
772
+ if (this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
773
+ const opt = filtered[this.highlightedIndex];
774
+ if (this.multiple) this.toggleMultiOption(opt);
775
+ else this.selectSingleOption(opt);
776
+ }
777
+ break;
778
+ case "Escape":
779
+ e.preventDefault();
780
+ this.closeListbox();
781
+ break;
782
+ case "Tab":
783
+ this.closeListbox();
784
+ break;
785
+ }
786
+ }
787
+
788
+ private renderChips() {
789
+ const values = this.selectedValues;
790
+ const visible = values.slice(0, this.maxVisibleChips);
791
+ const overflow = values.length - visible.length;
792
+ const opts = this.normalizedOptions;
793
+
794
+ return html`
795
+ <span class="select-chips">
796
+ ${repeat(
797
+ visible,
798
+ (v) => v,
799
+ (v) => {
800
+ const opt = opts.find((o) => o.value === v);
801
+ // Keyed by value: rel-chip tracks its own "removed" flag
802
+ // internally once its (x) is clicked, so reusing DOM nodes
803
+ // positionally (plain .map()) could hand a freshly-shown value
804
+ // a node that still remembers being removed as a *different*
805
+ // value. Keying forces a fresh node per value instead.
806
+ return html`
807
+ <rel-chip
808
+ label="${opt?.label ?? v}"
809
+ size="sm"
810
+ variant="subtle"
811
+ border="default"
812
+ ?removable=${this.state !== "disabled"}
813
+ @click=${(e: Event) => e.stopPropagation()}
814
+ @removed=${(e: Event) => {
815
+ e.stopPropagation();
816
+ this.removeChip(v);
817
+ }}
818
+ ></rel-chip>
819
+ `;
820
+ }
821
+ )}
822
+ ${overflow > 0
823
+ ? html`<rel-chip label="+${overflow}" size="sm" variant="fill" border="default"></rel-chip>`
824
+ : nothing}
825
+ </span>
826
+ `;
827
+ }
828
+
829
+ render() {
830
+ const opts = this.normalizedOptions;
831
+ const filtered = this.filteredOptions;
832
+ const isDisabled = this.state === "disabled";
833
+ const showFloatingChrome = this.labelStyle === "floating" && !!this.label;
834
+
835
+ const wrapperClasses = [
836
+ "select-wrapper",
837
+ this.size,
838
+ this.state,
839
+ `border-${this.border}`,
840
+ showFloatingChrome ? "floating" : "",
841
+ this.internalState === "focused" ? "focused" : "",
842
+ this.hasValue ? "has-value" : "",
843
+ this.open ? "open" : "",
844
+ ]
845
+ .filter(Boolean)
846
+ .join(" ");
847
+
848
+ const listboxId = `${this.uniqueId}-listbox`;
849
+ const activeOptionId =
850
+ this.open && this.highlightedIndex >= 0 ? `${this.uniqueId}-option-${this.highlightedIndex}` : "";
851
+
852
+ const selectedSingle = opts.find((o) => o.value === this.selected);
853
+
854
+ return html`
855
+ <div class="${wrapperClasses}">
856
+ ${!showFloatingChrome && this.label
857
+ ? html`<label class="select-label" for="${this.uniqueId}">${this.label}</label>`
858
+ : ""}
859
+
860
+ <div class="select-container">
861
+ ${showFloatingChrome
862
+ ? html`<span class="floating-label${this.leftIcon ? " has-left-icon" : ""}"
863
+ >${this.label}</span
864
+ >`
865
+ : ""}
866
+
867
+ <button
868
+ id="${this.uniqueId}"
869
+ type="button"
870
+ class="select-trigger"
871
+ role="combobox"
872
+ aria-haspopup="listbox"
873
+ aria-expanded="${this.open}"
874
+ aria-controls="${listboxId}"
875
+ aria-activedescendant="${activeOptionId}"
876
+ aria-invalid="${this.state === "error"}"
877
+ aria-label="${this.label || this.placeholder}"
878
+ ?disabled="${isDisabled}"
879
+ @click="${this.onTriggerClick}"
880
+ @keydown="${this.onTriggerKeyDown}"
881
+ @focus="${() => (this.internalState = "focused")}"
882
+ @blur="${() => (this.internalState = "default")}"
883
+ >
884
+ ${this.leftIcon
885
+ ? html`<rel-icon name="${this.leftIcon}" size="${this.iconSize}" class="icon left"></rel-icon>`
886
+ : ""}
887
+
888
+ ${this.multiple
889
+ ? this.selectedValues.length > 0
890
+ ? html`<span class="select-value">${this.renderChips()}</span>`
891
+ : html`<span class="select-value placeholder">
892
+ <span class="select-value-text">${this.placeholder}</span>
893
+ </span>`
894
+ : html`<span class="select-value ${selectedSingle ? "" : "placeholder"}">
895
+ <span class="select-value-text"
896
+ >${selectedSingle ? selectedSingle.label : this.placeholder}</span
897
+ >
898
+ </span>`}
899
+
900
+ <rel-icon
901
+ name="${this.rightIcon || "chevron-down"}"
902
+ size="${this.iconSize}"
903
+ class="icon ${this.rightIcon ? "" : "chevron"}"
904
+ ></rel-icon>
905
+ </button>
906
+ </div>
907
+
908
+ ${this.supportingText
909
+ ? html`<span class="select-support ${this.state === "error" ? "error" : ""}">
910
+ ${this.supportingText}
911
+ </span>`
912
+ : ""}
913
+ </div>
914
+
915
+ <div
916
+ id="${listboxId}"
917
+ class="select-listbox"
918
+ role="listbox"
919
+ aria-multiselectable="${this.multiple}"
920
+ aria-label="${this.label || "Options"}"
921
+ style="${this.panelStyle}"
922
+ ?data-open="${this.open}"
923
+ >
924
+ ${!this.noSearch
925
+ ? html`<div class="select-search">
926
+ <div class="search-input-wrap">
927
+ <rel-icon name="search" size="${this.searchIconSize}" class="icon"></rel-icon>
928
+ <input
929
+ class="search-input"
930
+ type="text"
931
+ placeholder="${this.searchPlaceholder}"
932
+ .value="${this.searchValue}"
933
+ @input="${this.onSearchInput}"
934
+ @keydown="${(e: KeyboardEvent) => this.onSearchKeyDown(e)}"
935
+ />
936
+ </div>
937
+ </div>`
938
+ : ""}
939
+
940
+ ${filtered.length === 0
941
+ ? html`<div class="select-empty">
942
+ ${opts.length === 0 ? "No options available" : "No matches found"}
943
+ </div>`
944
+ : this.multiple
945
+ ? html`
946
+ <div class="select-all-row">
947
+ <rel-checkbox
948
+ label="${this.selectAllLabel}"
949
+ status="${this.selectAllStatus}"
950
+ @rel-change=${(e: Event) => {
951
+ e.stopPropagation();
952
+ this.toggleSelectAll();
953
+ }}
954
+ ></rel-checkbox>
955
+ </div>
956
+ <div class="select-options">
957
+ ${repeat(
958
+ filtered,
959
+ (option) => option.value,
960
+ (option, index) => html`
961
+ <div
962
+ id="${this.uniqueId}-option-${index}"
963
+ class="checkbox-option ${index === this.highlightedIndex ? "highlighted" : ""}"
964
+ role="option"
965
+ aria-selected="${this.selectedValues.includes(option.value)}"
966
+ @pointerenter="${() => (this.highlightedIndex = index)}"
967
+ >
968
+ <rel-checkbox
969
+ label="${option.label}"
970
+ supportingtext="${option.supportingText}"
971
+ status="${this.selectedValues.includes(option.value) ? "checked" : "unchecked"}"
972
+ @rel-change=${(e: Event) => {
973
+ e.stopPropagation();
974
+ this.toggleMultiOption(option);
975
+ }}
976
+ ></rel-checkbox>
977
+ </div>
978
+ `
979
+ )}
980
+ </div>
981
+ `
982
+ : html`
983
+ <div class="select-options">
984
+ ${repeat(
985
+ filtered,
986
+ (option) => option.value,
987
+ (option, index) => html`
988
+ <div
989
+ id="${this.uniqueId}-option-${index}"
990
+ class="select-option ${index === this.highlightedIndex ? "highlighted" : ""}"
991
+ role="option"
992
+ aria-selected="${option.value === this.selected}"
993
+ @mousedown="${(e: Event) => e.preventDefault()}"
994
+ @click="${() => this.selectSingleOption(option)}"
995
+ @pointerenter="${() => (this.highlightedIndex = index)}"
996
+ >
997
+ <span class="select-option-label">${option.label}</span>
998
+ ${option.value === this.selected
999
+ ? html`<rel-icon name="check" size="sm" class="select-option-check"></rel-icon>`
1000
+ : ""}
1001
+ </div>
1002
+ `
1003
+ )}
1004
+ </div>
1005
+ `}
1006
+ </div>
1007
+ `;
1008
+ }
1009
+ }
1010
+
1011
+ declare global {
1012
+ interface HTMLElementTagNameMap {
1013
+ "rel-select": RelSelect;
1014
+ }
1015
+ }