@reliability-design/web 1.4.6 → 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 (146) 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 -138
  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 -111
  42. package/dist/components/rel-upload/README.md +220 -0
  43. package/dist/index.d.mts +1147 -340
  44. package/dist/index.d.ts +1147 -340
  45. package/dist/index.js +3451 -3163
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +3416 -3118
  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 +3 -2
  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 -720
  55. package/dist/components/rel-avatar/rel-avatar.ts +0 -142
  56. package/dist/components/rel-badge/rel-badge.ts +0 -175
  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 -565
  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 -723
  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 -483
  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/date-parse.test.ts +0 -116
  74. package/dist/components/rel-date-picker/date-parse.ts +0 -178
  75. package/dist/components/rel-date-picker/index.ts +0 -1
  76. package/dist/components/rel-date-picker/rel-date-picker.ts +0 -1676
  77. package/dist/components/rel-divider/rel-divider.ts +0 -54
  78. package/dist/components/rel-drawer/index.ts +0 -1
  79. package/dist/components/rel-drawer/rel-drawer-footer.ts +0 -27
  80. package/dist/components/rel-drawer/rel-drawer.ts +0 -607
  81. package/dist/components/rel-kbd/rel-kbd.ts +0 -188
  82. package/dist/components/rel-layout/rel-layout.ts +0 -49
  83. package/dist/components/rel-menu/rel-menu.ts +0 -541
  84. package/dist/components/rel-menubar/index.ts +0 -2
  85. package/dist/components/rel-menubar/rel-menubar-menu.css +0 -3
  86. package/dist/components/rel-menubar/rel-menubar-menu.ts +0 -17
  87. package/dist/components/rel-menubar/rel-menubar.ts +0 -777
  88. package/dist/components/rel-nav-menu/README.md +0 -289
  89. package/dist/components/rel-nav-menu/index.ts +0 -3
  90. package/dist/components/rel-nav-menu/rel-nav-menu-item.ts +0 -243
  91. package/dist/components/rel-nav-menu/rel-nav-menu-panel.ts +0 -72
  92. package/dist/components/rel-nav-menu/rel-nav-menu.ts +0 -270
  93. package/dist/components/rel-navbar/README.md +0 -126
  94. package/dist/components/rel-navbar/rel-navbar.ts +0 -105
  95. package/dist/components/rel-otp/index.ts +0 -1
  96. package/dist/components/rel-otp/rel-otp.ts +0 -527
  97. package/dist/components/rel-pagination/index.ts +0 -1
  98. package/dist/components/rel-pagination/rel-pagination.ts +0 -522
  99. package/dist/components/rel-popover/index.ts +0 -2
  100. package/dist/components/rel-popover/rel-popover-content.ts +0 -81
  101. package/dist/components/rel-popover/rel-popover.ts +0 -475
  102. package/dist/components/rel-progress/rel-progress.ts +0 -234
  103. package/dist/components/rel-radio/rel-radio.ts +0 -481
  104. package/dist/components/rel-range-picker/rel-range-picker.ts +0 -1688
  105. package/dist/components/rel-resizable/index.ts +0 -3
  106. package/dist/components/rel-resizable/rel-resizable-group.ts +0 -405
  107. package/dist/components/rel-resizable/rel-resizable-handle.ts +0 -150
  108. package/dist/components/rel-resizable/rel-resizable-panel.ts +0 -58
  109. package/dist/components/rel-segmented-button/rel-segmented-button.ts +0 -217
  110. package/dist/components/rel-select/rel-select.ts +0 -1416
  111. package/dist/components/rel-sheet/rel-sheet.ts +0 -610
  112. package/dist/components/rel-sidebar/README.md +0 -209
  113. package/dist/components/rel-sidebar/rel-sidebar.ts +0 -1191
  114. package/dist/components/rel-skeleton/rel-skeleton.ts +0 -143
  115. package/dist/components/rel-slider/rel-slider.ts +0 -673
  116. package/dist/components/rel-snackbar/rel-snackbar.ts +0 -329
  117. package/dist/components/rel-stepper/rel-stepper.ts +0 -567
  118. package/dist/components/rel-switch/rel-switch.ts +0 -199
  119. package/dist/components/rel-tab/rel-tab.ts +0 -264
  120. package/dist/components/rel-table/README.md +0 -178
  121. package/dist/components/rel-table/rel-table-body.ts +0 -22
  122. package/dist/components/rel-table/rel-table-caption.ts +0 -30
  123. package/dist/components/rel-table/rel-table-cell.ts +0 -43
  124. package/dist/components/rel-table/rel-table-footer.ts +0 -24
  125. package/dist/components/rel-table/rel-table-head.ts +0 -47
  126. package/dist/components/rel-table/rel-table-header.ts +0 -22
  127. package/dist/components/rel-table/rel-table-row.ts +0 -37
  128. package/dist/components/rel-table/rel-table.ts +0 -62
  129. package/dist/components/rel-textfield/rel-textfield.ts +0 -1054
  130. package/dist/components/rel-time-picker/rel-time-picker.ts +0 -1482
  131. package/dist/components/rel-time-picker/time-parse.test.ts +0 -67
  132. package/dist/components/rel-time-picker/time-parse.ts +0 -85
  133. package/dist/components/rel-toast/rel-toast.ts +0 -206
  134. package/dist/components/rel-tooltip/rel-tooltip.ts +0 -193
  135. package/dist/components/rel-topbar/rel-topbar-actions.ts +0 -27
  136. package/dist/components/rel-topbar/rel-topbar-brand.ts +0 -54
  137. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +0 -69
  138. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +0 -31
  139. package/dist/components/rel-topbar/rel-topbar-item.ts +0 -324
  140. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +0 -27
  141. package/dist/components/rel-topbar/rel-topbar-profile.ts +0 -243
  142. package/dist/components/rel-topbar/rel-topbar-utilities.ts +0 -27
  143. package/dist/components/rel-topbar/rel-topbar.ts +0 -446
  144. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +0 -134
  145. package/dist/components/rel-upload/rel-upload-actions.ts +0 -163
  146. 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
- }