@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.
@@ -0,0 +1,612 @@
1
+ import { LitElement, html, css, nothing, PropertyValues, TemplateResult } from "lit";
2
+ import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { repeat } from "lit/directives/repeat.js";
4
+ import "@reliability-design/icons/rel-icon";
5
+ import "../rel-textfield/rel-textfield.js";
6
+ import type { RelTextfield, TextFieldSize, TextFieldVariant } from "../rel-textfield/rel-textfield.js";
7
+
8
+ /** An item is a plain string or any object read through displayField/valueField. */
9
+ export type AutocompleteItem = string | Record<string, unknown>;
10
+ export type AutocompleteFilter = "contains" | "starts-with" | "none";
11
+ export type AutocompleteSearchFn = (query: string) => Promise<AutocompleteItem[]>;
12
+
13
+ interface NormalizedItem {
14
+ value: string;
15
+ label: string;
16
+ icon: string;
17
+ item: AutocompleteItem;
18
+ }
19
+
20
+ /**
21
+ * `<rel-autocomplete>` — searchable single-select type-ahead field.
22
+ *
23
+ * Composes `<rel-textfield>` for all field chrome (label, sizes, variants,
24
+ * states, icons) and adds a suggestion panel on top. Two data modes:
25
+ * - local: set `items`; typing filters them (`filter` mode)
26
+ * - remote: set `searchFn`; input is debounced (`debounceMs`), queries are
27
+ * latest-wins (stale responses discarded), and `items` is ignored
28
+ *
29
+ * The committed `value` is always the selected item's value string (its
30
+ * `valueField`) — typing alone emits `rel-change` with the raw text but does
31
+ * not change `value` until an item is picked (or the text exactly matches an
32
+ * item's label/value on Enter/blur).
33
+ */
34
+ @customElement("rel-autocomplete")
35
+ export class RelAutocomplete extends LitElement {
36
+ /** Local (or fallback initial) suggestion list. Ignored while searchFn drives results. */
37
+ @property({ type: Array }) items: AutocompleteItem[] = [];
38
+
39
+ /** Committed value — the selected item's valueField string ("" = none). */
40
+ @property({ type: String }) value: string = "";
41
+
42
+ /** Object key read for the row label when items are objects. */
43
+ @property({ type: String, attribute: "display-field" }) displayField: string = "label";
44
+
45
+ /** Object key read for the committed value when items are objects. */
46
+ @property({ type: String, attribute: "value-field" }) valueField: string = "value";
47
+
48
+ /** Local filtering mode. Ignored when searchFn is set (results arrive pre-filtered). */
49
+ @property({ type: String }) filter: AutocompleteFilter = "contains";
50
+
51
+ /** Async data source. When set, local filtering is bypassed entirely. */
52
+ @property({ attribute: false }) searchFn?: AutocompleteSearchFn;
53
+
54
+ /** Debounce for searchFn calls, in ms. */
55
+ @property({ type: Number, attribute: "debounce-ms" }) debounceMs: number = 300;
56
+
57
+ /** Minimum typed characters before the panel opens / searchFn fires. 0 = open on focus. */
58
+ @property({ type: Number, attribute: "min-search-length" }) minSearchLength: number = 1;
59
+
60
+ /** Optional cap on rendered suggestions (panel scrolls regardless). */
61
+ @property({ type: Number, attribute: "max-suggestions" }) maxSuggestions: number = Infinity;
62
+
63
+ /** Shows a trailing ✕ that clears the text and the committed value. */
64
+ @property({ type: Boolean }) clearable: boolean = false;
65
+
66
+ /** Externally-controlled loading state (internal searchFn loading is automatic). */
67
+ @property({ type: Boolean }) loading: boolean = false;
68
+
69
+ /** Row shown when a query yields nothing. */
70
+ @property({ type: String, attribute: "no-results-text" }) noResultsText: string = "No results found";
71
+
72
+ @property({ type: String, attribute: "loading-text" }) loadingText: string = "Loading…";
73
+
74
+ // ── Field chrome passthroughs (forwarded to the inner rel-textfield) ──
75
+ @property({ type: String }) label?: string;
76
+ @property({ type: String }) placeholder: string = "";
77
+ @property({ type: String }) size: TextFieldSize = "medium";
78
+ @property({ type: String }) variant: TextFieldVariant = "default";
79
+ @property({ type: Boolean, attribute: "floating-label" }) floatingLabel = false;
80
+ @property({ type: String, attribute: "prefix-icon" }) prefixIcon?: string;
81
+ @property({ type: Boolean }) disabled = false;
82
+ @property({ type: Boolean }) readonly = false;
83
+ @property({ type: Boolean }) required = false;
84
+ @property({ type: Boolean }) error = false;
85
+ @property({ type: String, attribute: "error-message" }) errorMessage?: string;
86
+ @property({ type: String, attribute: "helper-text" }) helperText?: string;
87
+ @property({ type: String }) name?: string;
88
+ @property({ type: Boolean, attribute: "full-width" }) fullWidth = true;
89
+
90
+ @state() private open = false;
91
+ @state() private inputText = "";
92
+ @state() private highlightedIndex = -1;
93
+ @state() private panelStyle = "";
94
+ @state() private searching = false;
95
+ /** searchFn results; null = idle/local mode (use items). */
96
+ @state() private remoteItems: AutocompleteItem[] | null = null;
97
+
98
+ @query("rel-textfield") private fieldEl!: RelTextfield;
99
+ @query(".ac-panel") private panelEl!: HTMLDivElement;
100
+
101
+ private uniqueId = `autocomplete-${Math.random().toString(36).slice(2)}`;
102
+ private debounceTimer?: ReturnType<typeof setTimeout>;
103
+ private searchSeq = 0;
104
+ /** Set while a selection/clear updates `value` internally, so willUpdate
105
+ * doesn't overwrite the input text the way it does for external writes. */
106
+ private internalValueWrite = false;
107
+
108
+ static styles = css`
109
+ :host {
110
+ color: var(--rel-text-default);
111
+ display: block;
112
+ font-family: var(--rel-fonts-font-family);
113
+ width: 100%;
114
+ }
115
+
116
+ :host(:not([full-width])) {
117
+ display: inline-block;
118
+ width: auto;
119
+ }
120
+
121
+ /* ── Suggestion panel — mirrors rel-select's listbox so the two read as
122
+ one family (same tokens, radius, shadow, row styling). ── */
123
+ .ac-panel {
124
+ position: fixed;
125
+ z-index: 1001;
126
+ display: none;
127
+ box-sizing: border-box;
128
+ background: var(--rel-autocomplete-bg, var(--rel-bg-neutral));
129
+ border: 1px solid var(--rel-autocomplete-menu-border, var(--rel-border-default));
130
+ border-radius: var(--rel-autocomplete-menu-radius, var(--rel-border-radius-sm));
131
+ box-shadow: var(--rel-autocomplete-menu-shadow, var(--rel-shadows-shadow-300));
132
+ max-height: 360px;
133
+ overflow-y: auto;
134
+ }
135
+
136
+ .ac-panel[data-open] {
137
+ display: block;
138
+ }
139
+
140
+ .ac-options {
141
+ padding: 4px 0;
142
+ }
143
+
144
+ .ac-option {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: var(--rel-spacing-xs);
148
+ padding: var(--rel-spacing-xs) 12px;
149
+ color: var(--rel-autocomplete-value-color, var(--rel-text-default));
150
+ cursor: pointer;
151
+ font-family: var(--rel-body-md-regular-font-family);
152
+ font-weight: var(--rel-body-md-regular-font-weight);
153
+ font-size: var(--rel-body-md-regular-font-size);
154
+ line-height: var(--rel-body-md-regular-line-height);
155
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
156
+ }
157
+
158
+ .ac-option:hover,
159
+ .ac-option.highlighted {
160
+ background: var(--rel-autocomplete-option-hover, var(--rel-bg-neutral-elevated-1));
161
+ }
162
+
163
+ .ac-option-icon {
164
+ flex-shrink: 0;
165
+ color: var(--rel-autocomplete-icon-color, var(--rel-text-default));
166
+ }
167
+
168
+ .ac-option-label {
169
+ overflow: hidden;
170
+ text-overflow: ellipsis;
171
+ white-space: nowrap;
172
+ }
173
+
174
+ .ac-option-label b {
175
+ font-weight: var(--rel-body-md-medium-font-weight);
176
+ }
177
+
178
+ /* Status rows (loading / no results) — informational, not selectable */
179
+ .ac-status {
180
+ display: flex;
181
+ align-items: center;
182
+ gap: var(--rel-spacing-xs);
183
+ padding: var(--rel-spacing-xs) 12px;
184
+ color: var(--rel-autocomplete-status-color, var(--rel-text-body));
185
+ font-family: var(--rel-body-sm-regular-font-family);
186
+ font-weight: var(--rel-body-sm-regular-font-weight);
187
+ font-size: var(--rel-body-sm-regular-font-size);
188
+ line-height: var(--rel-body-sm-regular-line-height);
189
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
190
+ }
191
+
192
+ .ac-spinner {
193
+ animation: ac-spin 0.9s linear infinite;
194
+ }
195
+
196
+ @keyframes ac-spin {
197
+ to {
198
+ transform: rotate(360deg);
199
+ }
200
+ }
201
+ `;
202
+
203
+ disconnectedCallback() {
204
+ super.disconnectedCallback();
205
+ this.removeGlobalListeners();
206
+ clearTimeout(this.debounceTimer);
207
+ }
208
+
209
+ protected willUpdate(changed: PropertyValues<this>) {
210
+ // External value writes (attribute, Angular writeValue) sync the visible
211
+ // text to the matching item's label — or the raw value when unknown
212
+ // (e.g. remote item not currently in the list).
213
+ if (changed.has("value") && !this.internalValueWrite) {
214
+ const match = this.allItems.find((i) => i.value === this.value);
215
+ this.inputText = match ? match.label : this.value ?? "";
216
+ }
217
+ this.internalValueWrite = false;
218
+ }
219
+
220
+ /** Full normalized source list (remote results while searchFn drives, else items). */
221
+ private get allItems(): NormalizedItem[] {
222
+ const source = this.searchFn && this.remoteItems !== null ? this.remoteItems : this.items;
223
+ return source.map((item) => this.normalizeItem(item));
224
+ }
225
+
226
+ private normalizeItem(item: AutocompleteItem): NormalizedItem {
227
+ if (typeof item === "string") return { value: item, label: item, icon: "", item };
228
+ const rec = item;
229
+ const rawLabel = rec[this.displayField] ?? rec[this.valueField];
230
+ const label = rawLabel == null ? "" : String(rawLabel);
231
+ const rawValue = rec[this.valueField];
232
+ const value = rawValue == null ? label : String(rawValue);
233
+ const icon = typeof rec["icon"] === "string" ? (rec["icon"] as string) : "";
234
+ return { value, label, icon, item };
235
+ }
236
+
237
+ /** Suggestions for the current query — local filter only applies without searchFn. */
238
+ private get filteredItems(): NormalizedItem[] {
239
+ let list = this.allItems;
240
+ if (!this.searchFn && this.filter !== "none" && this.inputText) {
241
+ const q = this.inputText.toLowerCase();
242
+ list = list.filter((i) =>
243
+ this.filter === "starts-with"
244
+ ? i.label.toLowerCase().startsWith(q)
245
+ : i.label.toLowerCase().includes(q)
246
+ );
247
+ }
248
+ return Number.isFinite(this.maxSuggestions) ? list.slice(0, this.maxSuggestions) : list;
249
+ }
250
+
251
+ private get isLoading(): boolean {
252
+ return this.loading || this.searching;
253
+ }
254
+
255
+ private get queryLongEnough(): boolean {
256
+ return this.inputText.length >= this.minSearchLength;
257
+ }
258
+
259
+ // ── Panel open/close + positioning (same proven mechanics as rel-select:
260
+ // fixed panel, flip above when short on space, close on outside
261
+ // pointerdown and on any outside scroll instead of re-anchoring) ──
262
+
263
+ private addGlobalListeners() {
264
+ document.addEventListener("pointerdown", this.onOutsidePointerDown);
265
+ window.addEventListener("resize", this.updatePosition);
266
+ window.addEventListener("scroll", this.onPageScroll, true);
267
+ }
268
+
269
+ private removeGlobalListeners() {
270
+ document.removeEventListener("pointerdown", this.onOutsidePointerDown);
271
+ window.removeEventListener("resize", this.updatePosition);
272
+ window.removeEventListener("scroll", this.onPageScroll, true);
273
+ }
274
+
275
+ private onOutsidePointerDown = (e: PointerEvent) => {
276
+ if (!this.open) return;
277
+ if (e.composedPath().includes(this)) return;
278
+ this.commitExactMatch();
279
+ this.closePanel();
280
+ };
281
+
282
+ private onPageScroll = (e: Event) => {
283
+ if (!this.open) return;
284
+ if (e.composedPath()[0] === this.panelEl) return;
285
+ this.closePanel();
286
+ };
287
+
288
+ private openPanel() {
289
+ if (this.disabled || this.readonly || this.open) return;
290
+ this.open = true;
291
+ this.highlightedIndex = -1;
292
+ this.addGlobalListeners();
293
+ requestAnimationFrame(() => this.updatePosition());
294
+ }
295
+
296
+ private closePanel() {
297
+ if (!this.open) return;
298
+ this.open = false;
299
+ this.highlightedIndex = -1;
300
+ this.removeGlobalListeners();
301
+ }
302
+
303
+ private updatePosition = () => {
304
+ if (!this.fieldEl || !this.panelEl) return;
305
+
306
+ this.panelEl.setAttribute("data-open", "");
307
+
308
+ const t = this.fieldEl.getBoundingClientRect();
309
+ const c = this.panelEl.getBoundingClientRect();
310
+
311
+ let top = t.bottom + 4;
312
+ if (top + c.height > window.innerHeight - 8) {
313
+ top = Math.max(8, t.top - c.height - 4);
314
+ }
315
+
316
+ let left = t.left;
317
+ if (left + t.width > window.innerWidth - 8) {
318
+ left = Math.max(8, window.innerWidth - t.width - 8);
319
+ }
320
+
321
+ this.panelStyle = `top:${top}px;left:${left}px;width:${t.width}px;`;
322
+ };
323
+
324
+ /** Re-evaluates whether the panel should be showing for the current state. */
325
+ private syncPanel() {
326
+ const shouldShow =
327
+ this.queryLongEnough && (this.isLoading || this.filteredItems.length > 0 || !!this.inputText || this.minSearchLength === 0);
328
+ if (shouldShow) {
329
+ if (!this.open) this.openPanel();
330
+ else requestAnimationFrame(() => this.updatePosition());
331
+ } else {
332
+ this.closePanel();
333
+ }
334
+ }
335
+
336
+ // ── Data flow ──
337
+
338
+ private onFieldInput(e: CustomEvent<string>) {
339
+ e.stopPropagation();
340
+ this.inputText = e.detail;
341
+ this.highlightedIndex = -1;
342
+
343
+ // Re-emit as our own rel-change so consumers listen on <rel-autocomplete>
344
+ // (the inner textfield's event is stopped above).
345
+ this.dispatchEvent(
346
+ new CustomEvent<string>("rel-change", { detail: this.inputText, bubbles: true, composed: true })
347
+ );
348
+
349
+ if (this.searchFn) {
350
+ clearTimeout(this.debounceTimer);
351
+ if (!this.queryLongEnough) {
352
+ this.searchSeq++; // invalidate any in-flight query
353
+ this.searching = false;
354
+ this.remoteItems = null;
355
+ this.closePanel();
356
+ return;
357
+ }
358
+ this.searching = true;
359
+ this.syncPanel();
360
+ this.debounceTimer = setTimeout(() => this.runSearch(this.inputText), this.debounceMs);
361
+ } else {
362
+ this.syncPanel();
363
+ }
364
+ }
365
+
366
+ private async runSearch(query: string) {
367
+ const seq = ++this.searchSeq;
368
+ try {
369
+ const results = await this.searchFn!(query);
370
+ if (seq !== this.searchSeq) return; // stale — a newer query superseded this one
371
+ this.remoteItems = results ?? [];
372
+ } catch (err) {
373
+ if (seq !== this.searchSeq) return;
374
+ this.remoteItems = [];
375
+ this.dispatchEvent(
376
+ new CustomEvent("rel-search-error", { detail: { query, error: err }, bubbles: true, composed: true })
377
+ );
378
+ } finally {
379
+ if (seq === this.searchSeq) {
380
+ this.searching = false;
381
+ this.syncPanel();
382
+ }
383
+ }
384
+ }
385
+
386
+ private selectItem(item: NormalizedItem) {
387
+ this.internalValueWrite = true;
388
+ this.value = item.value;
389
+ this.inputText = item.label;
390
+ this.closePanel();
391
+ this.emitChanged(item);
392
+ }
393
+
394
+ /** Silent commit when typed text exactly matches an item (label or value, case-insensitive). */
395
+ private commitExactMatch() {
396
+ if (!this.inputText) return;
397
+ const q = this.inputText.toLowerCase();
398
+ const match = this.allItems.find(
399
+ (i) => i.label.toLowerCase() === q || i.value.toLowerCase() === q
400
+ );
401
+ if (match && match.value !== this.value) {
402
+ this.internalValueWrite = true;
403
+ this.value = match.value;
404
+ this.inputText = match.label;
405
+ this.emitChanged(match);
406
+ }
407
+ }
408
+
409
+ private emitChanged(item: NormalizedItem | null) {
410
+ this.dispatchEvent(
411
+ new CustomEvent("changed", {
412
+ detail: { value: this.value, item: item ? item.item : null },
413
+ bubbles: true,
414
+ composed: true,
415
+ })
416
+ );
417
+ }
418
+
419
+ private clear() {
420
+ if (this.disabled || this.readonly) return;
421
+ const hadValue = !!this.value || !!this.inputText;
422
+ this.internalValueWrite = true;
423
+ this.value = "";
424
+ this.inputText = "";
425
+ this.searchSeq++;
426
+ this.searching = false;
427
+ this.remoteItems = null;
428
+ this.closePanel();
429
+ if (hadValue) {
430
+ this.dispatchEvent(new CustomEvent("rel-clear", { bubbles: true, composed: true }));
431
+ this.emitChanged(null);
432
+ this.dispatchEvent(
433
+ new CustomEvent<string>("rel-change", { detail: "", bubbles: true, composed: true })
434
+ );
435
+ }
436
+ this.fieldEl?.focus();
437
+ }
438
+
439
+ // ── Keyboard / focus ──
440
+
441
+ private onKeyDown(e: KeyboardEvent) {
442
+ if (this.disabled || this.readonly) return;
443
+ const filtered = this.filteredItems;
444
+
445
+ switch (e.key) {
446
+ case "ArrowDown":
447
+ e.preventDefault();
448
+ if (!this.open) {
449
+ if (this.queryLongEnough) this.openPanel();
450
+ return;
451
+ }
452
+ this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
453
+ break;
454
+ case "ArrowUp":
455
+ e.preventDefault();
456
+ if (!this.open) return;
457
+ this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
458
+ break;
459
+ case "Enter":
460
+ if (this.open && this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
461
+ e.preventDefault();
462
+ this.selectItem(filtered[this.highlightedIndex]);
463
+ } else {
464
+ // Free text stays free text — but an exact match commits.
465
+ this.commitExactMatch();
466
+ this.closePanel();
467
+ }
468
+ break;
469
+ case "Escape":
470
+ if (this.open) {
471
+ e.preventDefault();
472
+ this.closePanel();
473
+ }
474
+ break;
475
+ case "Tab":
476
+ this.commitExactMatch();
477
+ this.closePanel();
478
+ break;
479
+ }
480
+ }
481
+
482
+ private onFocusIn() {
483
+ if (this.disabled || this.readonly) return;
484
+ if (this.queryLongEnough && (this.filteredItems.length > 0 || this.minSearchLength === 0)) {
485
+ this.openPanel();
486
+ }
487
+ }
488
+
489
+ private onFocusOut(e: FocusEvent) {
490
+ const next = e.relatedTarget as Node | null;
491
+ if (next && (this.contains(next) || this.renderRoot.contains(next))) return;
492
+ // Focus genuinely left the component (outside pointerdown handles the
493
+ // click-away case before blur; this covers programmatic focus moves).
494
+ this.commitExactMatch();
495
+ this.closePanel();
496
+ }
497
+
498
+ /** Delegates focus to the inner textfield. */
499
+ focus() {
500
+ this.fieldEl?.focus();
501
+ }
502
+
503
+ /** Bolds the first occurrence of the query inside a suggestion label. */
504
+ private renderLabel(label: string): TemplateResult | string {
505
+ const q = this.inputText;
506
+ if (!q) return label;
507
+ const idx = label.toLowerCase().indexOf(q.toLowerCase());
508
+ if (idx < 0) return label;
509
+ return html`${label.slice(0, idx)}<b>${label.slice(idx, idx + q.length)}</b>${label.slice(idx + q.length)}`;
510
+ }
511
+
512
+ render() {
513
+ const filtered = this.filteredItems;
514
+ const panelId = `${this.uniqueId}-panel`;
515
+ const activeOptionId =
516
+ this.open && this.highlightedIndex >= 0 ? `${this.uniqueId}-option-${this.highlightedIndex}` : "";
517
+ const showClear = this.clearable && !!this.inputText && !this.disabled && !this.readonly;
518
+ const showNoResults = !this.isLoading && filtered.length === 0 && this.queryLongEnough && !!this.inputText;
519
+
520
+ // ARIA note: the real <input> lives in rel-textfield's shadow root, which
521
+ // aria-activedescendant can't reach across. The combobox role therefore
522
+ // sits on this wrapper (containing the field) in the same tree as the
523
+ // panel — the pragmatic pattern for nested shadow roots.
524
+ return html`
525
+ <div
526
+ class="ac-field"
527
+ role="combobox"
528
+ aria-haspopup="listbox"
529
+ aria-expanded="${this.open}"
530
+ aria-controls="${panelId}"
531
+ aria-activedescendant="${activeOptionId}"
532
+ @keydown="${this.onKeyDown}"
533
+ @focusin="${this.onFocusIn}"
534
+ @focusout="${this.onFocusOut}"
535
+ >
536
+ <rel-textfield
537
+ exportparts="label, control, input, helper, right-icon"
538
+ .label="${this.label}"
539
+ .placeholder="${this.placeholder}"
540
+ .value="${this.inputText}"
541
+ .size="${this.size}"
542
+ .variant="${this.variant}"
543
+ .floatingLabel="${this.floatingLabel}"
544
+ .prefixIcon="${this.prefixIcon}"
545
+ .disabled="${this.disabled}"
546
+ .readonly="${this.readonly}"
547
+ .required="${this.required}"
548
+ .error="${this.error}"
549
+ .errorMessage="${this.errorMessage}"
550
+ .helperText="${this.helperText}"
551
+ .name="${this.name}"
552
+ .rightIcon="${showClear ? "close" : undefined}"
553
+ .clickableRightIcon="${showClear}"
554
+ right-icon-aria-label="Clear"
555
+ @rel-change="${this.onFieldInput}"
556
+ @rel-right-icon-click="${this.clear}"
557
+ ></rel-textfield>
558
+ </div>
559
+
560
+ <div
561
+ id="${panelId}"
562
+ part="panel list"
563
+ class="ac-panel"
564
+ role="listbox"
565
+ aria-label="${this.label || "Suggestions"}"
566
+ style="${this.panelStyle}"
567
+ ?data-open="${this.open}"
568
+ >
569
+ ${this.isLoading
570
+ ? html`<div class="ac-status" role="status">
571
+ <rel-icon name="status-loading" size="sm" class="ac-spinner"></rel-icon>
572
+ <span>${this.loadingText}</span>
573
+ </div>`
574
+ : nothing}
575
+ ${showNoResults
576
+ ? html`<div class="ac-status">${this.noResultsText}</div>`
577
+ : nothing}
578
+ ${filtered.length > 0
579
+ ? html`<div class="ac-options">
580
+ ${repeat(
581
+ filtered,
582
+ (item) => item.value,
583
+ (item, index) => html`
584
+ <div
585
+ id="${this.uniqueId}-option-${index}"
586
+ part="option"
587
+ class="ac-option ${index === this.highlightedIndex ? "highlighted" : ""}"
588
+ role="option"
589
+ aria-selected="${item.value === this.value}"
590
+ @mousedown="${(e: Event) => e.preventDefault()}"
591
+ @click="${() => this.selectItem(item)}"
592
+ @pointerenter="${() => (this.highlightedIndex = index)}"
593
+ >
594
+ ${item.icon
595
+ ? html`<rel-icon name="${item.icon}" size="sm" class="ac-option-icon"></rel-icon>`
596
+ : nothing}
597
+ <span class="ac-option-label">${this.renderLabel(item.label)}</span>
598
+ </div>
599
+ `
600
+ )}
601
+ </div>`
602
+ : nothing}
603
+ </div>
604
+ `;
605
+ }
606
+ }
607
+
608
+ declare global {
609
+ interface HTMLElementTagNameMap {
610
+ "rel-autocomplete": RelAutocomplete;
611
+ }
612
+ }
@@ -191,13 +191,27 @@ export class RelButton extends LitElement {
191
191
  pointer-events:none on the HOST would make it transparent to the
192
192
  pointer and suppress the cursor entirely. */
193
193
  cursor: not-allowed;
194
+ /* The disabled tokens are full-contrast pairs (light: #0B0B0B on
195
+ #C2C2C2, dark: #FFF on #404040), so on their own they render a
196
+ crisp button that reads as enabled. Dulling happens here instead —
197
+ same opacity mechanism the rest of the library uses. */
198
+ opacity: 0.5;
199
+ }
200
+
201
+ /* Only styles that actually carry a fill flatten to the neutral disabled
202
+ fill. Ghost/link/outline/static stay transparent so a disabled one
203
+ keeps its own shape rather than becoming a solid pill — which is what
204
+ made them look like ordinary enabled buttons. */
205
+ :host([disabled]) button.btn-primary,
206
+ :host([disabled]) button.btn-default,
207
+ :host([disabled]) button.btn-icon {
208
+ background-color: var(--rel-action-brand-fill-disabled) !important;
209
+ color: var(--rel-action-brand-label-icons-primary-disabled) !important;
210
+ border: none !important;
194
211
  }
195
212
 
196
213
  :host([disabled]) button {
197
214
  cursor: not-allowed;
198
- background-color: var(--rel-action-brand-fill-disabled) !important;
199
- color: var(--rel-action-brand-label-icons-primary-disabled) !important;
200
- border: none !important;
201
215
  pointer-events: none;
202
216
  filter: none !important;
203
217
  }
@@ -23,6 +23,7 @@ A tri-state checkbox component supporting checked, unchecked, and indeterminate
23
23
  | Name | Type | Default | Description |
24
24
  |------|------|---------|-------------|
25
25
  | `label` | `string` | `"Label"` | Label text displayed next to the checkbox |
26
+ | `icon` | `string` | `""` | `rel-icon` name rendered between the checkbox and the label (label prefix icon). Unset renders no icon and reserves no space |
26
27
  | `supportingtext` | `string` | `""` | Helper text displayed below the checkbox and label |
27
28
  | `state` | `"default" \| "error" \| "disabled"` | `"default"` | Visual and interaction state of the checkbox |
28
29
  | `status` | `"unchecked" \| "checked" \| "indeterminate"` | `"unchecked"` | Current checked state of the checkbox |