@reliability-design/web 1.4.1 → 1.4.2
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/CHANGELOG.md +22 -22
- package/dist/components/rel-autocomplete/README.md +94 -0
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +612 -0
- package/dist/components/rel-button/rel-button.ts +17 -3
- package/dist/components/rel-checkbox/README.md +1 -0
- package/dist/components/rel-checkbox/rel-checkbox.ts +34 -0
- package/dist/components/rel-drawer/rel-drawer.ts +9 -0
- package/dist/components/rel-popover/rel-popover.ts +56 -0
- package/dist/components/rel-radio/rel-radio.ts +12 -0
- package/dist/components/rel-select/README.md +4 -2
- package/dist/components/rel-select/rel-select.ts +180 -8
- package/dist/components/rel-sheet/rel-sheet.ts +20 -0
- package/dist/components/rel-textfield/rel-textfield.ts +5 -0
- package/dist/index.d.mts +204 -53
- package/dist/index.d.ts +204 -53
- package/dist/index.js +13266 -12395
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13205 -12335
- package/dist/index.mjs.map +1 -1
- package/dist/styles/vars.css +866 -866
- package/dist/{vars-BYCEEKWJ.css → vars-XNMTSRVN.css} +866 -866
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LitElement, html, css } from "lit";
|
|
2
2
|
import { customElement, property } from "lit/decorators.js";
|
|
3
|
+
import "@reliability-design/icons/rel-icon";
|
|
3
4
|
|
|
4
5
|
type CheckboxState = "default" | "error" | "disabled";
|
|
5
6
|
type CheckboxStatus = "unchecked" | "checked" | "indeterminate";
|
|
@@ -7,6 +8,8 @@ type CheckboxStatus = "unchecked" | "checked" | "indeterminate";
|
|
|
7
8
|
@customElement("rel-checkbox")
|
|
8
9
|
export class RelCheckbox extends LitElement {
|
|
9
10
|
@property({ type: String }) label = "Label";
|
|
11
|
+
/** rel-icon name rendered between the checkbox and the label (label prefix icon). */
|
|
12
|
+
@property({ type: String }) icon = "";
|
|
10
13
|
@property({ type: String }) supportingtext = "";
|
|
11
14
|
@property({ type: String }) state: CheckboxState = "default";
|
|
12
15
|
@property({ type: String }) status: CheckboxStatus = "unchecked";
|
|
@@ -36,6 +39,14 @@ export class RelCheckbox extends LitElement {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
.row {
|
|
42
|
+
/* Containing block for the hidden input below — without this the
|
|
43
|
+
absolutely-positioned input resolves against the document (or any
|
|
44
|
+
positioned ancestor in the consumer page), where it neither moves
|
|
45
|
+
with intermediate scroll containers nor stays aligned with the
|
|
46
|
+
visible box. Tabbing to it then makes the browser scroll the WRONG
|
|
47
|
+
scroller (often the window past a 100vh overflow-hidden shell),
|
|
48
|
+
shoving the whole page up and leaving it in a broken state. */
|
|
49
|
+
position: relative;
|
|
39
50
|
display: flex;
|
|
40
51
|
align-items: center;
|
|
41
52
|
gap: var(--rel-spacing-xs);
|
|
@@ -44,6 +55,10 @@ export class RelCheckbox extends LitElement {
|
|
|
44
55
|
|
|
45
56
|
input {
|
|
46
57
|
position: absolute;
|
|
58
|
+
inset: 0;
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: 100%;
|
|
61
|
+
margin: 0;
|
|
47
62
|
opacity: 0;
|
|
48
63
|
pointer-events: none;
|
|
49
64
|
}
|
|
@@ -147,6 +162,21 @@ export class RelCheckbox extends LitElement {
|
|
|
147
162
|
color: var(--rel-text-disabled);
|
|
148
163
|
}
|
|
149
164
|
|
|
165
|
+
/* Optional label prefix icon — tracks the label's color in every state
|
|
166
|
+
(rel-icon inherits currentColor). */
|
|
167
|
+
.icon-prefix {
|
|
168
|
+
flex-shrink: 0;
|
|
169
|
+
color: var(--rel-text-default);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.default .row:hover .icon-prefix {
|
|
173
|
+
color: var(--rel-text-body);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.disabled .icon-prefix {
|
|
177
|
+
color: var(--rel-text-disabled);
|
|
178
|
+
}
|
|
179
|
+
|
|
150
180
|
.label {
|
|
151
181
|
/* Figma: label rests on text-default; text-body is the hover color */
|
|
152
182
|
color: var(--rel-text-default);
|
|
@@ -226,6 +256,10 @@ export class RelCheckbox extends LitElement {
|
|
|
226
256
|
</svg>
|
|
227
257
|
</span>
|
|
228
258
|
|
|
259
|
+
${this.icon
|
|
260
|
+
? html`<rel-icon name="${this.icon}" size="md" class="icon-prefix"></rel-icon>`
|
|
261
|
+
: null}
|
|
262
|
+
|
|
229
263
|
<span part="label" class="label">${this.label}</span>
|
|
230
264
|
</label>
|
|
231
265
|
|
|
@@ -53,10 +53,19 @@ export class RelDrawer extends LitElement {
|
|
|
53
53
|
bottom: 0;
|
|
54
54
|
z-index: 1000;
|
|
55
55
|
pointer-events: none;
|
|
56
|
+
/* pointer-events:none only blocks the mouse — the closed drawer's
|
|
57
|
+
close button and slotted content would otherwise stay in the Tab
|
|
58
|
+
order and swallow keyboard focus into invisible off-screen UI.
|
|
59
|
+
visibility also removes them from the tab order; the transition
|
|
60
|
+
delays the flip to hidden until the close animation has played. */
|
|
61
|
+
visibility: hidden;
|
|
62
|
+
transition: visibility 0s linear var(--drawer-transition-duration);
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
.drawer-container.open {
|
|
59
66
|
pointer-events: auto;
|
|
67
|
+
visibility: visible;
|
|
68
|
+
transition-delay: 0s;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
/* Overlay */
|
|
@@ -61,6 +61,16 @@ export class RelPopover extends LitElement {
|
|
|
61
61
|
position: fixed;
|
|
62
62
|
z-index: 1001;
|
|
63
63
|
display: none;
|
|
64
|
+
/* Reset native [popover] UA styles (the wrapper is promoted to the
|
|
65
|
+
top layer so transformed ancestors — e.g. a centered rel-modal
|
|
66
|
+
panel — can't hijack its containing block or clip it). */
|
|
67
|
+
inset: auto;
|
|
68
|
+
margin: 0;
|
|
69
|
+
padding: 0;
|
|
70
|
+
border: none;
|
|
71
|
+
background: transparent;
|
|
72
|
+
overflow: visible;
|
|
73
|
+
color: inherit;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
.content-wrapper[open] {
|
|
@@ -84,6 +94,7 @@ export class RelPopover extends LitElement {
|
|
|
84
94
|
super.disconnectedCallback();
|
|
85
95
|
this.removeEventListener("pointerdown", this.onHostPointerDown, true);
|
|
86
96
|
this.removeGlobalListeners();
|
|
97
|
+
this.hideNativePopover();
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
firstUpdated() {
|
|
@@ -157,6 +168,7 @@ export class RelPopover extends LitElement {
|
|
|
157
168
|
(document.activeElement as HTMLElement) ?? this.triggerEl;
|
|
158
169
|
|
|
159
170
|
this.addGlobalListeners();
|
|
171
|
+
this.showNativePopover();
|
|
160
172
|
|
|
161
173
|
requestAnimationFrame(() => {
|
|
162
174
|
this.updatePosition();
|
|
@@ -166,9 +178,41 @@ export class RelPopover extends LitElement {
|
|
|
166
178
|
|
|
167
179
|
private onClose() {
|
|
168
180
|
this.removeGlobalListeners();
|
|
181
|
+
this.hideNativePopover();
|
|
169
182
|
this.restoreFocus();
|
|
170
183
|
}
|
|
171
184
|
|
|
185
|
+
/* =======================
|
|
186
|
+
* TOP LAYER (native Popover API)
|
|
187
|
+
*
|
|
188
|
+
* A transformed ancestor (e.g. rel-modal's centered panel with
|
|
189
|
+
* translate(-50%,-50%)) becomes the containing block for position:fixed
|
|
190
|
+
* descendants, breaking viewport-based coordinates and letting the
|
|
191
|
+
* panel's overflow:hidden clip the content. Promoting the wrapper to
|
|
192
|
+
* the browser top layer sidesteps both. `manual` keeps light-dismiss
|
|
193
|
+
* and Escape handling under this component's own control.
|
|
194
|
+
* ======================= */
|
|
195
|
+
|
|
196
|
+
private showNativePopover() {
|
|
197
|
+
const w = this.contentWrapper;
|
|
198
|
+
if (!w || typeof w.showPopover !== "function") return;
|
|
199
|
+
try {
|
|
200
|
+
if (!w.matches(":popover-open")) w.showPopover();
|
|
201
|
+
} catch {
|
|
202
|
+
/* older engines / detached element — fixed-position fallback applies */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private hideNativePopover() {
|
|
207
|
+
const w = this.contentWrapper;
|
|
208
|
+
if (!w || typeof w.hidePopover !== "function") return;
|
|
209
|
+
try {
|
|
210
|
+
if (w.matches(":popover-open")) w.hidePopover();
|
|
211
|
+
} catch {
|
|
212
|
+
/* already hidden or unsupported — nothing to do */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
172
216
|
/* =======================
|
|
173
217
|
* GLOBAL LISTENERS
|
|
174
218
|
* ======================= */
|
|
@@ -312,6 +356,17 @@ export class RelPopover extends LitElement {
|
|
|
312
356
|
top = Math.max(8, Math.min(top, window.innerHeight - c.height - 8));
|
|
313
357
|
left = Math.max(8, Math.min(left, window.innerWidth - c.width - 8));
|
|
314
358
|
|
|
359
|
+
// Without top-layer support a transformed ancestor may be the containing
|
|
360
|
+
// block for position:fixed, shifting the wrapper away from the viewport
|
|
361
|
+
// coordinates computed above. Apply, re-measure, and cancel the offset.
|
|
362
|
+
this.contentWrapper.style.top = `${top}px`;
|
|
363
|
+
this.contentWrapper.style.left = `${left}px`;
|
|
364
|
+
const applied = this.contentWrapper.getBoundingClientRect();
|
|
365
|
+
top -= applied.top - top;
|
|
366
|
+
left -= applied.left - left;
|
|
367
|
+
this.contentWrapper.style.top = `${top}px`;
|
|
368
|
+
this.contentWrapper.style.left = `${left}px`;
|
|
369
|
+
|
|
315
370
|
this.contentStyle = `top:${top}px;left:${left}px;`;
|
|
316
371
|
};
|
|
317
372
|
|
|
@@ -344,6 +399,7 @@ export class RelPopover extends LitElement {
|
|
|
344
399
|
<div
|
|
345
400
|
id="${this.uniqueId}"
|
|
346
401
|
class="content-wrapper"
|
|
402
|
+
popover="manual"
|
|
347
403
|
style="${this.contentStyle}"
|
|
348
404
|
?open="${this.activeOpen}"
|
|
349
405
|
>
|
|
@@ -76,6 +76,14 @@ export class RelRadio extends LitElement {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
.radio-main {
|
|
79
|
+
/* Containing block for the hidden input below — without this the
|
|
80
|
+
absolutely-positioned input resolves against the document (or any
|
|
81
|
+
positioned ancestor in the consumer page), where it neither moves
|
|
82
|
+
with intermediate scroll containers nor stays aligned with the
|
|
83
|
+
visible radio. Tabbing to it then makes the browser scroll the
|
|
84
|
+
WRONG scroller, shoving the whole page up (same bug as
|
|
85
|
+
rel-checkbox — keep the two fixes in sync). */
|
|
86
|
+
position: relative;
|
|
79
87
|
display: flex;
|
|
80
88
|
align-items: center;
|
|
81
89
|
gap: var(--rel-spacing-2xs);
|
|
@@ -83,6 +91,10 @@ export class RelRadio extends LitElement {
|
|
|
83
91
|
|
|
84
92
|
input {
|
|
85
93
|
position: absolute;
|
|
94
|
+
inset: 0;
|
|
95
|
+
width: 100%;
|
|
96
|
+
height: 100%;
|
|
97
|
+
margin: 0;
|
|
86
98
|
opacity: 0;
|
|
87
99
|
pointer-events: none;
|
|
88
100
|
}
|
|
@@ -34,7 +34,7 @@ A custom combobox-style select component built on the same textfield visual lang
|
|
|
34
34
|
| Name | Type | Default | Description |
|
|
35
35
|
|------|------|---------|-------------|
|
|
36
36
|
| `label` | `string` | `""` | Label text displayed above the select |
|
|
37
|
-
| `options` | `string[]` | `[]` |
|
|
37
|
+
| `options` | `(string \| SelectOptionObject)[]` | `[]` | Options to display in the dropdown. Each entry is either a plain string or an object: `{ value, label?, supportingText?, icon? }`. `icon` is a `rel-icon` name rendered as a prefix (leading icon) on the menu item — before the label in single-select rows, and between the checkbox and label in multi-select rows |
|
|
38
38
|
| `selected` | `string` | `""` | Currently selected option value |
|
|
39
39
|
| `state` | `"default" \| "error" \| "disabled"` | `"default"` | Visual and interaction state of the select |
|
|
40
40
|
| `supportingText` | `string` | `""` | Helper or error text displayed below the select |
|
|
@@ -107,7 +107,9 @@ None exposed.
|
|
|
107
107
|
- Icons (left/right) adjust internal padding automatically to accommodate icon space
|
|
108
108
|
- The chevron is always visible on the far right and rotates 180° when the dropdown is open; a custom `rightIcon` replaces it entirely (no chevron) and does not rotate
|
|
109
109
|
- In error state, supporting text is styled with error color for visual consistency; the error state automatically clears once a valid option is selected
|
|
110
|
-
- Options
|
|
110
|
+
- Options accept plain strings or `{ value, label?, supportingText?, icon? }` objects; string entries use the same text for value and label
|
|
111
|
+
- In multi-select, selected chips stay on a single line: the number of visible chips adapts to the field's width and the rest collapse into a trailing "+N" chip (recomputed on selection changes and via `ResizeObserver` on resize). At least one chip is always shown. `max-visible-chips` is an optional additional hard cap (uncapped by default)
|
|
112
|
+
- An option's `icon` renders only when set — options without one simply omit the prefix icon (no reserved empty space), and an unknown icon name logs a `rel-icon` console warning and renders nothing
|
|
111
113
|
- Typed text that doesn't exactly match (case-insensitive) any option reverts to the last committed `selected` value on blur, Escape, or Tab
|
|
112
114
|
- The ghost-text suggestion shows the remainder of the first option whose name starts with the typed text (case-insensitive); it has no effect on filtering, which matches substrings anywhere in an option
|
|
113
115
|
- Dropdown panel is positioned via `position: fixed` and flips above the trigger if there isn't enough viewport space below
|
|
@@ -14,6 +14,8 @@ export interface SelectOptionObject {
|
|
|
14
14
|
value: string;
|
|
15
15
|
label?: string;
|
|
16
16
|
supportingText?: string;
|
|
17
|
+
/** rel-icon name rendered as a prefix (leading icon) on the menu item. */
|
|
18
|
+
icon?: string;
|
|
17
19
|
}
|
|
18
20
|
export type SelectOption = string | SelectOptionObject;
|
|
19
21
|
|
|
@@ -21,11 +23,17 @@ interface NormalizedOption {
|
|
|
21
23
|
value: string;
|
|
22
24
|
label: string;
|
|
23
25
|
supportingText: string;
|
|
26
|
+
icon: string;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
function normalizeOption(o: SelectOption): NormalizedOption {
|
|
27
|
-
if (typeof o === "string") return { value: o, label: o, supportingText: "" };
|
|
28
|
-
return {
|
|
30
|
+
if (typeof o === "string") return { value: o, label: o, supportingText: "", icon: "" };
|
|
31
|
+
return {
|
|
32
|
+
value: o.value,
|
|
33
|
+
label: o.label ?? o.value,
|
|
34
|
+
supportingText: o.supportingText ?? "",
|
|
35
|
+
icon: o.icon ?? "",
|
|
36
|
+
};
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
@customElement("rel-select")
|
|
@@ -67,8 +75,12 @@ export class RelSelect extends LitElement {
|
|
|
67
75
|
|
|
68
76
|
@property({ type: String, attribute: "select-all-label" }) selectAllLabel: string = "Select all";
|
|
69
77
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Optional hard cap on visible chips. By default (no cap) the visible
|
|
80
|
+
* count adapts to the trigger's width: chips collapse into the "+N" badge
|
|
81
|
+
* one by one instead of wrapping to a second line.
|
|
82
|
+
*/
|
|
83
|
+
@property({ type: Number, attribute: "max-visible-chips" }) maxVisibleChips: number = Infinity;
|
|
72
84
|
|
|
73
85
|
// Boolean props default true and toggled off via ="false" are a classic Lit
|
|
74
86
|
// trap: for type:Boolean, attribute *presence* (not its string value)
|
|
@@ -84,9 +96,19 @@ export class RelSelect extends LitElement {
|
|
|
84
96
|
@state() private searchValue = "";
|
|
85
97
|
@state() private internalState: "default" | "focused" = "default";
|
|
86
98
|
|
|
99
|
+
/** How many chips currently fit in the trigger width (width-adaptive). */
|
|
100
|
+
@state() private visibleChipCount = Infinity;
|
|
101
|
+
|
|
87
102
|
@query(".select-trigger") private triggerEl!: HTMLButtonElement;
|
|
88
103
|
@query(".search-input") private searchInputEl!: HTMLInputElement;
|
|
89
104
|
@query(".select-listbox") private listboxEl!: HTMLDivElement;
|
|
105
|
+
@query(".select-value") private valueEl!: HTMLElement;
|
|
106
|
+
@query(".select-chips-measure") private chipsMeasureEl!: HTMLElement;
|
|
107
|
+
|
|
108
|
+
/** Must match the `gap` of .select-chips / .select-chips-measure. */
|
|
109
|
+
private static readonly CHIP_GAP = 6;
|
|
110
|
+
|
|
111
|
+
private chipResizeObserver?: ResizeObserver;
|
|
90
112
|
|
|
91
113
|
private uniqueId = `select-${Math.random().toString(36).slice(2)}`;
|
|
92
114
|
|
|
@@ -232,7 +254,7 @@ export class RelSelect extends LitElement {
|
|
|
232
254
|
display: flex;
|
|
233
255
|
align-items: center;
|
|
234
256
|
gap: 6px;
|
|
235
|
-
flex-wrap:
|
|
257
|
+
flex-wrap: nowrap;
|
|
236
258
|
color: var(--rel-select-value-color, var(--rel-text-default));
|
|
237
259
|
overflow: hidden;
|
|
238
260
|
}
|
|
@@ -251,11 +273,33 @@ export class RelSelect extends LitElement {
|
|
|
251
273
|
white-space: nowrap;
|
|
252
274
|
}
|
|
253
275
|
|
|
276
|
+
/* Chips never wrap — the number of rendered chips adapts to the
|
|
277
|
+
available width instead (see computeVisibleChips), collapsing the
|
|
278
|
+
remainder into a trailing "+N" chip. overflow:hidden guards the one
|
|
279
|
+
frame between a resize and the recomputation catching up. */
|
|
254
280
|
.select-chips {
|
|
255
281
|
display: flex;
|
|
256
282
|
align-items: center;
|
|
257
283
|
gap: 6px;
|
|
258
|
-
flex-wrap:
|
|
284
|
+
flex-wrap: nowrap;
|
|
285
|
+
min-width: 0;
|
|
286
|
+
overflow: hidden;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* Invisible copy of ALL selected chips (plus a worst-case "+N" chip),
|
|
290
|
+
laid out unconstrained so computeVisibleChips can read each chip's
|
|
291
|
+
natural width — including chips currently collapsed into "+N", whose
|
|
292
|
+
widths are needed again when the field grows. */
|
|
293
|
+
.select-chips-measure {
|
|
294
|
+
position: absolute;
|
|
295
|
+
top: 0;
|
|
296
|
+
left: 0;
|
|
297
|
+
display: flex;
|
|
298
|
+
align-items: center;
|
|
299
|
+
gap: 6px;
|
|
300
|
+
width: max-content;
|
|
301
|
+
visibility: hidden;
|
|
302
|
+
pointer-events: none;
|
|
259
303
|
}
|
|
260
304
|
|
|
261
305
|
/* rel-chip's outer element is a tabindex="0" span, not a native button —
|
|
@@ -430,6 +474,21 @@ export class RelSelect extends LitElement {
|
|
|
430
474
|
letter-spacing: var(--rel-body-md-regular-letter-spacing);
|
|
431
475
|
}
|
|
432
476
|
|
|
477
|
+
/* Groups the optional prefix icon with the label so the trailing
|
|
478
|
+
check indicator stays pinned to the right edge of the row. */
|
|
479
|
+
.select-option-content {
|
|
480
|
+
flex: 1;
|
|
481
|
+
min-width: 0;
|
|
482
|
+
display: flex;
|
|
483
|
+
align-items: center;
|
|
484
|
+
gap: var(--rel-spacing-xs);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.select-option-icon {
|
|
488
|
+
flex-shrink: 0;
|
|
489
|
+
color: var(--rel-select-icon-color, var(--rel-text-default));
|
|
490
|
+
}
|
|
491
|
+
|
|
433
492
|
.select-option-label {
|
|
434
493
|
overflow: hidden;
|
|
435
494
|
text-overflow: ellipsis;
|
|
@@ -480,9 +539,23 @@ export class RelSelect extends LitElement {
|
|
|
480
539
|
|
|
481
540
|
`;
|
|
482
541
|
|
|
542
|
+
connectedCallback() {
|
|
543
|
+
super.connectedCallback();
|
|
544
|
+
// firstUpdated only runs once — re-observe if the element is re-attached.
|
|
545
|
+
if (this.chipResizeObserver && this.triggerEl) {
|
|
546
|
+
this.chipResizeObserver.observe(this.triggerEl);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
483
550
|
disconnectedCallback() {
|
|
484
551
|
super.disconnectedCallback();
|
|
485
552
|
this.removeGlobalListeners();
|
|
553
|
+
this.chipResizeObserver?.disconnect();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
protected firstUpdated() {
|
|
557
|
+
this.chipResizeObserver = new ResizeObserver(() => this.computeVisibleChips());
|
|
558
|
+
this.chipResizeObserver.observe(this.triggerEl);
|
|
486
559
|
}
|
|
487
560
|
|
|
488
561
|
protected willUpdate(changed: PropertyValues<this>) {
|
|
@@ -491,6 +564,65 @@ export class RelSelect extends LitElement {
|
|
|
491
564
|
}
|
|
492
565
|
}
|
|
493
566
|
|
|
567
|
+
protected updated(changed: PropertyValues<this>) {
|
|
568
|
+
if (
|
|
569
|
+
changed.has("selectedValues") ||
|
|
570
|
+
changed.has("options") ||
|
|
571
|
+
changed.has("multiple") ||
|
|
572
|
+
changed.has("size")
|
|
573
|
+
) {
|
|
574
|
+
// rAF: rel-chip renders in its own (microtask) update cycle, so its
|
|
575
|
+
// width isn't measurable synchronously here — by the next frame it is.
|
|
576
|
+
requestAnimationFrame(() => this.computeVisibleChips());
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Width-adaptive chip collapsing: measures every selected chip in the
|
|
582
|
+
* hidden .select-chips-measure row and works out how many fit in the value
|
|
583
|
+
* area, always reserving room for the trailing "+N" chip when not all fit.
|
|
584
|
+
* At least one chip stays visible no matter how narrow the field gets.
|
|
585
|
+
*/
|
|
586
|
+
private computeVisibleChips() {
|
|
587
|
+
if (!this.isConnected || !this.multiple || this.selectedValues.length === 0) return;
|
|
588
|
+
const measure = this.chipsMeasureEl;
|
|
589
|
+
const container = this.valueEl;
|
|
590
|
+
if (!measure || !container) return;
|
|
591
|
+
|
|
592
|
+
const available = container.clientWidth;
|
|
593
|
+
if (available <= 0) return; // hidden (display:none) — RO will refire when visible
|
|
594
|
+
|
|
595
|
+
const children = Array.from(measure.children) as HTMLElement[];
|
|
596
|
+
if (children.length < 2) return;
|
|
597
|
+
|
|
598
|
+
const gap = RelSelect.CHIP_GAP;
|
|
599
|
+
// Last measure child is the worst-case "+N" chip (N = total selected,
|
|
600
|
+
// i.e. the widest label the badge can ever need).
|
|
601
|
+
const overflowWidth = children[children.length - 1].offsetWidth;
|
|
602
|
+
const chipWidths = children.slice(0, -1).map((c) => c.offsetWidth);
|
|
603
|
+
const total = chipWidths.reduce((sum, w) => sum + w, 0) + gap * (chipWidths.length - 1);
|
|
604
|
+
|
|
605
|
+
let fit: number;
|
|
606
|
+
if (total <= available) {
|
|
607
|
+
fit = chipWidths.length;
|
|
608
|
+
} else {
|
|
609
|
+
fit = 0;
|
|
610
|
+
let used = 0;
|
|
611
|
+
for (let i = 0; i < chipWidths.length; i++) {
|
|
612
|
+
const w = chipWidths[i] + (i > 0 ? gap : 0);
|
|
613
|
+
if (used + w + gap + overflowWidth <= available) {
|
|
614
|
+
used += w;
|
|
615
|
+
fit = i + 1;
|
|
616
|
+
} else {
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
fit = Math.max(1, fit);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (fit !== this.visibleChipCount) this.visibleChipCount = fit;
|
|
624
|
+
}
|
|
625
|
+
|
|
494
626
|
private get normalizedOptions(): NormalizedOption[] {
|
|
495
627
|
return this.options.map(normalizeOption);
|
|
496
628
|
}
|
|
@@ -719,7 +851,7 @@ export class RelSelect extends LitElement {
|
|
|
719
851
|
|
|
720
852
|
private renderChips() {
|
|
721
853
|
const values = this.selectedValues;
|
|
722
|
-
const visible = values.slice(0, this.maxVisibleChips);
|
|
854
|
+
const visible = values.slice(0, Math.min(this.visibleChipCount, this.maxVisibleChips));
|
|
723
855
|
const overflow = values.length - visible.length;
|
|
724
856
|
const opts = this.normalizedOptions;
|
|
725
857
|
|
|
@@ -758,6 +890,34 @@ export class RelSelect extends LitElement {
|
|
|
758
890
|
`;
|
|
759
891
|
}
|
|
760
892
|
|
|
893
|
+
/**
|
|
894
|
+
* Hidden, unconstrained copy of every selected chip used purely for width
|
|
895
|
+
* measurement (see computeVisibleChips). Chips mirror the visible ones'
|
|
896
|
+
* width-affecting props (removable adds the ✕ icon). The trailing chip is
|
|
897
|
+
* the widest "+N" badge that could ever be needed.
|
|
898
|
+
*/
|
|
899
|
+
private renderChipsMeasure() {
|
|
900
|
+
const values = this.selectedValues;
|
|
901
|
+
const opts = this.normalizedOptions;
|
|
902
|
+
return html`
|
|
903
|
+
<span class="select-chips-measure" aria-hidden="true">
|
|
904
|
+
${values.map((v) => {
|
|
905
|
+
const opt = opts.find((o) => o.value === v);
|
|
906
|
+
return html`
|
|
907
|
+
<rel-chip
|
|
908
|
+
label="${opt?.label ?? v}"
|
|
909
|
+
size="sm"
|
|
910
|
+
variant="subtle"
|
|
911
|
+
border="default"
|
|
912
|
+
?removable=${this.state !== "disabled"}
|
|
913
|
+
></rel-chip>
|
|
914
|
+
`;
|
|
915
|
+
})}
|
|
916
|
+
<rel-chip label="+${values.length}" size="sm" variant="fill" border="default"></rel-chip>
|
|
917
|
+
</span>
|
|
918
|
+
`;
|
|
919
|
+
}
|
|
920
|
+
|
|
761
921
|
render() {
|
|
762
922
|
const opts = this.normalizedOptions;
|
|
763
923
|
const filtered = this.filteredOptions;
|
|
@@ -835,6 +995,8 @@ export class RelSelect extends LitElement {
|
|
|
835
995
|
class="icon ${this.rightIcon ? "" : "chevron"}"
|
|
836
996
|
></rel-icon>
|
|
837
997
|
</button>
|
|
998
|
+
|
|
999
|
+
${this.multiple && this.selectedValues.length > 0 ? this.renderChipsMeasure() : nothing}
|
|
838
1000
|
</div>
|
|
839
1001
|
|
|
840
1002
|
${this.supportingText
|
|
@@ -899,6 +1061,7 @@ export class RelSelect extends LitElement {
|
|
|
899
1061
|
>
|
|
900
1062
|
<rel-checkbox
|
|
901
1063
|
label="${option.label}"
|
|
1064
|
+
icon="${option.icon}"
|
|
902
1065
|
supportingtext="${option.supportingText}"
|
|
903
1066
|
status="${this.selectedValues.includes(option.value) ? "checked" : "unchecked"}"
|
|
904
1067
|
@rel-change=${(e: Event) => {
|
|
@@ -926,7 +1089,16 @@ export class RelSelect extends LitElement {
|
|
|
926
1089
|
@click="${() => this.selectSingleOption(option)}"
|
|
927
1090
|
@pointerenter="${() => (this.highlightedIndex = index)}"
|
|
928
1091
|
>
|
|
929
|
-
<span class="select-option-
|
|
1092
|
+
<span class="select-option-content">
|
|
1093
|
+
${option.icon
|
|
1094
|
+
? html`<rel-icon
|
|
1095
|
+
name="${option.icon}"
|
|
1096
|
+
size="sm"
|
|
1097
|
+
class="select-option-icon"
|
|
1098
|
+
></rel-icon>`
|
|
1099
|
+
: nothing}
|
|
1100
|
+
<span class="select-option-label">${option.label}</span>
|
|
1101
|
+
</span>
|
|
930
1102
|
${option.value === this.selected
|
|
931
1103
|
? html`<rel-icon name="check" size="sm" class="select-option-check"></rel-icon>`
|
|
932
1104
|
: ""}
|
|
@@ -41,10 +41,19 @@ export class RelModal extends LitElement {
|
|
|
41
41
|
bottom: 0;
|
|
42
42
|
z-index: 1000;
|
|
43
43
|
pointer-events: none;
|
|
44
|
+
/* pointer-events:none only blocks the mouse — the closed modal's
|
|
45
|
+
close button and slotted content would otherwise stay in the Tab
|
|
46
|
+
order and swallow keyboard focus into invisible UI. visibility
|
|
47
|
+
also removes them from the tab order; the transition delays the
|
|
48
|
+
flip to hidden until the close animation has played. */
|
|
49
|
+
visibility: hidden;
|
|
50
|
+
transition: visibility 0s linear var(--modal-transition-duration);
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
.modal-container.open {
|
|
47
54
|
pointer-events: auto;
|
|
55
|
+
visibility: visible;
|
|
56
|
+
transition-delay: 0s;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
.overlay {
|
|
@@ -291,6 +300,17 @@ export class RelModal extends LitElement {
|
|
|
291
300
|
document.removeEventListener("keydown", this.handleKeydown);
|
|
292
301
|
}
|
|
293
302
|
|
|
303
|
+
protected willUpdate() {
|
|
304
|
+
// `title` doubles as HTML's global tooltip attribute: leaving it on the
|
|
305
|
+
// host shows a native tooltip anywhere over the modal, including the
|
|
306
|
+
// full-viewport backdrop. Sync it into the property, then strip it.
|
|
307
|
+
if (this.hasAttribute("title")) {
|
|
308
|
+
const value = this.getAttribute("title") ?? "";
|
|
309
|
+
this.removeAttribute("title");
|
|
310
|
+
this.title = value;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
294
314
|
updated(changed: Map<string, unknown>) {
|
|
295
315
|
if (changed.has("open")) {
|
|
296
316
|
if (this.open) {
|
|
@@ -568,6 +568,11 @@ export class RelTextfield extends LitElement {
|
|
|
568
568
|
if (rightIcon) this.rightIcon = rightIcon;
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
/** Focuses the inner input/textarea (the host itself is not focusable). */
|
|
572
|
+
focus(options?: FocusOptions) {
|
|
573
|
+
(this.renderRoot.querySelector("input, textarea") as HTMLElement | null)?.focus(options);
|
|
574
|
+
}
|
|
575
|
+
|
|
571
576
|
private handleFocus() {
|
|
572
577
|
if (!this.isDisabled) {
|
|
573
578
|
this.internalState = "focused";
|