@reliability-design/web 1.4.1 → 1.4.3

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.
@@ -4,16 +4,20 @@ import { repeat } from "lit/directives/repeat.js";
4
4
  import "@reliability-design/icons/rel-icon";
5
5
  import "../rel-chip/rel-chip.js";
6
6
  import "../rel-checkbox/rel-checkbox.js";
7
+ import type { FieldLabelStyle } from "../rel-textfield/rel-textfield.js";
7
8
 
8
9
  export type SelectState = "default" | "error" | "disabled";
9
10
  export type SelectSize = "small" | "medium" | "large";
10
11
  export type SelectBorder = "rounded" | "sharp";
11
- export type SelectLabelStyle = "default" | "floating";
12
+ /** Alias of the shared field type — kept so existing imports keep resolving. */
13
+ export type SelectLabelStyle = FieldLabelStyle;
12
14
 
13
15
  export interface SelectOptionObject {
14
16
  value: string;
15
17
  label?: string;
16
18
  supportingText?: string;
19
+ /** rel-icon name rendered as a prefix (leading icon) on the menu item. */
20
+ icon?: string;
17
21
  }
18
22
  export type SelectOption = string | SelectOptionObject;
19
23
 
@@ -21,11 +25,17 @@ interface NormalizedOption {
21
25
  value: string;
22
26
  label: string;
23
27
  supportingText: string;
28
+ icon: string;
24
29
  }
25
30
 
26
31
  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 ?? "" };
32
+ if (typeof o === "string") return { value: o, label: o, supportingText: "", icon: "" };
33
+ return {
34
+ value: o.value,
35
+ label: o.label ?? o.value,
36
+ supportingText: o.supportingText ?? "",
37
+ icon: o.icon ?? "",
38
+ };
29
39
  }
30
40
 
31
41
  @customElement("rel-select")
@@ -36,7 +46,16 @@ export class RelSelect extends LitElement {
36
46
  // hyphens) — e.g. labelStyle would otherwise only respond to "labelstyle".
37
47
  // Every multi-word property below gets an explicit kebab-case `attribute`
38
48
  // so it works the way HTML usage actually looks.
39
- @property({ type: String, attribute: "label-style" }) labelStyle: SelectLabelStyle = "default";
49
+ @property({ type: String, attribute: "label-style", reflect: true })
50
+ labelStyle: SelectLabelStyle = "default";
51
+
52
+ /**
53
+ * Accessible name for the trigger. Required when `labelStyle="none"`, since no
54
+ * `<label>` renders and a placeholder is not a valid naming mechanism.
55
+ */
56
+ @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
57
+ /** IDREF list of external element(s) naming the trigger. Wins over `fieldAriaLabel`. */
58
+ @property({ type: String, attribute: "field-aria-labelledby" }) fieldAriaLabelledby?: string;
40
59
 
41
60
  @property({ type: Array }) options: SelectOption[] = [];
42
61
 
@@ -67,8 +86,12 @@ export class RelSelect extends LitElement {
67
86
 
68
87
  @property({ type: String, attribute: "select-all-label" }) selectAllLabel: string = "Select all";
69
88
 
70
- /** Max selected chips shown before collapsing the rest into a "+N" badge. */
71
- @property({ type: Number, attribute: "max-visible-chips" }) maxVisibleChips: number = 3;
89
+ /**
90
+ * Optional hard cap on visible chips. By default (no cap) the visible
91
+ * count adapts to the trigger's width: chips collapse into the "+N" badge
92
+ * one by one instead of wrapping to a second line.
93
+ */
94
+ @property({ type: Number, attribute: "max-visible-chips" }) maxVisibleChips: number = Infinity;
72
95
 
73
96
  // Boolean props default true and toggled off via ="false" are a classic Lit
74
97
  // trap: for type:Boolean, attribute *presence* (not its string value)
@@ -84,9 +107,19 @@ export class RelSelect extends LitElement {
84
107
  @state() private searchValue = "";
85
108
  @state() private internalState: "default" | "focused" = "default";
86
109
 
110
+ /** How many chips currently fit in the trigger width (width-adaptive). */
111
+ @state() private visibleChipCount = Infinity;
112
+
87
113
  @query(".select-trigger") private triggerEl!: HTMLButtonElement;
88
114
  @query(".search-input") private searchInputEl!: HTMLInputElement;
89
115
  @query(".select-listbox") private listboxEl!: HTMLDivElement;
116
+ @query(".select-value") private valueEl!: HTMLElement;
117
+ @query(".select-chips-measure") private chipsMeasureEl!: HTMLElement;
118
+
119
+ /** Must match the `gap` of .select-chips / .select-chips-measure. */
120
+ private static readonly CHIP_GAP = 6;
121
+
122
+ private chipResizeObserver?: ResizeObserver;
90
123
 
91
124
  private uniqueId = `select-${Math.random().toString(36).slice(2)}`;
92
125
 
@@ -127,6 +160,22 @@ export class RelSelect extends LitElement {
127
160
 
128
161
  }
129
162
 
163
+ /* ── labelStyle="none" ──
164
+ No label renders. An empty spacer holds the label row's full height so the
165
+ trigger still bottom-aligns with labelled siblings in a form row.
166
+ The reserve covers label line-height + the wrapper gap, and the negative
167
+ margin cancels the flex gap that would otherwise sit below it — so
168
+ --rel-field-label-reserve: 0 collapses the row to genuinely zero
169
+ (toolbars, table cells), not just to the leftover gap. */
170
+ .label-reserve {
171
+ flex: none;
172
+ height: var(
173
+ --rel-field-label-reserve,
174
+ calc(var(--rel-body-sm-regular-line-height) + var(--rel-spacing-2xs))
175
+ );
176
+ margin-bottom: calc(-1 * var(--rel-spacing-2xs));
177
+ }
178
+
130
179
  /* Error only colors the border/focus ring and the supporting text below
131
180
  the field — label, floating label, placeholder, and icons stay normal. */
132
181
 
@@ -232,7 +281,7 @@ export class RelSelect extends LitElement {
232
281
  display: flex;
233
282
  align-items: center;
234
283
  gap: 6px;
235
- flex-wrap: wrap;
284
+ flex-wrap: nowrap;
236
285
  color: var(--rel-select-value-color, var(--rel-text-default));
237
286
  overflow: hidden;
238
287
  }
@@ -251,11 +300,33 @@ export class RelSelect extends LitElement {
251
300
  white-space: nowrap;
252
301
  }
253
302
 
303
+ /* Chips never wrap — the number of rendered chips adapts to the
304
+ available width instead (see computeVisibleChips), collapsing the
305
+ remainder into a trailing "+N" chip. overflow:hidden guards the one
306
+ frame between a resize and the recomputation catching up. */
254
307
  .select-chips {
255
308
  display: flex;
256
309
  align-items: center;
257
310
  gap: 6px;
258
- flex-wrap: wrap;
311
+ flex-wrap: nowrap;
312
+ min-width: 0;
313
+ overflow: hidden;
314
+ }
315
+
316
+ /* Invisible copy of ALL selected chips (plus a worst-case "+N" chip),
317
+ laid out unconstrained so computeVisibleChips can read each chip's
318
+ natural width — including chips currently collapsed into "+N", whose
319
+ widths are needed again when the field grows. */
320
+ .select-chips-measure {
321
+ position: absolute;
322
+ top: 0;
323
+ left: 0;
324
+ display: flex;
325
+ align-items: center;
326
+ gap: 6px;
327
+ width: max-content;
328
+ visibility: hidden;
329
+ pointer-events: none;
259
330
  }
260
331
 
261
332
  /* rel-chip's outer element is a tabindex="0" span, not a native button —
@@ -430,6 +501,21 @@ export class RelSelect extends LitElement {
430
501
  letter-spacing: var(--rel-body-md-regular-letter-spacing);
431
502
  }
432
503
 
504
+ /* Groups the optional prefix icon with the label so the trailing
505
+ check indicator stays pinned to the right edge of the row. */
506
+ .select-option-content {
507
+ flex: 1;
508
+ min-width: 0;
509
+ display: flex;
510
+ align-items: center;
511
+ gap: var(--rel-spacing-xs);
512
+ }
513
+
514
+ .select-option-icon {
515
+ flex-shrink: 0;
516
+ color: var(--rel-select-icon-color, var(--rel-text-default));
517
+ }
518
+
433
519
  .select-option-label {
434
520
  overflow: hidden;
435
521
  text-overflow: ellipsis;
@@ -480,9 +566,23 @@ export class RelSelect extends LitElement {
480
566
 
481
567
  `;
482
568
 
569
+ connectedCallback() {
570
+ super.connectedCallback();
571
+ // firstUpdated only runs once — re-observe if the element is re-attached.
572
+ if (this.chipResizeObserver && this.triggerEl) {
573
+ this.chipResizeObserver.observe(this.triggerEl);
574
+ }
575
+ }
576
+
483
577
  disconnectedCallback() {
484
578
  super.disconnectedCallback();
485
579
  this.removeGlobalListeners();
580
+ this.chipResizeObserver?.disconnect();
581
+ }
582
+
583
+ protected firstUpdated() {
584
+ this.chipResizeObserver = new ResizeObserver(() => this.computeVisibleChips());
585
+ this.chipResizeObserver.observe(this.triggerEl);
486
586
  }
487
587
 
488
588
  protected willUpdate(changed: PropertyValues<this>) {
@@ -491,6 +591,83 @@ export class RelSelect extends LitElement {
491
591
  }
492
592
  }
493
593
 
594
+ private _warnedNoName = false;
595
+
596
+ protected updated(changed: PropertyValues<this>) {
597
+ if (
598
+ !this._warnedNoName &&
599
+ this.labelStyle === "none" &&
600
+ !this.fieldAriaLabel &&
601
+ !this.fieldAriaLabelledby &&
602
+ !this.getAttribute("aria-label")
603
+ ) {
604
+ this._warnedNoName = true;
605
+ console.warn(
606
+ '<rel-select label-style="none"> has no accessible name. Set ' +
607
+ "`field-aria-label` or `field-aria-labelledby` — a placeholder is not a " +
608
+ "valid naming mechanism.",
609
+ this
610
+ );
611
+ }
612
+
613
+ if (
614
+ changed.has("selectedValues") ||
615
+ changed.has("options") ||
616
+ changed.has("multiple") ||
617
+ changed.has("size")
618
+ ) {
619
+ // rAF: rel-chip renders in its own (microtask) update cycle, so its
620
+ // width isn't measurable synchronously here — by the next frame it is.
621
+ requestAnimationFrame(() => this.computeVisibleChips());
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Width-adaptive chip collapsing: measures every selected chip in the
627
+ * hidden .select-chips-measure row and works out how many fit in the value
628
+ * area, always reserving room for the trailing "+N" chip when not all fit.
629
+ * At least one chip stays visible no matter how narrow the field gets.
630
+ */
631
+ private computeVisibleChips() {
632
+ if (!this.isConnected || !this.multiple || this.selectedValues.length === 0) return;
633
+ const measure = this.chipsMeasureEl;
634
+ const container = this.valueEl;
635
+ if (!measure || !container) return;
636
+
637
+ const available = container.clientWidth;
638
+ if (available <= 0) return; // hidden (display:none) — RO will refire when visible
639
+
640
+ const children = Array.from(measure.children) as HTMLElement[];
641
+ if (children.length < 2) return;
642
+
643
+ const gap = RelSelect.CHIP_GAP;
644
+ // Last measure child is the worst-case "+N" chip (N = total selected,
645
+ // i.e. the widest label the badge can ever need).
646
+ const overflowWidth = children[children.length - 1].offsetWidth;
647
+ const chipWidths = children.slice(0, -1).map((c) => c.offsetWidth);
648
+ const total = chipWidths.reduce((sum, w) => sum + w, 0) + gap * (chipWidths.length - 1);
649
+
650
+ let fit: number;
651
+ if (total <= available) {
652
+ fit = chipWidths.length;
653
+ } else {
654
+ fit = 0;
655
+ let used = 0;
656
+ for (let i = 0; i < chipWidths.length; i++) {
657
+ const w = chipWidths[i] + (i > 0 ? gap : 0);
658
+ if (used + w + gap + overflowWidth <= available) {
659
+ used += w;
660
+ fit = i + 1;
661
+ } else {
662
+ break;
663
+ }
664
+ }
665
+ fit = Math.max(1, fit);
666
+ }
667
+
668
+ if (fit !== this.visibleChipCount) this.visibleChipCount = fit;
669
+ }
670
+
494
671
  private get normalizedOptions(): NormalizedOption[] {
495
672
  return this.options.map(normalizeOption);
496
673
  }
@@ -719,7 +896,7 @@ export class RelSelect extends LitElement {
719
896
 
720
897
  private renderChips() {
721
898
  const values = this.selectedValues;
722
- const visible = values.slice(0, this.maxVisibleChips);
899
+ const visible = values.slice(0, Math.min(this.visibleChipCount, this.maxVisibleChips));
723
900
  const overflow = values.length - visible.length;
724
901
  const opts = this.normalizedOptions;
725
902
 
@@ -758,11 +935,42 @@ export class RelSelect extends LitElement {
758
935
  `;
759
936
  }
760
937
 
938
+ /**
939
+ * Hidden, unconstrained copy of every selected chip used purely for width
940
+ * measurement (see computeVisibleChips). Chips mirror the visible ones'
941
+ * width-affecting props (removable adds the ✕ icon). The trailing chip is
942
+ * the widest "+N" badge that could ever be needed.
943
+ */
944
+ private renderChipsMeasure() {
945
+ const values = this.selectedValues;
946
+ const opts = this.normalizedOptions;
947
+ return html`
948
+ <span class="select-chips-measure" aria-hidden="true">
949
+ ${values.map((v) => {
950
+ const opt = opts.find((o) => o.value === v);
951
+ return html`
952
+ <rel-chip
953
+ label="${opt?.label ?? v}"
954
+ size="sm"
955
+ variant="subtle"
956
+ border="default"
957
+ ?removable=${this.state !== "disabled"}
958
+ ></rel-chip>
959
+ `;
960
+ })}
961
+ <rel-chip label="+${values.length}" size="sm" variant="fill" border="default"></rel-chip>
962
+ </span>
963
+ `;
964
+ }
965
+
761
966
  render() {
762
967
  const opts = this.normalizedOptions;
763
968
  const filtered = this.filteredOptions;
764
969
  const isDisabled = this.state === "disabled";
970
+ const labelHidden = this.labelStyle === "none";
765
971
  const showFloatingChrome = this.labelStyle === "floating" && !!this.label;
972
+ // With no visible label the ARIA name comes from fieldAriaLabel instead.
973
+ const accessibleName = (labelHidden ? this.fieldAriaLabel : this.label) || "";
766
974
 
767
975
  const wrapperClasses = [
768
976
  "select-wrapper",
@@ -785,9 +993,11 @@ export class RelSelect extends LitElement {
785
993
 
786
994
  return html`
787
995
  <div class="${wrapperClasses}">
788
- ${!showFloatingChrome && this.label
789
- ? html`<label part="label" class="select-label" for="${this.uniqueId}">${this.label}</label>`
790
- : ""}
996
+ ${labelHidden
997
+ ? html`<div class="label-reserve" aria-hidden="true"></div>`
998
+ : !showFloatingChrome && this.label
999
+ ? html`<label part="label" class="select-label" for="${this.uniqueId}">${this.label}</label>`
1000
+ : ""}
791
1001
 
792
1002
  <div class="select-container">
793
1003
  ${showFloatingChrome
@@ -806,7 +1016,8 @@ export class RelSelect extends LitElement {
806
1016
  aria-controls="${listboxId}"
807
1017
  aria-activedescendant="${activeOptionId}"
808
1018
  aria-invalid="${this.state === "error"}"
809
- aria-label="${this.label || this.placeholder}"
1019
+ aria-label="${this.fieldAriaLabelledby ? nothing : accessibleName || this.placeholder}"
1020
+ aria-labelledby="${this.fieldAriaLabelledby ?? nothing}"
810
1021
  ?disabled="${isDisabled}"
811
1022
  @click="${this.onTriggerClick}"
812
1023
  @keydown="${this.onTriggerKeyDown}"
@@ -835,6 +1046,8 @@ export class RelSelect extends LitElement {
835
1046
  class="icon ${this.rightIcon ? "" : "chevron"}"
836
1047
  ></rel-icon>
837
1048
  </button>
1049
+
1050
+ ${this.multiple && this.selectedValues.length > 0 ? this.renderChipsMeasure() : nothing}
838
1051
  </div>
839
1052
 
840
1053
  ${this.supportingText
@@ -849,7 +1062,7 @@ export class RelSelect extends LitElement {
849
1062
  part="panel list" class="select-listbox"
850
1063
  role="listbox"
851
1064
  aria-multiselectable="${this.multiple}"
852
- aria-label="${this.label || "Options"}"
1065
+ aria-label="${accessibleName || "Options"}"
853
1066
  style="${this.panelStyle}"
854
1067
  ?data-open="${this.open}"
855
1068
  >
@@ -899,6 +1112,7 @@ export class RelSelect extends LitElement {
899
1112
  >
900
1113
  <rel-checkbox
901
1114
  label="${option.label}"
1115
+ icon="${option.icon}"
902
1116
  supportingtext="${option.supportingText}"
903
1117
  status="${this.selectedValues.includes(option.value) ? "checked" : "unchecked"}"
904
1118
  @rel-change=${(e: Event) => {
@@ -926,7 +1140,16 @@ export class RelSelect extends LitElement {
926
1140
  @click="${() => this.selectSingleOption(option)}"
927
1141
  @pointerenter="${() => (this.highlightedIndex = index)}"
928
1142
  >
929
- <span class="select-option-label">${option.label}</span>
1143
+ <span class="select-option-content">
1144
+ ${option.icon
1145
+ ? html`<rel-icon
1146
+ name="${option.icon}"
1147
+ size="sm"
1148
+ class="select-option-icon"
1149
+ ></rel-icon>`
1150
+ : nothing}
1151
+ <span class="select-option-label">${option.label}</span>
1152
+ </span>
930
1153
  ${option.value === this.selected
931
1154
  ? html`<rel-icon name="check" size="sm" class="select-option-check"></rel-icon>`
932
1155
  : ""}
@@ -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) {
@@ -18,6 +18,13 @@ A text input field component with support for labels, icons, validation states,
18
18
  supportingText="Must be at least 8 characters">
19
19
  </rel-textfield>
20
20
 
21
+ <!-- No label — an accessible name is required in its place -->
22
+ <rel-textfield
23
+ label-style="none"
24
+ placeholder="Search orders"
25
+ field-aria-label="Search orders">
26
+ </rel-textfield>
27
+
21
28
  <!-- Setting value via JavaScript -->
22
29
  <rel-textfield id="myField" label="Username"></rel-textfield>
23
30
  <script>
@@ -33,6 +40,10 @@ A text input field component with support for labels, icons, validation states,
33
40
  | Name | Type | Default | Description |
34
41
  |------|------|---------|-------------|
35
42
  | `label` | `string` | - | Label text displayed above the input |
43
+ | `label-style` | `"default" \| "floating" \| "none"` | - | Label presentation. `"none"` renders no label at all. Unset, the deprecated `floatingLabel` boolean decides |
44
+ | `floatingLabel` | `boolean` | `false` | **Deprecated** — use `label-style="floating"`. Kept permanently as an alias |
45
+ | `field-aria-label` | `string` | - | Accessible name for the input. Required with `label-style="none"` |
46
+ | `field-aria-labelledby` | `string` | - | IDREF list of external element(s) naming the input. Wins over `field-aria-label` |
36
47
  | `placeholder` | `string` | `""` | Placeholder text |
37
48
  | `type` | `string` | `"text"` | Input type (text, email, password, etc.) |
38
49
  | `value` | `string` | `""` | Current input value |
@@ -72,10 +83,15 @@ None exposed.
72
83
  - `--rel-bg-neutral-elevated-1`: Disabled background
73
84
  - `--rel-spacing-*`: Spacing tokens
74
85
  - `--rel-border-radius-sm`: Border radius
86
+ - `--rel-field-label-reserve`: Height held for the absent label row under `label-style="none"`, so the input bottom-aligns with labelled siblings. Defaults to the label line-height plus the wrapper gap; set to `0` to collapse the row entirely in compact contexts (toolbars, table cells)
75
87
 
76
88
  ## Accessibility
77
89
 
78
90
  - Associates `<label>` with input via `for`/`id`
91
+ - With `label-style="none"` no `<label>` renders, so `field-aria-label` (or
92
+ `field-aria-labelledby`) must supply the accessible name — a placeholder is
93
+ not a valid naming mechanism. The component logs a console warning when both
94
+ are missing.
79
95
  - Supports native HTML5 input attributes (`required`, `disabled`, `readonly`, `maxlength`)
80
96
  - Focus ring visible on keyboard navigation (`:focus-visible`)
81
97
  - Error state communicated visually via border color