@reliability-design/web 1.4.0 → 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.
@@ -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
 
@@ -24,6 +24,10 @@ export class RelDrawer extends LitElement {
24
24
 
25
25
  @state() private isDragging = false;
26
26
  @state() private dragOffset = 0;
27
+ // Once a left/right drawer finishes sliding in, we drop its transform so that
28
+ // `position: fixed` descendants (select dropdowns, popovers, tooltips) are no
29
+ // longer trapped by the transformed panel's containing block.
30
+ @state() private settled = false;
27
31
 
28
32
  @query(".panel") private panelElement!: HTMLElement;
29
33
  @query(".overlay") private overlayElement!: HTMLElement;
@@ -49,10 +53,19 @@ export class RelDrawer extends LitElement {
49
53
  bottom: 0;
50
54
  z-index: 1000;
51
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);
52
63
  }
53
64
 
54
65
  .drawer-container.open {
55
66
  pointer-events: auto;
67
+ visibility: visible;
68
+ transition-delay: 0s;
56
69
  }
57
70
 
58
71
  /* Overlay */
@@ -130,6 +143,15 @@ export class RelDrawer extends LitElement {
130
143
  transform: translateX(0);
131
144
  }
132
145
 
146
+ /* Once settled open, drop the (now identity) transform on left/right panels
147
+ so their fixed-position descendants (select dropdowns, popovers, tooltips)
148
+ escape the transformed containing block instead of being clipped by the
149
+ panel. Bottom keeps its transform (used for centering). */
150
+ .drawer-container.open .panel[data-side="left"].settled,
151
+ .drawer-container.open .panel[data-side="right"].settled {
152
+ transform: none;
153
+ }
154
+
133
155
  /* Dragging state */
134
156
  .panel.dragging {
135
157
  transition: none;
@@ -183,36 +205,36 @@ export class RelDrawer extends LitElement {
183
205
  gap: var(--rel-spacing-sm);
184
206
  }
185
207
 
186
- .title {
208
+ .title {
187
209
  color: var(--rel-text-label);
188
- margin: 0;
210
+ margin: 0;
189
211
 
190
- font-family: var(--rel-headings-lg-font-family);
212
+ font-family: var(--rel-headings-lg-font-family);
191
213
 
192
- font-weight: var(--rel-headings-lg-font-weight);
214
+ font-weight: var(--rel-headings-lg-font-weight);
193
215
 
194
- font-size: var(--rel-headings-lg-font-size);
216
+ font-size: var(--rel-headings-lg-font-size);
195
217
 
196
- line-height: var(--rel-headings-lg-line-height);
218
+ line-height: var(--rel-headings-lg-line-height);
197
219
 
198
- letter-spacing: var(--rel-headings-lg-letter-spacing);
220
+ letter-spacing: var(--rel-headings-lg-letter-spacing);
199
221
 
200
222
  }
201
223
 
202
- .description {
224
+ .description {
203
225
  color: var(--rel-text-body);
204
226
  margin: 0;
205
- opacity: 0.8;
227
+ opacity: 0.8;
206
228
 
207
- font-family: var(--rel-body-lg-regular-font-family);
229
+ font-family: var(--rel-body-lg-regular-font-family);
208
230
 
209
- font-weight: var(--rel-body-lg-regular-font-weight);
231
+ font-weight: var(--rel-body-lg-regular-font-weight);
210
232
 
211
- font-size: var(--rel-body-lg-regular-font-size);
233
+ font-size: var(--rel-body-lg-regular-font-size);
212
234
 
213
- line-height: var(--rel-body-lg-regular-line-height);
235
+ line-height: var(--rel-body-lg-regular-line-height);
214
236
 
215
- letter-spacing: var(--rel-body-lg-regular-letter-spacing);
237
+ letter-spacing: var(--rel-body-lg-regular-letter-spacing);
216
238
 
217
239
  }
218
240
 
@@ -320,6 +342,27 @@ export class RelDrawer extends LitElement {
320
342
 
321
343
  document.addEventListener("keydown", this.handleKeydown);
322
344
 
345
+ // After the slide-in transition completes, mark the panel settled so its
346
+ // transform is dropped (frees fixed-position dropdowns/popovers). A timeout
347
+ // backstops the case where transitionend doesn't fire.
348
+ this.settled = false;
349
+ this.updateComplete.then(() => {
350
+ const panel = this.panelElement;
351
+ if (!panel) return;
352
+ let done = false;
353
+ const settle = () => {
354
+ if (done) return;
355
+ done = true;
356
+ this.settled = true;
357
+ panel.removeEventListener("transitionend", onEnd);
358
+ };
359
+ const onEnd = (e: TransitionEvent) => {
360
+ if (e.propertyName === "transform") settle();
361
+ };
362
+ panel.addEventListener("transitionend", onEnd);
363
+ setTimeout(settle, 500);
364
+ });
365
+
323
366
  this.dispatchEvent(
324
367
  new CustomEvent("rel-open-change", {
325
368
  detail: { open: true },
@@ -330,6 +373,7 @@ export class RelDrawer extends LitElement {
330
373
  }
331
374
 
332
375
  private handleClose() {
376
+ this.settled = false;
333
377
  this.unlockScroll();
334
378
  document.removeEventListener("keydown", this.handleKeydown);
335
379
 
@@ -537,7 +581,7 @@ export class RelDrawer extends LitElement {
537
581
 
538
582
  <div
539
583
  part="panel"
540
- class="panel ${this.isDragging ? "dragging" : ""}"
584
+ class="panel ${this.isDragging ? "dragging" : ""} ${this.settled ? "settled" : ""}"
541
585
  data-side="${this.side}"
542
586
  style="${this.width ? `--drawer-width: ${this.width};` : ''}${this.maxHeight ? `--drawer-max-height: ${this.maxHeight};` : ''}${this.getPanelStyle()}"
543
587
  role="${this.modal ? "dialog" : "region"}"
@@ -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[]` | `[]` | Array of option values to display in dropdown |
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 array is rendered as-is; complex objects not supported (use string values only)
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 { value: o.value, label: o.label ?? o.value, supportingText: o.supportingText ?? "" };
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
- /** Max selected chips shown before collapsing the rest into a "+N" badge. */
71
- @property({ type: Number, attribute: "max-visible-chips" }) maxVisibleChips: number = 3;
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: 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: 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-label">${option.label}</span>
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
  : ""}