@f-ewald/components 0.7.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +76 -47
  2. package/custom-elements.json +3049 -910
  3. package/dist/address-autocomplete.d.ts +8 -0
  4. package/dist/address-autocomplete.d.ts.map +1 -1
  5. package/dist/address-autocomplete.js +165 -15
  6. package/dist/address-autocomplete.js.map +1 -1
  7. package/dist/animate-confetti.d.ts +8 -5
  8. package/dist/animate-confetti.d.ts.map +1 -1
  9. package/dist/animate-confetti.js +46 -19
  10. package/dist/animate-confetti.js.map +1 -1
  11. package/dist/autocomplete-input.d.ts +8 -0
  12. package/dist/autocomplete-input.d.ts.map +1 -1
  13. package/dist/autocomplete-input.js +165 -15
  14. package/dist/autocomplete-input.js.map +1 -1
  15. package/dist/calendar-entry.d.ts +33 -0
  16. package/dist/calendar-entry.d.ts.map +1 -0
  17. package/dist/calendar-entry.js +64 -0
  18. package/dist/calendar-entry.js.map +1 -0
  19. package/dist/calendar-month.d.ts +67 -0
  20. package/dist/calendar-month.d.ts.map +1 -0
  21. package/dist/calendar-month.js +469 -0
  22. package/dist/calendar-month.js.map +1 -0
  23. package/dist/calendar-year.d.ts +43 -0
  24. package/dist/calendar-year.d.ts.map +1 -0
  25. package/dist/calendar-year.js +145 -0
  26. package/dist/calendar-year.js.map +1 -0
  27. package/dist/chat-message.d.ts.map +1 -1
  28. package/dist/chat-message.js +77 -24
  29. package/dist/chat-message.js.map +1 -1
  30. package/dist/confirm-dialog.d.ts +8 -1
  31. package/dist/confirm-dialog.d.ts.map +1 -1
  32. package/dist/confirm-dialog.js +130 -18
  33. package/dist/confirm-dialog.js.map +1 -1
  34. package/dist/copy-link-button.d.ts +2 -0
  35. package/dist/copy-link-button.d.ts.map +1 -1
  36. package/dist/copy-link-button.js +35 -3
  37. package/dist/copy-link-button.js.map +1 -1
  38. package/dist/data-table.d.ts +2 -0
  39. package/dist/data-table.d.ts.map +1 -1
  40. package/dist/data-table.js +83 -11
  41. package/dist/data-table.js.map +1 -1
  42. package/dist/distance-value.d.ts +0 -1
  43. package/dist/distance-value.d.ts.map +1 -1
  44. package/dist/distance-value.js +3 -8
  45. package/dist/distance-value.js.map +1 -1
  46. package/dist/distribution-chart.d.ts.map +1 -1
  47. package/dist/distribution-chart.js +54 -17
  48. package/dist/distribution-chart.js.map +1 -1
  49. package/dist/dropdown-button.d.ts.map +1 -1
  50. package/dist/dropdown-button.js +60 -11
  51. package/dist/dropdown-button.js.map +1 -1
  52. package/dist/editable-text.d.ts.map +1 -1
  53. package/dist/editable-text.js +46 -13
  54. package/dist/editable-text.js.map +1 -1
  55. package/dist/form-select.d.ts +21 -2
  56. package/dist/form-select.d.ts.map +1 -1
  57. package/dist/form-select.js +489 -37
  58. package/dist/form-select.js.map +1 -1
  59. package/dist/frame-box.d.ts.map +1 -1
  60. package/dist/frame-box.js +20 -12
  61. package/dist/frame-box.js.map +1 -1
  62. package/dist/icon-button.d.ts.map +1 -1
  63. package/dist/icon-button.js +11 -0
  64. package/dist/icon-button.js.map +1 -1
  65. package/dist/index.d.ts +4 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +4 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/kbd-hint.d.ts +30 -0
  70. package/dist/kbd-hint.d.ts.map +1 -0
  71. package/dist/kbd-hint.js +184 -0
  72. package/dist/kbd-hint.js.map +1 -0
  73. package/dist/live-timer.d.ts +0 -1
  74. package/dist/live-timer.d.ts.map +1 -1
  75. package/dist/live-timer.js +1 -6
  76. package/dist/live-timer.js.map +1 -1
  77. package/dist/map-circle.d.ts +3 -0
  78. package/dist/map-circle.d.ts.map +1 -1
  79. package/dist/map-circle.js +19 -1
  80. package/dist/map-circle.js.map +1 -1
  81. package/dist/map-pin.d.ts.map +1 -1
  82. package/dist/map-pin.js +16 -1
  83. package/dist/map-pin.js.map +1 -1
  84. package/dist/percent-bar-chart.d.ts +3 -0
  85. package/dist/percent-bar-chart.d.ts.map +1 -1
  86. package/dist/percent-bar-chart.js +60 -4
  87. package/dist/percent-bar-chart.js.map +1 -1
  88. package/dist/photo-gallery.d.ts.map +1 -1
  89. package/dist/photo-gallery.js +12 -3
  90. package/dist/photo-gallery.js.map +1 -1
  91. package/dist/popover-panel.d.ts.map +1 -1
  92. package/dist/popover-panel.js +132 -23
  93. package/dist/popover-panel.js.map +1 -1
  94. package/dist/price-history-chart.d.ts.map +1 -1
  95. package/dist/price-history-chart.js +52 -18
  96. package/dist/price-history-chart.js.map +1 -1
  97. package/dist/radio-cards.d.ts +2 -0
  98. package/dist/radio-cards.d.ts.map +1 -1
  99. package/dist/radio-cards.js +51 -5
  100. package/dist/radio-cards.js.map +1 -1
  101. package/dist/radio-pills.d.ts +2 -0
  102. package/dist/radio-pills.d.ts.map +1 -1
  103. package/dist/radio-pills.js +46 -4
  104. package/dist/radio-pills.js.map +1 -1
  105. package/dist/relative-time.d.ts +0 -1
  106. package/dist/relative-time.d.ts.map +1 -1
  107. package/dist/relative-time.js +1 -6
  108. package/dist/relative-time.js.map +1 -1
  109. package/dist/reveal-button.d.ts +2 -0
  110. package/dist/reveal-button.d.ts.map +1 -1
  111. package/dist/reveal-button.js +42 -5
  112. package/dist/reveal-button.js.map +1 -1
  113. package/dist/roman-numeral.js +1 -1
  114. package/dist/roman-numeral.js.map +1 -1
  115. package/dist/slide-panel.d.ts +7 -3
  116. package/dist/slide-panel.d.ts.map +1 -1
  117. package/dist/slide-panel.js +95 -23
  118. package/dist/slide-panel.js.map +1 -1
  119. package/dist/stat-meter.d.ts.map +1 -1
  120. package/dist/stat-meter.js +17 -7
  121. package/dist/stat-meter.js.map +1 -1
  122. package/dist/status-pill.d.ts.map +1 -1
  123. package/dist/status-pill.js +26 -12
  124. package/dist/status-pill.js.map +1 -1
  125. package/dist/tile-grid.d.ts +8 -0
  126. package/dist/tile-grid.d.ts.map +1 -1
  127. package/dist/tile-grid.js +88 -12
  128. package/dist/tile-grid.js.map +1 -1
  129. package/dist/toast-notification.d.ts.map +1 -1
  130. package/dist/toast-notification.js +40 -11
  131. package/dist/toast-notification.js.map +1 -1
  132. package/dist/tokens.css +23 -0
  133. package/dist/tokens.d.ts.map +1 -1
  134. package/dist/tokens.js +16 -0
  135. package/dist/tokens.js.map +1 -1
  136. package/dist/ui-button.d.ts +2 -0
  137. package/dist/ui-button.d.ts.map +1 -1
  138. package/dist/ui-button.js +58 -9
  139. package/dist/ui-button.js.map +1 -1
  140. package/dist/user-avatar.d.ts.map +1 -1
  141. package/dist/user-avatar.js +18 -3
  142. package/dist/user-avatar.js.map +1 -1
  143. package/dist/utils/calendar.d.ts +77 -0
  144. package/dist/utils/calendar.d.ts.map +1 -0
  145. package/dist/utils/calendar.js +135 -0
  146. package/dist/utils/calendar.js.map +1 -0
  147. package/dist/utils/form.d.ts +3 -0
  148. package/dist/utils/form.d.ts.map +1 -0
  149. package/dist/utils/form.js +14 -0
  150. package/dist/utils/form.js.map +1 -0
  151. package/dist/utils/layer-stack.d.ts +9 -0
  152. package/dist/utils/layer-stack.d.ts.map +1 -0
  153. package/dist/utils/layer-stack.js +35 -0
  154. package/dist/utils/layer-stack.js.map +1 -0
  155. package/dist/weight-bar-chart.d.ts.map +1 -1
  156. package/dist/weight-bar-chart.js +27 -8
  157. package/dist/weight-bar-chart.js.map +1 -1
  158. package/docs/address-autocomplete.md +5 -0
  159. package/docs/autocomplete-input.md +5 -0
  160. package/docs/calendar-entry.md +53 -0
  161. package/docs/calendar-month.md +79 -0
  162. package/docs/calendar-year.md +53 -0
  163. package/docs/chat-message.md +4 -0
  164. package/docs/confirm-dialog.md +2 -0
  165. package/docs/copy-link-button.md +2 -0
  166. package/docs/data-table.md +2 -0
  167. package/docs/design-language.md +149 -0
  168. package/docs/distribution-chart.md +4 -0
  169. package/docs/dropdown-button.md +1 -0
  170. package/docs/form-select.md +15 -3
  171. package/docs/frame-box.md +2 -0
  172. package/docs/kbd-hint.md +42 -0
  173. package/docs/map-circle.md +4 -0
  174. package/docs/percent-bar-chart.md +4 -1
  175. package/docs/photo-gallery.md +1 -0
  176. package/docs/popover-panel.md +2 -0
  177. package/docs/price-history-chart.md +8 -1
  178. package/docs/radio-cards.md +2 -0
  179. package/docs/radio-pills.md +2 -0
  180. package/docs/reveal-button.md +3 -0
  181. package/docs/slide-panel.md +4 -2
  182. package/docs/status-pill.md +3 -0
  183. package/docs/tile-grid.md +10 -1
  184. package/docs/toast-notification.md +4 -1
  185. package/docs/ui-button.md +2 -0
  186. package/docs/user-avatar.md +1 -0
  187. package/llms.txt +175 -61
  188. package/package.json +14 -2
  189. package/dist/map-point.d.ts +0 -31
  190. package/dist/map-point.d.ts.map +0 -1
  191. package/dist/map-point.js +0 -83
  192. package/dist/map-point.js.map +0 -1
  193. package/docs/map-point.md +0 -44
@@ -4,10 +4,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { LitElement, css, html, nothing } from "lit";
7
+ import { LitElement, css, html, nothing, } from "lit";
8
8
  import { customElement, property, state } from "lit/decorators.js";
9
9
  import { iconChevronRight } from "./icons.js";
10
10
  import { tokens } from "./tokens.js";
11
+ let instanceCount = 0;
11
12
  /**
12
13
  * A styled dropdown select: a trigger button showing the current option's
13
14
  * label, opening a listbox popover on click. Drop-in generic replacement for
@@ -21,6 +22,13 @@ import { tokens } from "./tokens.js";
21
22
  * bar, a status picker) keep shrink-to-fit auto-width unchanged. To make an
22
23
  * instance full-width, size the host itself: `form-select { width: 100%; }`.
23
24
  *
25
+ * Set `searchable` to replace the button trigger with an editable combobox
26
+ * that filters the predefined options by case-insensitive label infix. Typed
27
+ * text is only a query: `value` changes exclusively when an actual option is
28
+ * selected, and an uncommitted query is discarded when the list closes.
29
+ * Each option may also provide a pre-rendered `icon` and square `iconSize`;
30
+ * iconless options reserve no leading space.
31
+ *
24
32
  * @element form-select
25
33
  * @fires change - Fired with `{ value: string }` when a different option is picked.
26
34
  */
@@ -35,11 +43,26 @@ let FormSelect = class FormSelect extends LitElement {
35
43
  this.label = "";
36
44
  /** Disables the trigger, preventing the popover from opening. */
37
45
  this.disabled = false;
46
+ /**
47
+ * Enables editable, case-insensitive infix filtering by option label.
48
+ * Typed text never becomes the selected `value`.
49
+ */
50
+ this.searchable = false;
38
51
  this._open = false;
39
52
  this._activeIndex = -1;
53
+ this._query = null;
54
+ this.#listboxPointerDown = false;
55
+ this.#suppressNextBlur = false;
56
+ this.#restoringSearchFocus = false;
57
+ this.#searchSelection = null;
58
+ this.#isComposing = false;
59
+ this.#compositionJustEnded = false;
60
+ this.#compositionEndTimer = null;
61
+ this.#blurTimer = null;
62
+ this.#listboxId = `form-select-listbox-${++instanceCount}`;
40
63
  this.#onWindowMousedown = (e) => {
41
64
  if (!e.composedPath().includes(this))
42
- this._open = false;
65
+ this.#close();
43
66
  };
44
67
  }
45
68
  static { this.styles = [
@@ -48,25 +71,34 @@ let FormSelect = class FormSelect extends LitElement {
48
71
  :host {
49
72
  display: inline-block;
50
73
  position: relative;
51
- font-family: var(--ui-font, ui-sans-serif, system-ui, sans-serif);
74
+ font-family: var(
75
+ --ui-font,
76
+ ui-sans-serif,
77
+ system-ui,
78
+ sans-serif,
79
+ "Apple Color Emoji",
80
+ "Segoe UI Emoji",
81
+ "Segoe UI Symbol",
82
+ "Noto Color Emoji"
83
+ );
52
84
  font-size: var(--ui-font-size-sm, 0.75rem);
53
85
  }
54
86
  button.trigger {
55
87
  display: flex;
56
88
  align-items: center;
57
89
  justify-content: space-between;
58
- gap: 0.35rem;
90
+ gap: 0.25rem;
59
91
  width: 100%;
60
92
  box-sizing: border-box;
61
93
  font: inherit;
62
94
  color: var(--ui-text, #0f172a);
63
- background: var(--ui-surface, #fff);
95
+ background: var(--ui-surface, #ffffff);
64
96
  border: 1px solid var(--ui-border, #e2e8f0);
65
97
  border-radius: var(--ui-radius-sm, 0.25rem);
66
- padding: 0.3rem 0.5rem;
98
+ padding: 0.25rem 0.5rem;
67
99
  cursor: pointer;
68
100
  }
69
- button.trigger:hover {
101
+ button.trigger:hover:not(:disabled) {
70
102
  background: var(--ui-surface-muted, #f8fafc);
71
103
  }
72
104
  button.trigger:disabled {
@@ -78,11 +110,55 @@ let FormSelect = class FormSelect extends LitElement {
78
110
  border-color: var(--ui-primary, #4f46e5);
79
111
  box-shadow: var(--ui-focus-ring, 0 0 0 3px rgb(79 70 229 / 0.35));
80
112
  }
113
+ .search-trigger {
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: space-between;
117
+ gap: 0.25rem;
118
+ width: 100%;
119
+ box-sizing: border-box;
120
+ color: var(--ui-text, #0f172a);
121
+ background: var(--ui-surface, #ffffff);
122
+ border: 1px solid var(--ui-border, #e2e8f0);
123
+ border-radius: var(--ui-radius-sm, 0.25rem);
124
+ cursor: text;
125
+ }
126
+ .search-trigger:hover:not(.disabled) {
127
+ background: var(--ui-surface-muted, #f8fafc);
128
+ }
129
+ .search-trigger:not(.disabled):focus-within {
130
+ border-color: var(--ui-primary, #4f46e5);
131
+ box-shadow: var(--ui-focus-ring, 0 0 0 3px rgb(79 70 229 / 0.35));
132
+ }
133
+ .search-trigger.disabled {
134
+ cursor: not-allowed;
135
+ opacity: 0.6;
136
+ }
137
+ .search-trigger.has-icon input.search-input {
138
+ padding-left: 0;
139
+ }
140
+ input.search-input {
141
+ min-width: 0;
142
+ flex: 1;
143
+ box-sizing: border-box;
144
+ padding: 0.25rem 0 0.25rem 0.5rem;
145
+ color: inherit;
146
+ background: transparent;
147
+ border: 0;
148
+ outline: 0;
149
+ font: inherit;
150
+ }
81
151
  .chevron {
82
152
  display: flex;
153
+ flex: 0 0 auto;
154
+ margin-right: 0.5rem;
83
155
  color: var(--ui-text-muted, #64748b);
156
+ pointer-events: none;
84
157
  transform: rotate(90deg);
85
- transition: transform 0.1s ease;
158
+ transition: transform 150ms ease;
159
+ }
160
+ button.trigger .chevron {
161
+ margin-right: 0;
86
162
  }
87
163
  :host([open]) .chevron {
88
164
  transform: rotate(-90deg);
@@ -95,17 +171,28 @@ let FormSelect = class FormSelect extends LitElement {
95
171
  min-width: 100%;
96
172
  max-height: 40vh;
97
173
  overflow-y: auto;
98
- margin: 2px 0 0;
99
- padding: 4px 0;
174
+ scrollbar-gutter: stable;
175
+ margin: 0.25rem 0 0;
176
+ padding: 0.25rem 0;
100
177
  list-style: none;
101
178
  white-space: nowrap;
102
- background: var(--ui-surface, #fff);
179
+ color: var(--ui-text, #0f172a);
180
+ background: var(--ui-surface, #ffffff);
103
181
  border: 1px solid var(--ui-border, #e2e8f0);
104
182
  border-radius: var(--ui-radius-sm, 0.25rem);
105
183
  box-shadow: var(--ui-shadow, 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1));
106
184
  }
185
+ ul.options::-webkit-scrollbar {
186
+ width: 0.75rem;
187
+ }
188
+ ul.options::-webkit-scrollbar-thumb {
189
+ background: var(--ui-border, #e2e8f0);
190
+ border: 0.25rem solid transparent;
191
+ border-radius: 999px;
192
+ background-clip: padding-box;
193
+ }
107
194
  li {
108
- padding: 0.35rem 0.6rem;
195
+ padding: 0.5rem;
109
196
  cursor: pointer;
110
197
  }
111
198
  li.active,
@@ -116,34 +203,167 @@ let FormSelect = class FormSelect extends LitElement {
116
203
  font-weight: 600;
117
204
  color: var(--ui-primary, #4f46e5);
118
205
  }
206
+ .option-content {
207
+ display: inline-flex;
208
+ min-width: 0;
209
+ align-items: center;
210
+ gap: 0.5rem;
211
+ }
212
+ li .option-content {
213
+ display: flex;
214
+ width: 100%;
215
+ }
216
+ .option-icon {
217
+ display: inline-flex;
218
+ width: var(--form-select-icon-size);
219
+ height: var(--form-select-icon-size);
220
+ flex: 0 0 var(--form-select-icon-size);
221
+ align-items: center;
222
+ justify-content: center;
223
+ }
224
+ .option-icon > svg {
225
+ width: 100%;
226
+ height: 100%;
227
+ }
228
+ .selected-icon {
229
+ margin-left: 0.5rem;
230
+ }
231
+ .option-label {
232
+ min-width: 0;
233
+ overflow: hidden;
234
+ text-overflow: ellipsis;
235
+ white-space: nowrap;
236
+ }
237
+ li.no-options {
238
+ padding: 0.5rem;
239
+ color: var(--ui-text-muted, #64748b);
240
+ cursor: default;
241
+ font-style: italic;
242
+ }
243
+ @media (prefers-reduced-motion: reduce) {
244
+ .chevron {
245
+ transition: none;
246
+ }
247
+ }
248
+ @media (forced-colors: active) {
249
+ button.trigger:focus-visible,
250
+ .search-trigger:not(.disabled):focus-within {
251
+ outline: 2px solid CanvasText;
252
+ outline-offset: 2px;
253
+ box-shadow: none;
254
+ }
255
+ button.trigger:disabled,
256
+ .search-trigger.disabled {
257
+ color: GrayText;
258
+ opacity: 1;
259
+ }
260
+ li.active,
261
+ li:hover {
262
+ color: HighlightText;
263
+ background: Highlight;
264
+ }
265
+ li[aria-selected="true"] {
266
+ color: Highlight;
267
+ }
268
+ li.active[aria-selected="true"],
269
+ li[aria-selected="true"]:hover {
270
+ color: HighlightText;
271
+ }
272
+ }
119
273
  `,
120
274
  ]; }
275
+ #listboxPointerDown;
276
+ #suppressNextBlur;
277
+ #restoringSearchFocus;
278
+ #searchSelection;
279
+ #isComposing;
280
+ #compositionJustEnded;
281
+ #compositionEndTimer;
282
+ #blurTimer;
283
+ #listboxId;
121
284
  disconnectedCallback() {
122
285
  super.disconnectedCallback();
123
286
  window.removeEventListener("mousedown", this.#onWindowMousedown, true);
287
+ if (this.#blurTimer)
288
+ clearTimeout(this.#blurTimer);
289
+ if (this.#compositionEndTimer)
290
+ clearTimeout(this.#compositionEndTimer);
291
+ this.#listboxPointerDown = false;
292
+ this.#suppressNextBlur = false;
293
+ this.#searchSelection = null;
294
+ this.#isComposing = false;
295
+ this.#compositionJustEnded = false;
296
+ this.#close();
124
297
  }
125
298
  updated(changed) {
126
- if (!changed.has("_open"))
299
+ if (changed.has("_open")) {
300
+ this.toggleAttribute("open", this._open);
301
+ if (this._open) {
302
+ window.addEventListener("mousedown", this.#onWindowMousedown, true);
303
+ }
304
+ else {
305
+ window.removeEventListener("mousedown", this.#onWindowMousedown, true);
306
+ }
307
+ }
308
+ if ((changed.has("disabled") && this.disabled) || changed.has("searchable")) {
309
+ this.#close();
127
310
  return;
128
- this.toggleAttribute("open", this._open);
129
- if (this._open) {
130
- window.addEventListener("mousedown", this.#onWindowMousedown, true);
131
311
  }
132
- else {
133
- window.removeEventListener("mousedown", this.#onWindowMousedown, true);
312
+ if (changed.has("options") && this._open) {
313
+ this._activeIndex = this.#initialActiveIndex();
314
+ }
315
+ else if (changed.has("value") && this._open && this._query === null) {
316
+ this._activeIndex = this.#initialActiveIndex();
317
+ }
318
+ if ((changed.has("_activeIndex") || changed.has("_query") || changed.has("options")) &&
319
+ this._open &&
320
+ this._activeIndex >= 0) {
321
+ this.renderRoot
322
+ .querySelector(`#${this.#listboxId}-option-${this._activeIndex}`)
323
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
134
324
  }
135
325
  }
136
326
  #onWindowMousedown;
137
327
  #toggle() {
138
328
  if (this.disabled)
139
329
  return;
140
- this._open = !this._open;
141
- if (this._open) {
142
- this._activeIndex = Math.max(0, this.options.findIndex((o) => o.value === this.value));
143
- }
330
+ if (this._open)
331
+ this.#close();
332
+ else
333
+ this.#open();
144
334
  }
145
- #select(option) {
335
+ #open() {
336
+ if (this.disabled)
337
+ return;
338
+ this._query = null;
339
+ this._open = true;
340
+ this._activeIndex = this.#initialActiveIndex();
341
+ }
342
+ #close() {
146
343
  this._open = false;
344
+ this._query = null;
345
+ this._activeIndex = -1;
346
+ this.#searchSelection = null;
347
+ }
348
+ #initialActiveIndex() {
349
+ const options = this.#visibleOptions();
350
+ if (options.length === 0)
351
+ return -1;
352
+ if (this._query !== null)
353
+ return 0;
354
+ const selectedIndex = options.findIndex((option) => option.value === this.value);
355
+ return selectedIndex >= 0 ? selectedIndex : 0;
356
+ }
357
+ #visibleOptions() {
358
+ if (!this.searchable || this._query === null)
359
+ return this.options;
360
+ const needle = this._query.trim().toLocaleLowerCase();
361
+ if (!needle)
362
+ return this.options;
363
+ return this.options.filter((option) => option.label.toLocaleLowerCase().includes(needle));
364
+ }
365
+ #select(option) {
366
+ this.#close();
147
367
  if (option.value === this.value)
148
368
  return;
149
369
  this.value = option.value;
@@ -165,57 +385,283 @@ let FormSelect = class FormSelect extends LitElement {
165
385
  }
166
386
  else if (e.key === "Escape" && this._open) {
167
387
  e.preventDefault();
168
- this._open = false;
388
+ this.#close();
169
389
  }
170
390
  }
171
391
  #moveActive(delta) {
172
- if (this.options.length === 0)
392
+ const options = this.#visibleOptions();
393
+ if (options.length === 0)
173
394
  return;
174
- const n = this.options.length;
395
+ const n = options.length;
175
396
  this._activeIndex = (this._activeIndex + delta + n) % n;
176
397
  }
177
398
  #confirmActive() {
178
- const option = this.options[this._activeIndex];
399
+ const options = this.#visibleOptions();
400
+ const option = options[this._activeIndex] ?? options[0];
179
401
  if (option)
180
402
  this.#select(option);
181
403
  }
404
+ #onSearchFocus(e) {
405
+ if (this.disabled)
406
+ return;
407
+ if (this.#blurTimer) {
408
+ clearTimeout(this.#blurTimer);
409
+ this.#blurTimer = null;
410
+ }
411
+ if (!this._open)
412
+ this.#open();
413
+ if (!this.#restoringSearchFocus)
414
+ e.currentTarget.select();
415
+ }
416
+ #onSearchClick(e) {
417
+ if (!this._open)
418
+ this.#open();
419
+ if (this._query === null)
420
+ e.currentTarget.select();
421
+ }
422
+ #onSearchMousedown(e) {
423
+ if (e.button !== 0)
424
+ e.preventDefault();
425
+ }
426
+ #onSearchInput(e) {
427
+ this._query = e.currentTarget.value;
428
+ this._open = true;
429
+ const options = this.#visibleOptions();
430
+ this._activeIndex = options.length > 0 ? 0 : -1;
431
+ }
432
+ #onSearchKeydown(e) {
433
+ if (e.isComposing || e.keyCode === 229 || this.#isComposing || this.#compositionJustEnded) {
434
+ return;
435
+ }
436
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
437
+ e.preventDefault();
438
+ if (!this._open) {
439
+ this.#open();
440
+ return;
441
+ }
442
+ this.#moveActive(e.key === "ArrowDown" ? 1 : -1);
443
+ }
444
+ else if (e.key === "Enter" && this._open) {
445
+ e.preventDefault();
446
+ this.#confirmActive();
447
+ }
448
+ else if (e.key === "Escape" && this._open) {
449
+ e.preventDefault();
450
+ this.#close();
451
+ }
452
+ }
453
+ #onSearchBlur() {
454
+ if (this.#blurTimer)
455
+ clearTimeout(this.#blurTimer);
456
+ this.#blurTimer = setTimeout(() => {
457
+ this.#blurTimer = null;
458
+ if (!this._open)
459
+ return;
460
+ if (!this.#listboxPointerDown && !this.#suppressNextBlur)
461
+ this.#close();
462
+ });
463
+ }
464
+ #focusSearchInput(e) {
465
+ if (e.button !== 0 ||
466
+ this.disabled ||
467
+ e.composedPath()[0] instanceof HTMLInputElement) {
468
+ return;
469
+ }
470
+ e.preventDefault();
471
+ const input = this.renderRoot.querySelector("input.search-input");
472
+ if (!this._open)
473
+ this.#open();
474
+ input?.focus();
475
+ input?.select();
476
+ }
477
+ #onCompositionStart() {
478
+ if (this.#compositionEndTimer)
479
+ clearTimeout(this.#compositionEndTimer);
480
+ this.#compositionEndTimer = null;
481
+ this.#compositionJustEnded = false;
482
+ this.#isComposing = true;
483
+ }
484
+ #onCompositionEnd() {
485
+ this.#isComposing = false;
486
+ this.#compositionJustEnded = true;
487
+ if (this.#compositionEndTimer)
488
+ clearTimeout(this.#compositionEndTimer);
489
+ this.#compositionEndTimer = setTimeout(() => {
490
+ this.#compositionJustEnded = false;
491
+ this.#compositionEndTimer = null;
492
+ });
493
+ }
494
+ #onListboxMousedown(e) {
495
+ if (e.button !== 0)
496
+ return;
497
+ this.#listboxPointerDown = true;
498
+ this.#suppressNextBlur = true;
499
+ const input = this.renderRoot.querySelector("input.search-input");
500
+ this.#searchSelection = input
501
+ ? [input.selectionStart, input.selectionEnd]
502
+ : null;
503
+ window.addEventListener("mouseup", () => {
504
+ this.#listboxPointerDown = false;
505
+ if (this._open) {
506
+ const searchInput = this.renderRoot.querySelector("input.search-input");
507
+ if (searchInput) {
508
+ this.#restoringSearchFocus = true;
509
+ searchInput.focus({ preventScroll: true });
510
+ this.#restoringSearchFocus = false;
511
+ const [start, end] = this.#searchSelection ?? [null, null];
512
+ if (start !== null && end !== null)
513
+ searchInput.setSelectionRange(start, end);
514
+ }
515
+ }
516
+ this.#searchSelection = null;
517
+ setTimeout(() => {
518
+ this.#suppressNextBlur = false;
519
+ });
520
+ }, { capture: true, once: true });
521
+ }
522
+ #onOptionMousedown(e, option) {
523
+ if (e.button !== 0)
524
+ return;
525
+ const optionElement = e.currentTarget;
526
+ const listbox = optionElement.parentElement;
527
+ if (listbox) {
528
+ const scrollbarWidth = listbox.offsetWidth - listbox.clientWidth;
529
+ const bounds = listbox.getBoundingClientRect();
530
+ const isRtl = getComputedStyle(listbox).direction === "rtl";
531
+ const inScrollbarGutter = isRtl
532
+ ? e.clientX <= bounds.left + scrollbarWidth
533
+ : e.clientX >= bounds.right - scrollbarWidth;
534
+ if (scrollbarWidth > 0 && inScrollbarGutter)
535
+ return;
536
+ }
537
+ e.preventDefault();
538
+ this.#select(option);
539
+ }
540
+ #optionIconSize(option) {
541
+ const size = option.iconSize ?? 16;
542
+ return Number.isFinite(size) && size > 0 ? size : 16;
543
+ }
544
+ #renderOptionIcon(option, className = "option-icon") {
545
+ if (!option.icon)
546
+ return nothing;
547
+ return html `
548
+ <span
549
+ class=${className}
550
+ style=${`--form-select-icon-size: ${this.#optionIconSize(option)}px`}
551
+ aria-hidden="true"
552
+ >
553
+ ${option.icon}
554
+ </span>
555
+ `;
556
+ }
557
+ #renderOptionContent(option) {
558
+ return html `
559
+ <span class="option-content">
560
+ ${this.#renderOptionIcon(option)}
561
+ <span class="option-label">${option.label}</span>
562
+ </span>
563
+ `;
564
+ }
182
565
  renderListbox() {
183
566
  if (!this._open)
184
567
  return nothing;
568
+ const options = this.#visibleOptions();
185
569
  return html `
186
- <ul class="options" role="listbox">
187
- ${this.options.map((o, i) => html `
570
+ <ul
571
+ id=${this.#listboxId}
572
+ class="options"
573
+ role="listbox"
574
+ aria-label=${this.label ? `${this.label} options` : "Options"}
575
+ @mousedown=${(e) => this.#onListboxMousedown(e)}
576
+ >
577
+ ${options.map((o, i) => html `
188
578
  <li
579
+ id=${`${this.#listboxId}-option-${i}`}
189
580
  role="option"
190
581
  aria-selected=${o.value === this.value}
191
582
  class=${i === this._activeIndex ? "active" : ""}
192
- @mousedown=${(e) => {
193
- e.preventDefault();
194
- this.#select(o);
195
- }}
583
+ @mousedown=${(e) => this.#onOptionMousedown(e, o)}
196
584
  >
197
- ${o.label}
585
+ ${this.#renderOptionContent(o)}
198
586
  </li>
199
587
  `)}
588
+ ${options.length === 0
589
+ ? html `<li class="no-options" role="presentation"><span role="status">No options found</span></li>`
590
+ : nothing}
200
591
  </ul>
201
592
  `;
202
593
  }
203
- render() {
204
- const current = this.options.find((o) => o.value === this.value);
594
+ renderSearchTrigger(current) {
595
+ const displayValue = this._query ?? current?.label ?? this.value;
596
+ const showSelectedIcon = this._query === null && Boolean(current?.icon);
597
+ const activeDescendant = this._open && this._activeIndex >= 0
598
+ ? `${this.#listboxId}-option-${this._activeIndex}`
599
+ : nothing;
600
+ return html `
601
+ <div
602
+ class="search-trigger ${this.disabled ? "disabled" : ""} ${showSelectedIcon
603
+ ? "has-icon"
604
+ : ""}"
605
+ @mousedown=${(e) => this.#focusSearchInput(e)}
606
+ >
607
+ ${showSelectedIcon && current
608
+ ? this.#renderOptionIcon(current, "option-icon selected-icon")
609
+ : nothing}
610
+ <input
611
+ type="text"
612
+ class="search-input"
613
+ role="combobox"
614
+ aria-autocomplete="list"
615
+ aria-expanded=${this._open}
616
+ aria-controls=${this.#listboxId}
617
+ aria-activedescendant=${activeDescendant}
618
+ aria-label=${this.label || "Select an option"}
619
+ autocomplete="off"
620
+ ?disabled=${this.disabled}
621
+ .value=${displayValue}
622
+ @mousedown=${(e) => this.#onSearchMousedown(e)}
623
+ @focus=${(e) => this.#onSearchFocus(e)}
624
+ @click=${(e) => this.#onSearchClick(e)}
625
+ @input=${(e) => this.#onSearchInput(e)}
626
+ @compositionstart=${() => this.#onCompositionStart()}
627
+ @compositionend=${() => this.#onCompositionEnd()}
628
+ @keydown=${(e) => this.#onSearchKeydown(e)}
629
+ @blur=${() => this.#onSearchBlur()}
630
+ />
631
+ <span class="chevron">${iconChevronRight(14)}</span>
632
+ </div>
633
+ `;
634
+ }
635
+ renderButtonTrigger(current) {
636
+ const activeDescendant = this._open && this._activeIndex >= 0
637
+ ? `${this.#listboxId}-option-${this._activeIndex}`
638
+ : nothing;
205
639
  return html `
206
640
  <button
207
641
  type="button"
208
642
  class="trigger"
209
643
  aria-haspopup="listbox"
210
644
  aria-expanded=${this._open}
645
+ aria-controls=${this.#listboxId}
646
+ aria-activedescendant=${activeDescendant}
211
647
  aria-label=${this.label || nothing}
212
648
  ?disabled=${this.disabled}
213
649
  @click=${() => this.#toggle()}
214
650
  @keydown=${(e) => this.#onTriggerKeydown(e)}
215
651
  >
216
- <span>${current?.label ?? this.value}</span>
652
+ ${current
653
+ ? this.#renderOptionContent(current)
654
+ : html `<span class="option-label">${this.value}</span>`}
217
655
  <span class="chevron">${iconChevronRight(14)}</span>
218
656
  </button>
657
+ `;
658
+ }
659
+ render() {
660
+ const current = this.options.find((o) => o.value === this.value);
661
+ return html `
662
+ ${this.searchable
663
+ ? this.renderSearchTrigger(current)
664
+ : this.renderButtonTrigger(current)}
219
665
  ${this.renderListbox()}
220
666
  `;
221
667
  }
@@ -232,12 +678,18 @@ __decorate([
232
678
  __decorate([
233
679
  property({ type: Boolean })
234
680
  ], FormSelect.prototype, "disabled", void 0);
681
+ __decorate([
682
+ property({ type: Boolean, reflect: true })
683
+ ], FormSelect.prototype, "searchable", void 0);
235
684
  __decorate([
236
685
  state()
237
686
  ], FormSelect.prototype, "_open", void 0);
238
687
  __decorate([
239
688
  state()
240
689
  ], FormSelect.prototype, "_activeIndex", void 0);
690
+ __decorate([
691
+ state()
692
+ ], FormSelect.prototype, "_query", void 0);
241
693
  FormSelect = __decorate([
242
694
  customElement("form-select")
243
695
  ], FormSelect);