@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.
- package/dist/components/rel-accordion/rel-accordion.ts +289 -135
- package/dist/components/rel-alert/rel-alert.ts +8 -1
- package/dist/components/rel-avatar/README.md +15 -4
- package/dist/components/rel-avatar/rel-avatar.ts +138 -96
- package/dist/components/rel-badge/README.md +7 -0
- package/dist/components/rel-badge/rel-badge.ts +61 -8
- package/dist/components/rel-button/rel-button.ts +64 -7
- package/dist/components/rel-chip/README.md +7 -0
- package/dist/components/rel-chip/rel-chip.ts +250 -130
- package/dist/components/rel-date-picker/rel-date-picker.ts +116 -14
- package/dist/components/rel-drawer/rel-drawer.ts +5 -2
- package/dist/components/rel-menubar/rel-menubar.ts +1 -0
- package/dist/components/rel-pagination/rel-pagination.ts +442 -425
- package/dist/components/rel-radio/rel-radio.ts +16 -16
- package/dist/components/rel-range-picker/rel-range-picker.css +349 -349
- package/dist/components/rel-range-picker/rel-range-picker.ts +1080 -1079
- package/dist/components/rel-select/README.md +34 -28
- package/dist/components/rel-select/rel-select.ts +1015 -346
- package/dist/components/rel-sheet/rel-sheet.ts +6 -3
- package/dist/components/rel-skeleton/rel-skeleton.ts +139 -0
- package/dist/components/rel-tab/rel-tab.ts +10 -4
- package/dist/components/rel-textfield/rel-textfield.ts +637 -349
- package/dist/components/rel-toast/rel-toast.ts +1 -6
- package/dist/components/rel-topbar/rel-topbar-actions.ts +4 -0
- package/dist/components/rel-topbar/rel-topbar-brand.ts +4 -0
- package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +4 -0
- package/dist/components/rel-topbar/rel-topbar-dropdown.ts +4 -0
- package/dist/components/rel-topbar/rel-topbar-item.ts +4 -0
- package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +4 -0
- package/dist/components/rel-topbar/rel-topbar-profile.ts +3 -0
- package/dist/components/rel-topbar/rel-topbar-utilities.ts +4 -0
- package/dist/components/rel-topbar/rel-topbar.ts +6 -0
- package/dist/index.d.mts +239 -22
- package/dist/index.d.ts +239 -22
- package/dist/index.js +2609 -981
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2621 -994
- package/dist/index.mjs.map +1 -1
- package/dist/styles/vars.css +17 -0
- package/dist/{vars-I2PC74XD.css → vars-F54UGCHM.css} +17 -0
- 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 "
|
|
4
|
-
import "
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
select
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
font-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
.
|
|
170
|
-
padding
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
+
}
|