@reliability-design/web 1.4.6 → 1.4.8

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