@reliability-design/web 1.4.5 → 1.4.7

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.
Files changed (142) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/components/rel-accordion/README.md +132 -132
  3. package/dist/components/rel-alert/README.md +145 -141
  4. package/dist/components/rel-autocomplete/README.md +156 -61
  5. package/dist/components/rel-avatar/README.md +116 -130
  6. package/dist/components/rel-badge/README.md +98 -93
  7. package/dist/components/rel-breadcrumb/README.md +96 -102
  8. package/dist/components/rel-button/README.md +160 -130
  9. package/dist/components/rel-calendar/README.md +109 -109
  10. package/dist/components/rel-card/README.md +75 -94
  11. package/dist/components/rel-cardmedia/README.md +100 -127
  12. package/dist/components/rel-carousel/README.md +184 -166
  13. package/dist/components/rel-checkbox/README.md +93 -75
  14. package/dist/components/rel-chip/README.md +132 -121
  15. package/dist/components/rel-container/README.md +107 -99
  16. package/dist/components/rel-date-picker/README.md +200 -289
  17. package/dist/components/rel-divider/README.md +78 -100
  18. package/dist/components/rel-drawer/README.md +187 -200
  19. package/dist/components/rel-kbd/README.md +141 -151
  20. package/dist/components/rel-menu/README.md +178 -151
  21. package/dist/components/rel-menubar/README.md +218 -329
  22. package/dist/components/rel-otp/README.md +170 -440
  23. package/dist/components/rel-pagination/README.md +100 -424
  24. package/dist/components/rel-popover/README.md +203 -568
  25. package/dist/components/rel-progress/README.md +88 -110
  26. package/dist/components/rel-radio/README.md +131 -135
  27. package/dist/components/rel-range-picker/README.md +162 -0
  28. package/dist/components/rel-resizable/README.md +199 -140
  29. package/dist/components/rel-segmented-button/README.md +155 -141
  30. package/dist/components/rel-select/README.md +154 -93
  31. package/dist/components/rel-sheet/README.md +168 -224
  32. package/dist/components/rel-skeleton/README.md +103 -0
  33. package/dist/components/rel-slider/README.md +130 -135
  34. package/dist/components/rel-snackbar/README.md +162 -134
  35. package/dist/components/rel-stepper/README.md +158 -160
  36. package/dist/components/rel-switch/README.md +108 -102
  37. package/dist/components/rel-tab/README.md +171 -163
  38. package/dist/components/rel-textfield/README.md +159 -69
  39. package/dist/components/rel-time-picker/README.md +122 -157
  40. package/dist/components/rel-toast/README.md +157 -148
  41. package/dist/components/rel-tooltip/README.md +108 -103
  42. package/dist/components/rel-upload/README.md +220 -0
  43. package/dist/index.d.mts +1387 -350
  44. package/dist/index.d.ts +1387 -350
  45. package/dist/index.js +5863 -3600
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +5864 -3592
  48. package/dist/index.mjs.map +1 -1
  49. package/dist/styles/vars.css +877 -866
  50. package/dist/{vars-XNMTSRVN.css → vars-JHTUGNRG.css} +877 -866
  51. package/package.json +4 -3
  52. package/dist/components/rel-accordion/rel-accordion.ts +0 -336
  53. package/dist/components/rel-alert/rel-alert.ts +0 -305
  54. package/dist/components/rel-autocomplete/rel-autocomplete.ts +0 -689
  55. package/dist/components/rel-avatar/rel-avatar.ts +0 -142
  56. package/dist/components/rel-badge/rel-badge.ts +0 -170
  57. package/dist/components/rel-breadcrumb/rel-breadcrumb.ts +0 -106
  58. package/dist/components/rel-button/rel-button.ts +0 -560
  59. package/dist/components/rel-calendar/index.ts +0 -1
  60. package/dist/components/rel-calendar/rel-calendar.ts +0 -519
  61. package/dist/components/rel-card/rel-card.ts +0 -88
  62. package/dist/components/rel-cardmedia/rel-cardmedia.ts +0 -88
  63. package/dist/components/rel-carousel/index.ts +0 -1
  64. package/dist/components/rel-carousel/rel-carousel.ts +0 -700
  65. package/dist/components/rel-chart/README.md +0 -286
  66. package/dist/components/rel-chart/index.ts +0 -1
  67. package/dist/components/rel-chart/rel-chart.ts +0 -754
  68. package/dist/components/rel-checkbox/rel-checkbox.ts +0 -382
  69. package/dist/components/rel-chip/rel-chip.ts +0 -472
  70. package/dist/components/rel-container/rel-container.ts +0 -95
  71. package/dist/components/rel-date-picker/date-format.test.ts +0 -43
  72. package/dist/components/rel-date-picker/date-format.ts +0 -47
  73. package/dist/components/rel-date-picker/index.ts +0 -1
  74. package/dist/components/rel-date-picker/rel-date-picker.ts +0 -1237
  75. package/dist/components/rel-divider/rel-divider.ts +0 -54
  76. package/dist/components/rel-drawer/index.ts +0 -1
  77. package/dist/components/rel-drawer/rel-drawer-footer.ts +0 -27
  78. package/dist/components/rel-drawer/rel-drawer.ts +0 -604
  79. package/dist/components/rel-kbd/rel-kbd.ts +0 -188
  80. package/dist/components/rel-layout/rel-layout.ts +0 -49
  81. package/dist/components/rel-menu/rel-menu.ts +0 -535
  82. package/dist/components/rel-menubar/index.ts +0 -2
  83. package/dist/components/rel-menubar/rel-menubar-menu.css +0 -3
  84. package/dist/components/rel-menubar/rel-menubar-menu.ts +0 -17
  85. package/dist/components/rel-menubar/rel-menubar.ts +0 -777
  86. package/dist/components/rel-nav-menu/README.md +0 -289
  87. package/dist/components/rel-nav-menu/index.ts +0 -3
  88. package/dist/components/rel-nav-menu/rel-nav-menu-item.ts +0 -243
  89. package/dist/components/rel-nav-menu/rel-nav-menu-panel.ts +0 -72
  90. package/dist/components/rel-nav-menu/rel-nav-menu.ts +0 -270
  91. package/dist/components/rel-navbar/README.md +0 -126
  92. package/dist/components/rel-navbar/rel-navbar.ts +0 -105
  93. package/dist/components/rel-otp/index.ts +0 -1
  94. package/dist/components/rel-otp/rel-otp.ts +0 -517
  95. package/dist/components/rel-pagination/index.ts +0 -1
  96. package/dist/components/rel-pagination/rel-pagination.ts +0 -509
  97. package/dist/components/rel-popover/index.ts +0 -2
  98. package/dist/components/rel-popover/rel-popover-content.ts +0 -81
  99. package/dist/components/rel-popover/rel-popover.ts +0 -444
  100. package/dist/components/rel-progress/rel-progress.ts +0 -225
  101. package/dist/components/rel-radio/rel-radio.ts +0 -305
  102. package/dist/components/rel-range-picker/rel-range-picker.ts +0 -1331
  103. package/dist/components/rel-resizable/index.ts +0 -3
  104. package/dist/components/rel-resizable/rel-resizable-group.ts +0 -405
  105. package/dist/components/rel-resizable/rel-resizable-handle.ts +0 -150
  106. package/dist/components/rel-resizable/rel-resizable-panel.ts +0 -58
  107. package/dist/components/rel-segmented-button/rel-segmented-button.ts +0 -213
  108. package/dist/components/rel-select/rel-select.ts +0 -1385
  109. package/dist/components/rel-sheet/rel-sheet.ts +0 -465
  110. package/dist/components/rel-sidebar/README.md +0 -209
  111. package/dist/components/rel-sidebar/rel-sidebar.ts +0 -1185
  112. package/dist/components/rel-skeleton/rel-skeleton.ts +0 -134
  113. package/dist/components/rel-slider/rel-slider.ts +0 -670
  114. package/dist/components/rel-snackbar/rel-snackbar.ts +0 -325
  115. package/dist/components/rel-stepper/rel-stepper.ts +0 -565
  116. package/dist/components/rel-switch/rel-switch.ts +0 -197
  117. package/dist/components/rel-tab/rel-tab.ts +0 -264
  118. package/dist/components/rel-table/README.md +0 -178
  119. package/dist/components/rel-table/rel-table-body.ts +0 -22
  120. package/dist/components/rel-table/rel-table-caption.ts +0 -30
  121. package/dist/components/rel-table/rel-table-cell.ts +0 -43
  122. package/dist/components/rel-table/rel-table-footer.ts +0 -24
  123. package/dist/components/rel-table/rel-table-head.ts +0 -47
  124. package/dist/components/rel-table/rel-table-header.ts +0 -22
  125. package/dist/components/rel-table/rel-table-row.ts +0 -37
  126. package/dist/components/rel-table/rel-table.ts +0 -62
  127. package/dist/components/rel-textfield/rel-textfield.ts +0 -866
  128. package/dist/components/rel-time-picker/rel-time-picker.ts +0 -1126
  129. package/dist/components/rel-toast/rel-toast.ts +0 -204
  130. package/dist/components/rel-tooltip/rel-tooltip.ts +0 -190
  131. package/dist/components/rel-topbar/rel-topbar-actions.ts +0 -27
  132. package/dist/components/rel-topbar/rel-topbar-brand.ts +0 -54
  133. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +0 -69
  134. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +0 -31
  135. package/dist/components/rel-topbar/rel-topbar-item.ts +0 -324
  136. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +0 -27
  137. package/dist/components/rel-topbar/rel-topbar-profile.ts +0 -243
  138. package/dist/components/rel-topbar/rel-topbar-utilities.ts +0 -27
  139. package/dist/components/rel-topbar/rel-topbar.ts +0 -446
  140. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +0 -134
  141. package/dist/components/rel-upload/rel-upload-actions.ts +0 -163
  142. package/dist/components/rel-upload/rel-upload.ts +0 -943
@@ -1,689 +0,0 @@
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 {
7
- RelTextfield,
8
- TextFieldSize,
9
- TextFieldVariant,
10
- FieldLabelStyle,
11
- } from "../rel-textfield/rel-textfield.js";
12
-
13
- /** An item is a plain string or any object read through displayField/valueField. */
14
- export type AutocompleteItem = string | Record<string, unknown>;
15
- export type AutocompleteFilter = "contains" | "starts-with" | "none";
16
- export type AutocompleteSearchFn = (query: string) => Promise<AutocompleteItem[]>;
17
-
18
- interface NormalizedItem {
19
- value: string;
20
- label: string;
21
- icon: string;
22
- item: AutocompleteItem;
23
- }
24
-
25
- /**
26
- * `<rel-autocomplete>` — searchable single-select type-ahead field.
27
- *
28
- * Composes `<rel-textfield>` for all field chrome (label, sizes, variants,
29
- * states, icons) and adds a suggestion panel on top. Two data modes:
30
- * - local: set `items`; typing filters them (`filter` mode)
31
- * - remote: set `searchFn`; input is debounced (`debounceMs`), queries are
32
- * latest-wins (stale responses discarded), and `items` is ignored
33
- *
34
- * The committed `value` is always the selected item's value string (its
35
- * `valueField`) — typing alone emits `rel-change` with the raw text but does
36
- * not change `value` until an item is picked (or the text exactly matches an
37
- * item's label/value on Enter/blur).
38
- */
39
- @customElement("rel-autocomplete")
40
- export class RelAutocomplete extends LitElement {
41
- /** Local (or fallback initial) suggestion list. Ignored while searchFn drives results. */
42
- @property({ type: Array }) items: AutocompleteItem[] = [];
43
-
44
- /** Committed value — the selected item's valueField string ("" = none). */
45
- @property({ type: String }) value: string = "";
46
-
47
- /** Object key read for the row label when items are objects. */
48
- @property({ type: String, attribute: "display-field" }) displayField: string = "label";
49
-
50
- /** Object key read for the committed value when items are objects. */
51
- @property({ type: String, attribute: "value-field" }) valueField: string = "value";
52
-
53
- /** Local filtering mode. Ignored when searchFn is set (results arrive pre-filtered). */
54
- @property({ type: String }) filter: AutocompleteFilter = "contains";
55
-
56
- /** Async data source. When set, local filtering is bypassed entirely. */
57
- @property({ attribute: false }) searchFn?: AutocompleteSearchFn;
58
-
59
- /** Debounce for searchFn calls, in ms. */
60
- @property({ type: Number, attribute: "debounce-ms" }) debounceMs: number = 300;
61
-
62
- /** Minimum typed characters before the panel opens / searchFn fires. 0 = open on focus. */
63
- @property({ type: Number, attribute: "min-search-length" }) minSearchLength: number = 1;
64
-
65
- /** Optional cap on rendered suggestions (panel scrolls regardless). */
66
- @property({ type: Number, attribute: "max-suggestions" }) maxSuggestions: number = Infinity;
67
-
68
- /** Shows a trailing ✕ that clears the text and the committed value. */
69
- @property({ type: Boolean }) clearable: boolean = false;
70
-
71
- /** Externally-controlled loading state (internal searchFn loading is automatic). */
72
- @property({ type: Boolean }) loading: boolean = false;
73
-
74
- /** Row shown when a query yields nothing. */
75
- @property({ type: String, attribute: "no-results-text" }) noResultsText: string = "No results found";
76
-
77
- @property({ type: String, attribute: "loading-text" }) loadingText: string = "Loading…";
78
-
79
- // ── Field chrome passthroughs (forwarded to the inner rel-textfield) ──
80
- @property({ type: String }) label?: string;
81
- @property({ type: String }) placeholder: string = "";
82
- @property({ type: String }) size: TextFieldSize = "medium";
83
- @property({ type: String }) variant: TextFieldVariant = "default";
84
- /** See `FieldLabelStyle`. Left `undefined` the deprecated `floatingLabel` decides. */
85
- @property({ type: String, attribute: "label-style", reflect: true })
86
- labelStyle?: FieldLabelStyle;
87
- /** @deprecated Use `labelStyle="floating"`. Kept permanently as an alias. */
88
- @property({ type: Boolean, attribute: "floating-label" }) floatingLabel = false;
89
- /** Accessible name for the input. Required when `labelStyle="none"`. */
90
- @property({ type: String, attribute: "field-aria-label" }) fieldAriaLabel?: string;
91
- /** IDREF list of external element(s) naming the input. Wins over `fieldAriaLabel`. */
92
- @property({ type: String, attribute: "field-aria-labelledby" }) fieldAriaLabelledby?: string;
93
- @property({ type: String, attribute: "prefix-icon" }) prefixIcon?: string;
94
- @property({ type: Boolean }) disabled = false;
95
- @property({ type: Boolean }) readonly = false;
96
- @property({ type: Boolean }) required = false;
97
- @property({ type: Boolean }) error = false;
98
- @property({ type: String, attribute: "error-message" }) errorMessage?: string;
99
- @property({ type: String, attribute: "helper-text" }) helperText?: string;
100
- @property({ type: String }) name?: string;
101
- @property({ type: Boolean, attribute: "full-width" }) fullWidth = true;
102
-
103
- @state() private open = false;
104
- @state() private inputText = "";
105
- @state() private highlightedIndex = -1;
106
- @state() private panelStyle = "";
107
- @state() private searching = false;
108
- /** searchFn results; null = idle/local mode (use items). */
109
- @state() private remoteItems: AutocompleteItem[] | null = null;
110
-
111
- @query("rel-textfield") private fieldEl!: RelTextfield;
112
- @query(".ac-panel") private panelEl!: HTMLDivElement;
113
-
114
- private uniqueId = `autocomplete-${Math.random().toString(36).slice(2)}`;
115
- private debounceTimer?: ReturnType<typeof setTimeout>;
116
- private searchSeq = 0;
117
- /** Set while a selection/clear updates `value` internally, so willUpdate
118
- * doesn't overwrite the input text the way it does for external writes. */
119
- private internalValueWrite = false;
120
-
121
- /** Name used for the suggestion panel — the visible label, or fieldAriaLabel
122
- * once labelStyle="none" hides it. Mirrors rel-textfield's own resolution. */
123
- private get accessibleName() {
124
- return (this.labelStyle === "none" ? this.fieldAriaLabel : this.label) || "";
125
- }
126
-
127
- static styles = css`
128
- :host {
129
- color: var(--rel-text-default);
130
- display: block;
131
- font-family: var(--rel-fonts-font-family);
132
- width: 100%;
133
- }
134
-
135
- :host(:not([full-width])) {
136
- display: inline-block;
137
- width: auto;
138
- }
139
-
140
- /* ── Suggestion panel — mirrors rel-select's listbox so the two read as
141
- one family (same tokens, radius, shadow, row styling). ── */
142
- .ac-panel {
143
- position: fixed;
144
- z-index: 1001;
145
- display: none;
146
- /* Reset native [popover] UA styles (the panel is promoted to the
147
- top layer so transformed ancestors — e.g. a centered rel-modal
148
- panel — can't hijack its containing block or clip it). */
149
- inset: auto;
150
- margin: 0;
151
- padding: 0;
152
- width: auto;
153
- height: auto;
154
- color: inherit;
155
- box-sizing: border-box;
156
- background: var(--rel-autocomplete-bg, var(--rel-bg-neutral));
157
- border: 1px solid var(--rel-autocomplete-menu-border, var(--rel-border-default));
158
- border-radius: var(--rel-autocomplete-menu-radius, var(--rel-border-radius-sm));
159
- box-shadow: var(--rel-autocomplete-menu-shadow, var(--rel-shadows-shadow-300));
160
- max-height: 360px;
161
- overflow-y: auto;
162
- }
163
-
164
- .ac-panel[data-open] {
165
- display: block;
166
- }
167
-
168
- .ac-options {
169
- padding: 4px 0;
170
- }
171
-
172
- .ac-option {
173
- display: flex;
174
- align-items: center;
175
- gap: var(--rel-spacing-xs);
176
- padding: var(--rel-spacing-xs) 12px;
177
- color: var(--rel-autocomplete-value-color, var(--rel-text-default));
178
- cursor: pointer;
179
- font-family: var(--rel-body-md-regular-font-family);
180
- font-weight: var(--rel-body-md-regular-font-weight);
181
- font-size: var(--rel-body-md-regular-font-size);
182
- line-height: var(--rel-body-md-regular-line-height);
183
- letter-spacing: var(--rel-body-md-regular-letter-spacing);
184
- }
185
-
186
- .ac-option:hover,
187
- .ac-option.highlighted {
188
- background: var(--rel-autocomplete-option-hover, var(--rel-bg-neutral-elevated-1));
189
- }
190
-
191
- .ac-option-icon {
192
- flex-shrink: 0;
193
- color: var(--rel-autocomplete-icon-color, var(--rel-text-default));
194
- }
195
-
196
- .ac-option-label {
197
- overflow: hidden;
198
- text-overflow: ellipsis;
199
- white-space: nowrap;
200
- }
201
-
202
- .ac-option-label b {
203
- font-weight: var(--rel-body-md-medium-font-weight);
204
- }
205
-
206
- /* Status rows (loading / no results) — informational, not selectable */
207
- .ac-status {
208
- display: flex;
209
- align-items: center;
210
- gap: var(--rel-spacing-xs);
211
- padding: var(--rel-spacing-xs) 12px;
212
- color: var(--rel-autocomplete-status-color, var(--rel-text-body));
213
- font-family: var(--rel-body-sm-regular-font-family);
214
- font-weight: var(--rel-body-sm-regular-font-weight);
215
- font-size: var(--rel-body-sm-regular-font-size);
216
- line-height: var(--rel-body-sm-regular-line-height);
217
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
218
- }
219
-
220
- .ac-spinner {
221
- animation: ac-spin 0.9s linear infinite;
222
- }
223
-
224
- @keyframes ac-spin {
225
- to {
226
- transform: rotate(360deg);
227
- }
228
- }
229
- `;
230
-
231
- disconnectedCallback() {
232
- super.disconnectedCallback();
233
- this.removeGlobalListeners();
234
- this.hideNativePopover();
235
- clearTimeout(this.debounceTimer);
236
- }
237
-
238
- protected willUpdate(changed: PropertyValues<this>) {
239
- // External value writes (attribute, Angular writeValue) sync the visible
240
- // text to the matching item's label — or the raw value when unknown
241
- // (e.g. remote item not currently in the list).
242
- if (changed.has("value") && !this.internalValueWrite) {
243
- const match = this.allItems.find((i) => i.value === this.value);
244
- this.inputText = match ? match.label : this.value ?? "";
245
- }
246
- this.internalValueWrite = false;
247
- }
248
-
249
- /** Full normalized source list (remote results while searchFn drives, else items). */
250
- private get allItems(): NormalizedItem[] {
251
- const source = this.searchFn && this.remoteItems !== null ? this.remoteItems : this.items;
252
- return source.map((item) => this.normalizeItem(item));
253
- }
254
-
255
- private normalizeItem(item: AutocompleteItem): NormalizedItem {
256
- if (typeof item === "string") return { value: item, label: item, icon: "", item };
257
- const rec = item;
258
- const rawLabel = rec[this.displayField] ?? rec[this.valueField];
259
- const label = rawLabel == null ? "" : String(rawLabel);
260
- const rawValue = rec[this.valueField];
261
- const value = rawValue == null ? label : String(rawValue);
262
- const icon = typeof rec["icon"] === "string" ? (rec["icon"] as string) : "";
263
- return { value, label, icon, item };
264
- }
265
-
266
- /** Suggestions for the current query — local filter only applies without searchFn. */
267
- private get filteredItems(): NormalizedItem[] {
268
- let list = this.allItems;
269
- if (!this.searchFn && this.filter !== "none" && this.inputText) {
270
- const q = this.inputText.toLowerCase();
271
- list = list.filter((i) =>
272
- this.filter === "starts-with"
273
- ? i.label.toLowerCase().startsWith(q)
274
- : i.label.toLowerCase().includes(q)
275
- );
276
- }
277
- return Number.isFinite(this.maxSuggestions) ? list.slice(0, this.maxSuggestions) : list;
278
- }
279
-
280
- private get isLoading(): boolean {
281
- return this.loading || this.searching;
282
- }
283
-
284
- private get queryLongEnough(): boolean {
285
- return this.inputText.length >= this.minSearchLength;
286
- }
287
-
288
- // ── Panel open/close + positioning (same proven mechanics as rel-select:
289
- // fixed panel, flip above when short on space, close on outside
290
- // pointerdown and on any outside scroll instead of re-anchoring) ──
291
-
292
- private addGlobalListeners() {
293
- document.addEventListener("pointerdown", this.onOutsidePointerDown);
294
- window.addEventListener("resize", this.updatePosition);
295
- window.addEventListener("scroll", this.onPageScroll, true);
296
- }
297
-
298
- private removeGlobalListeners() {
299
- document.removeEventListener("pointerdown", this.onOutsidePointerDown);
300
- window.removeEventListener("resize", this.updatePosition);
301
- window.removeEventListener("scroll", this.onPageScroll, true);
302
- }
303
-
304
- private onOutsidePointerDown = (e: PointerEvent) => {
305
- if (!this.open) return;
306
- if (e.composedPath().includes(this)) return;
307
- this.commitExactMatch();
308
- this.closePanel();
309
- };
310
-
311
- private onPageScroll = (e: Event) => {
312
- if (!this.open) return;
313
- if (e.composedPath()[0] === this.panelEl) return;
314
- this.closePanel();
315
- };
316
-
317
- private openPanel() {
318
- if (this.disabled || this.readonly || this.open) return;
319
- this.open = true;
320
- this.highlightedIndex = -1;
321
- this.addGlobalListeners();
322
- requestAnimationFrame(() => {
323
- this.showNativePopover();
324
- this.updatePosition();
325
- });
326
- }
327
-
328
- private closePanel() {
329
- if (!this.open) return;
330
- this.open = false;
331
- this.highlightedIndex = -1;
332
- this.removeGlobalListeners();
333
- this.hideNativePopover();
334
- }
335
-
336
- /* A transformed ancestor (e.g. rel-modal's centered panel with
337
- * translate(-50%,-50%)) becomes the containing block for position:fixed
338
- * descendants, breaking viewport-based coordinates and letting the
339
- * panel's overflow:hidden clip the suggestions. Promoting the panel to
340
- * the browser top layer sidesteps both — same fix as rel-popover.
341
- * `manual` keeps light-dismiss and Escape handling under this
342
- * component's own control. */
343
-
344
- private showNativePopover() {
345
- const p = this.panelEl;
346
- if (!p || typeof p.showPopover !== "function") return;
347
- try {
348
- if (!p.matches(":popover-open")) p.showPopover();
349
- } catch {
350
- /* older engines / detached element — fixed-position fallback applies */
351
- }
352
- }
353
-
354
- private hideNativePopover() {
355
- const p = this.panelEl;
356
- if (!p || typeof p.hidePopover !== "function") return;
357
- try {
358
- if (p.matches(":popover-open")) p.hidePopover();
359
- } catch {
360
- /* already hidden or unsupported — nothing to do */
361
- }
362
- }
363
-
364
- private updatePosition = () => {
365
- if (!this.fieldEl || !this.panelEl) return;
366
-
367
- this.panelEl.setAttribute("data-open", "");
368
-
369
- const t = this.fieldEl.getBoundingClientRect();
370
- const c = this.panelEl.getBoundingClientRect();
371
-
372
- let top = t.bottom + 4;
373
- if (top + c.height > window.innerHeight - 8) {
374
- top = Math.max(8, t.top - c.height - 4);
375
- }
376
-
377
- let left = t.left;
378
- if (left + t.width > window.innerWidth - 8) {
379
- left = Math.max(8, window.innerWidth - t.width - 8);
380
- }
381
-
382
- // Without top-layer support a transformed ancestor may be the containing
383
- // block for position:fixed, shifting the panel away from the viewport
384
- // coordinates computed above. Apply, re-measure, and cancel the offset.
385
- this.panelEl.style.top = `${top}px`;
386
- this.panelEl.style.left = `${left}px`;
387
- this.panelEl.style.width = `${t.width}px`;
388
- const applied = this.panelEl.getBoundingClientRect();
389
- top -= applied.top - top;
390
- left -= applied.left - left;
391
- this.panelEl.style.top = `${top}px`;
392
- this.panelEl.style.left = `${left}px`;
393
-
394
- this.panelStyle = `top:${top}px;left:${left}px;width:${t.width}px;`;
395
- };
396
-
397
- /** Re-evaluates whether the panel should be showing for the current state. */
398
- private syncPanel() {
399
- const shouldShow =
400
- this.queryLongEnough && (this.isLoading || this.filteredItems.length > 0 || !!this.inputText || this.minSearchLength === 0);
401
- if (shouldShow) {
402
- if (!this.open) this.openPanel();
403
- else requestAnimationFrame(() => this.updatePosition());
404
- } else {
405
- this.closePanel();
406
- }
407
- }
408
-
409
- // ── Data flow ──
410
-
411
- private onFieldInput(e: CustomEvent<string>) {
412
- e.stopPropagation();
413
- this.inputText = e.detail;
414
- this.highlightedIndex = -1;
415
-
416
- // Re-emit as our own rel-change so consumers listen on <rel-autocomplete>
417
- // (the inner textfield's event is stopped above).
418
- this.dispatchEvent(
419
- new CustomEvent<string>("rel-change", { detail: this.inputText, bubbles: true, composed: true })
420
- );
421
-
422
- if (this.searchFn) {
423
- clearTimeout(this.debounceTimer);
424
- if (!this.queryLongEnough) {
425
- this.searchSeq++; // invalidate any in-flight query
426
- this.searching = false;
427
- this.remoteItems = null;
428
- this.closePanel();
429
- return;
430
- }
431
- this.searching = true;
432
- this.syncPanel();
433
- this.debounceTimer = setTimeout(() => this.runSearch(this.inputText), this.debounceMs);
434
- } else {
435
- this.syncPanel();
436
- }
437
- }
438
-
439
- private async runSearch(query: string) {
440
- const seq = ++this.searchSeq;
441
- try {
442
- const results = await this.searchFn!(query);
443
- if (seq !== this.searchSeq) return; // stale — a newer query superseded this one
444
- this.remoteItems = results ?? [];
445
- } catch (err) {
446
- if (seq !== this.searchSeq) return;
447
- this.remoteItems = [];
448
- this.dispatchEvent(
449
- new CustomEvent("rel-search-error", { detail: { query, error: err }, bubbles: true, composed: true })
450
- );
451
- } finally {
452
- if (seq === this.searchSeq) {
453
- this.searching = false;
454
- this.syncPanel();
455
- }
456
- }
457
- }
458
-
459
- private selectItem(item: NormalizedItem) {
460
- this.internalValueWrite = true;
461
- this.value = item.value;
462
- this.inputText = item.label;
463
- this.closePanel();
464
- this.emitChanged(item);
465
- }
466
-
467
- /** Silent commit when typed text exactly matches an item (label or value, case-insensitive). */
468
- private commitExactMatch() {
469
- if (!this.inputText) return;
470
- const q = this.inputText.toLowerCase();
471
- const match = this.allItems.find(
472
- (i) => i.label.toLowerCase() === q || i.value.toLowerCase() === q
473
- );
474
- if (match && match.value !== this.value) {
475
- this.internalValueWrite = true;
476
- this.value = match.value;
477
- this.inputText = match.label;
478
- this.emitChanged(match);
479
- }
480
- }
481
-
482
- private emitChanged(item: NormalizedItem | null) {
483
- this.dispatchEvent(
484
- new CustomEvent("changed", {
485
- detail: { value: this.value, item: item ? item.item : null },
486
- bubbles: true,
487
- composed: true,
488
- })
489
- );
490
- }
491
-
492
- private clear() {
493
- if (this.disabled || this.readonly) return;
494
- const hadValue = !!this.value || !!this.inputText;
495
- this.internalValueWrite = true;
496
- this.value = "";
497
- this.inputText = "";
498
- this.searchSeq++;
499
- this.searching = false;
500
- this.remoteItems = null;
501
- this.closePanel();
502
- if (hadValue) {
503
- this.dispatchEvent(new CustomEvent("rel-clear", { bubbles: true, composed: true }));
504
- this.emitChanged(null);
505
- this.dispatchEvent(
506
- new CustomEvent<string>("rel-change", { detail: "", bubbles: true, composed: true })
507
- );
508
- }
509
- this.fieldEl?.focus();
510
- }
511
-
512
- // ── Keyboard / focus ──
513
-
514
- private onKeyDown(e: KeyboardEvent) {
515
- if (this.disabled || this.readonly) return;
516
- const filtered = this.filteredItems;
517
-
518
- switch (e.key) {
519
- case "ArrowDown":
520
- e.preventDefault();
521
- if (!this.open) {
522
- if (this.queryLongEnough) this.openPanel();
523
- return;
524
- }
525
- this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
526
- break;
527
- case "ArrowUp":
528
- e.preventDefault();
529
- if (!this.open) return;
530
- this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
531
- break;
532
- case "Enter":
533
- if (this.open && this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
534
- e.preventDefault();
535
- this.selectItem(filtered[this.highlightedIndex]);
536
- } else {
537
- // Free text stays free text — but an exact match commits.
538
- this.commitExactMatch();
539
- this.closePanel();
540
- }
541
- break;
542
- case "Escape":
543
- if (this.open) {
544
- e.preventDefault();
545
- this.closePanel();
546
- }
547
- break;
548
- case "Tab":
549
- this.commitExactMatch();
550
- this.closePanel();
551
- break;
552
- }
553
- }
554
-
555
- private onFocusIn() {
556
- if (this.disabled || this.readonly) return;
557
- if (this.queryLongEnough && (this.filteredItems.length > 0 || this.minSearchLength === 0)) {
558
- this.openPanel();
559
- }
560
- }
561
-
562
- private onFocusOut(e: FocusEvent) {
563
- const next = e.relatedTarget as Node | null;
564
- if (next && (this.contains(next) || this.renderRoot.contains(next))) return;
565
- // Focus genuinely left the component (outside pointerdown handles the
566
- // click-away case before blur; this covers programmatic focus moves).
567
- this.commitExactMatch();
568
- this.closePanel();
569
- }
570
-
571
- /** Delegates focus to the inner textfield. */
572
- focus() {
573
- this.fieldEl?.focus();
574
- }
575
-
576
- /** Bolds the first occurrence of the query inside a suggestion label. */
577
- private renderLabel(label: string): TemplateResult | string {
578
- const q = this.inputText;
579
- if (!q) return label;
580
- const idx = label.toLowerCase().indexOf(q.toLowerCase());
581
- if (idx < 0) return label;
582
- return html`${label.slice(0, idx)}<b>${label.slice(idx, idx + q.length)}</b>${label.slice(idx + q.length)}`;
583
- }
584
-
585
- render() {
586
- const filtered = this.filteredItems;
587
- const panelId = `${this.uniqueId}-panel`;
588
- const activeOptionId =
589
- this.open && this.highlightedIndex >= 0 ? `${this.uniqueId}-option-${this.highlightedIndex}` : "";
590
- const showClear = this.clearable && !!this.inputText && !this.disabled && !this.readonly;
591
- const showNoResults = !this.isLoading && filtered.length === 0 && this.queryLongEnough && !!this.inputText;
592
-
593
- // ARIA note: the real <input> lives in rel-textfield's shadow root, which
594
- // aria-activedescendant can't reach across. The combobox role therefore
595
- // sits on this wrapper (containing the field) in the same tree as the
596
- // panel — the pragmatic pattern for nested shadow roots.
597
- return html`
598
- <div
599
- class="ac-field"
600
- role="combobox"
601
- aria-haspopup="listbox"
602
- aria-expanded="${this.open}"
603
- aria-controls="${panelId}"
604
- aria-activedescendant="${activeOptionId}"
605
- @keydown="${this.onKeyDown}"
606
- @focusin="${this.onFocusIn}"
607
- @focusout="${this.onFocusOut}"
608
- >
609
- <rel-textfield
610
- exportparts="label, control, input, helper, right-icon"
611
- .label="${this.label}"
612
- .placeholder="${this.placeholder}"
613
- .value="${this.inputText}"
614
- .size="${this.size}"
615
- .variant="${this.variant}"
616
- .labelStyle="${this.labelStyle}"
617
- .floatingLabel="${this.floatingLabel}"
618
- .fieldAriaLabel="${this.fieldAriaLabel}"
619
- .fieldAriaLabelledby="${this.fieldAriaLabelledby}"
620
- .prefixIcon="${this.prefixIcon}"
621
- .disabled="${this.disabled}"
622
- .readonly="${this.readonly}"
623
- .required="${this.required}"
624
- .error="${this.error}"
625
- .errorMessage="${this.errorMessage}"
626
- .helperText="${this.helperText}"
627
- .name="${this.name}"
628
- .rightIcon="${showClear ? "close" : undefined}"
629
- .clickableRightIcon="${showClear}"
630
- right-icon-aria-label="Clear"
631
- @rel-change="${this.onFieldInput}"
632
- @rel-right-icon-click="${this.clear}"
633
- ></rel-textfield>
634
- </div>
635
-
636
- <div
637
- id="${panelId}"
638
- part="panel list"
639
- class="ac-panel"
640
- popover="manual"
641
- role="listbox"
642
- aria-label="${this.accessibleName || "Suggestions"}"
643
- style="${this.panelStyle}"
644
- ?data-open="${this.open}"
645
- >
646
- ${this.isLoading
647
- ? html`<div class="ac-status" role="status">
648
- <rel-icon name="status-loading" size="sm" class="ac-spinner"></rel-icon>
649
- <span>${this.loadingText}</span>
650
- </div>`
651
- : nothing}
652
- ${showNoResults
653
- ? html`<div class="ac-status">${this.noResultsText}</div>`
654
- : nothing}
655
- ${filtered.length > 0
656
- ? html`<div class="ac-options">
657
- ${repeat(
658
- filtered,
659
- (item) => item.value,
660
- (item, index) => html`
661
- <div
662
- id="${this.uniqueId}-option-${index}"
663
- part="option"
664
- class="ac-option ${index === this.highlightedIndex ? "highlighted" : ""}"
665
- role="option"
666
- aria-selected="${item.value === this.value}"
667
- @mousedown="${(e: Event) => e.preventDefault()}"
668
- @click="${() => this.selectItem(item)}"
669
- @pointerenter="${() => (this.highlightedIndex = index)}"
670
- >
671
- ${item.icon
672
- ? html`<rel-icon name="${item.icon}" size="sm" class="ac-option-icon"></rel-icon>`
673
- : nothing}
674
- <span class="ac-option-label">${this.renderLabel(item.label)}</span>
675
- </div>
676
- `
677
- )}
678
- </div>`
679
- : nothing}
680
- </div>
681
- `;
682
- }
683
- }
684
-
685
- declare global {
686
- interface HTMLElementTagNameMap {
687
- "rel-autocomplete": RelAutocomplete;
688
- }
689
- }