@starwind-ui/core 1.6.2 → 1.7.1

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 (101) hide show
  1. package/dist/index.js +8 -4
  2. package/dist/index.js.map +1 -1
  3. package/dist/src/components/accordion/Accordion.astro +224 -224
  4. package/dist/src/components/accordion/AccordionContent.astro +13 -13
  5. package/dist/src/components/accordion/AccordionItem.astro +6 -6
  6. package/dist/src/components/accordion/AccordionTrigger.astro +13 -13
  7. package/dist/src/components/accordion/index.ts +4 -4
  8. package/dist/src/components/alert/Alert.astro +15 -15
  9. package/dist/src/components/alert/AlertDescription.astro +1 -1
  10. package/dist/src/components/alert/AlertTitle.astro +2 -2
  11. package/dist/src/components/alert/index.ts +3 -3
  12. package/dist/src/components/avatar/Avatar.astro +16 -16
  13. package/dist/src/components/avatar/AvatarFallback.astro +3 -3
  14. package/dist/src/components/avatar/AvatarImage.astro +12 -12
  15. package/dist/src/components/avatar/index.ts +4 -4
  16. package/dist/src/components/badge/Badge.astro +33 -33
  17. package/dist/src/components/breadcrumb/Breadcrumb.astro +1 -1
  18. package/dist/src/components/breadcrumb/BreadcrumbEllipsis.astro +6 -6
  19. package/dist/src/components/breadcrumb/BreadcrumbItem.astro +1 -1
  20. package/dist/src/components/breadcrumb/BreadcrumbLink.astro +8 -8
  21. package/dist/src/components/breadcrumb/BreadcrumbList.astro +2 -2
  22. package/dist/src/components/breadcrumb/BreadcrumbPage.astro +6 -6
  23. package/dist/src/components/breadcrumb/BreadcrumbSeparator.astro +7 -7
  24. package/dist/src/components/breadcrumb/index.ts +14 -14
  25. package/dist/src/components/button/Button.astro +38 -38
  26. package/dist/src/components/card/Card.astro +1 -1
  27. package/dist/src/components/card/CardContent.astro +1 -1
  28. package/dist/src/components/card/CardDescription.astro +1 -1
  29. package/dist/src/components/card/CardFooter.astro +1 -1
  30. package/dist/src/components/card/CardHeader.astro +1 -1
  31. package/dist/src/components/card/CardTitle.astro +1 -1
  32. package/dist/src/components/card/index.ts +7 -7
  33. package/dist/src/components/checkbox/Checkbox.astro +89 -89
  34. package/dist/src/components/dialog/Dialog.astro +237 -178
  35. package/dist/src/components/dialog/DialogClose.astro +14 -14
  36. package/dist/src/components/dialog/DialogContent.astro +32 -32
  37. package/dist/src/components/dialog/DialogDescription.astro +1 -1
  38. package/dist/src/components/dialog/DialogFooter.astro +1 -1
  39. package/dist/src/components/dialog/DialogHeader.astro +1 -1
  40. package/dist/src/components/dialog/DialogTitle.astro +6 -6
  41. package/dist/src/components/dialog/DialogTrigger.astro +26 -20
  42. package/dist/src/components/dialog/index.ts +16 -16
  43. package/dist/src/components/dropdown/Dropdown.astro +359 -359
  44. package/dist/src/components/dropdown/DropdownContent.astro +63 -63
  45. package/dist/src/components/dropdown/DropdownItem.astro +31 -31
  46. package/dist/src/components/dropdown/DropdownLabel.astro +14 -14
  47. package/dist/src/components/dropdown/DropdownSeparator.astro +5 -5
  48. package/dist/src/components/dropdown/DropdownTrigger.astro +26 -26
  49. package/dist/src/components/dropdown/index.ts +12 -12
  50. package/dist/src/components/dropzone/Dropzone.astro +232 -0
  51. package/dist/src/components/dropzone/DropzoneFilesList.astro +25 -0
  52. package/dist/src/components/dropzone/DropzoneLoadingIndicator.astro +10 -0
  53. package/dist/src/components/dropzone/DropzoneUploadIndicator.astro +10 -0
  54. package/dist/src/components/dropzone/index.ts +13 -0
  55. package/dist/src/components/input/Input.astro +12 -12
  56. package/dist/src/components/label/Label.astro +8 -8
  57. package/dist/src/components/pagination/Pagination.astro +1 -1
  58. package/dist/src/components/pagination/PaginationContent.astro +3 -3
  59. package/dist/src/components/pagination/PaginationEllipsis.astro +2 -2
  60. package/dist/src/components/pagination/PaginationItem.astro +3 -3
  61. package/dist/src/components/pagination/PaginationLink.astro +27 -27
  62. package/dist/src/components/pagination/PaginationNext.astro +7 -6
  63. package/dist/src/components/pagination/PaginationPrevious.astro +7 -6
  64. package/dist/src/components/pagination/index.ts +14 -14
  65. package/dist/src/components/progress/Progress.astro +151 -0
  66. package/dist/src/components/progress/index.ts +5 -0
  67. package/dist/src/components/radio-group/RadioGroup.astro +156 -0
  68. package/dist/src/components/radio-group/RadioGroupItem.astro +125 -0
  69. package/dist/src/components/radio-group/RadioGroupTypes.ts +6 -0
  70. package/dist/src/components/radio-group/index.ts +10 -0
  71. package/dist/src/components/select/Select.astro +515 -475
  72. package/dist/src/components/select/SelectContent.astro +62 -62
  73. package/dist/src/components/select/SelectItem.astro +27 -27
  74. package/dist/src/components/select/SelectLabel.astro +1 -1
  75. package/dist/src/components/select/SelectTrigger.astro +28 -28
  76. package/dist/src/components/select/SelectTypes.ts +11 -5
  77. package/dist/src/components/select/SelectValue.astro +5 -5
  78. package/dist/src/components/select/index.ts +16 -16
  79. package/dist/src/components/skeleton/Skeleton.astro +14 -0
  80. package/dist/src/components/skeleton/index.ts +5 -0
  81. package/dist/src/components/switch/Switch.astro +150 -150
  82. package/dist/src/components/switch/SwitchTypes.ts +4 -4
  83. package/dist/src/components/table/Table.astro +5 -5
  84. package/dist/src/components/table/TableBody.astro +3 -3
  85. package/dist/src/components/table/TableCaption.astro +3 -3
  86. package/dist/src/components/table/TableCell.astro +3 -3
  87. package/dist/src/components/table/TableFoot.astro +3 -3
  88. package/dist/src/components/table/TableHead.astro +3 -3
  89. package/dist/src/components/table/TableHeader.astro +3 -3
  90. package/dist/src/components/table/TableRow.astro +3 -3
  91. package/dist/src/components/table/index.ts +8 -8
  92. package/dist/src/components/tabs/Tabs.astro +250 -250
  93. package/dist/src/components/tabs/TabsContent.astro +10 -10
  94. package/dist/src/components/tabs/TabsList.astro +2 -2
  95. package/dist/src/components/tabs/TabsTrigger.astro +15 -15
  96. package/dist/src/components/tabs/index.ts +4 -4
  97. package/dist/src/components/textarea/Textarea.astro +16 -16
  98. package/dist/src/components/tooltip/Tooltip.astro +217 -217
  99. package/dist/src/components/tooltip/TooltipContent.astro +81 -81
  100. package/dist/src/components/tooltip/index.ts +3 -3
  101. package/package.json +6 -6
@@ -2,492 +2,532 @@
2
2
  import type { HTMLAttributes } from "astro/types";
3
3
 
4
4
  type Props = HTMLAttributes<"div"> & {
5
- /**
6
- * The name of the select field for form handling
7
- */
8
- name?: string;
9
- /**
10
- * The value of the item that should be selected by default
11
- */
12
- defaultValue?: string;
13
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
- children: any;
5
+ /**
6
+ * The name for the hidden <select> element - used for standard form handling
7
+ */
8
+ name?: string;
9
+ /**
10
+ * The value of the item that should be selected by default
11
+ */
12
+ defaultValue?: string;
13
+
14
+ children: any;
15
15
  };
16
16
 
17
17
  const { class: className, name, defaultValue, ...rest } = Astro.props;
18
18
  ---
19
19
 
20
20
  <div
21
- class:list={["starwind-select", "relative", className]}
22
- data-name={name}
23
- data-value={defaultValue}
24
- {...rest}
21
+ class:list={["starwind-select", "relative", className]}
22
+ data-name={name}
23
+ data-value={defaultValue}
24
+ {...rest}
25
25
  >
26
- <slot />
26
+ <slot />
27
27
  </div>
28
28
 
29
29
  <script>
30
- import type { SelectChangeEvent } from "./SelectTypes";
31
-
32
- class SelectHandler {
33
- private select: HTMLElement;
34
- private trigger: HTMLButtonElement | null;
35
- private content: HTMLElement | null;
36
- private isOpen: boolean = false;
37
- private selectedItem: HTMLElement | null = null;
38
- private animationDuration = 150;
39
- private typeaheadTimerRef: number | null = null;
40
- private typeaheadSearch = "";
41
- private returnFocusOnClose: boolean = true;
42
-
43
- constructor(select: HTMLElement, selectIdx: number) {
44
- this.select = select;
45
- this.trigger = select.querySelector(".starwind-select-trigger");
46
- this.content = select.querySelector(".starwind-select-content");
47
-
48
- if (!this.trigger || !this.content) return;
49
-
50
- // animationDuration is set with inline styles through passed prop to SelectContent
51
- const animationDurationString = this.content.style.animationDuration;
52
- if (animationDurationString.endsWith("ms")) {
53
- this.animationDuration = parseFloat(animationDurationString);
54
- } else if (animationDurationString.endsWith("s")) {
55
- // using something like @playform/compress might optimize to use "s" instead of "ms"
56
- this.animationDuration = parseFloat(animationDurationString) * 1000;
57
- }
58
-
59
- this.init(selectIdx);
60
- }
61
-
62
- private init(selectIdx: number) {
63
- this.setupAccessibility(selectIdx);
64
- this.setupEvents();
65
- this.setupSelectField();
66
- this.setInitialState();
67
- }
68
-
69
- private setupSelectField() {
70
- if (!this.trigger || !this.content) return;
71
- // build the standard select field
72
- const selectField = document.createElement("select");
73
- selectField.tabIndex = -1;
74
- selectField.setAttribute("aria-hidden", "true");
75
- selectField.setAttribute("placeholder", "select");
76
- selectField.name = this.select.getAttribute("data-name") || "";
77
-
78
- // you can comment out this "sr-only" class line below if you want to see the native select in action
79
- selectField.classList.add("starwind-sr-only");
80
-
81
- // The first option is a placeholder
82
- const placeholderOption = document.createElement("option");
83
- placeholderOption.value = "";
84
- placeholderOption.textContent = "Select";
85
- placeholderOption.disabled = true;
86
- placeholderOption.selected = true;
87
- selectField.appendChild(placeholderOption);
88
-
89
- // add all options to the select field
90
- this.content.querySelectorAll('[role="option"]').forEach((option) => {
91
- const optionValue = option.getAttribute("data-value");
92
- const optionText = option.textContent;
93
- const optionElement = document.createElement("option");
94
- optionElement.value = optionValue || "";
95
- optionElement.textContent = optionText || "";
96
- selectField.appendChild(optionElement);
97
- });
98
- this.trigger.appendChild(selectField);
99
-
100
- // add this select field right after the trigger
101
- this.trigger.parentElement?.insertBefore(selectField, this.trigger.nextSibling);
102
-
103
- this.setSize();
104
- this.content.style.width = "var(--starwind-select-trigger-width)";
105
- }
106
-
107
- private setupAccessibility(selectIdx: number) {
108
- if (!this.trigger || !this.content) return;
109
-
110
- // Generate unique IDs for accessibility
111
- this.trigger.id = `starwind-select${selectIdx}-trigger`;
112
- this.content.id = `starwind-select${selectIdx}-content`;
113
-
114
- // Set up additional ARIA attributes
115
- this.trigger.setAttribute("aria-controls", this.content.id);
116
- this.content.setAttribute("aria-labelledby", this.trigger.id);
117
- }
118
-
119
- private setupEvents() {
120
- if (!this.trigger || !this.content) return;
121
-
122
- // Handle pointerdown
123
- this.trigger.addEventListener("pointerdown", (e) => {
124
- // prevent implicit pointer capture
125
- // https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture
126
- const target = e.target as HTMLElement;
127
- if (target.hasPointerCapture(e.pointerId)) {
128
- target.releasePointerCapture(e.pointerId);
129
- }
130
-
131
- // prevent trigger from stealing focus from the active item after opening.
132
- e.preventDefault();
133
-
134
- // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
135
- // but not when the control key is pressed (avoiding MacOS right click); also not for touch
136
- // devices because that would open the menu on scroll. (pen devices behave as touch on iOS).
137
- if (e.button === 0 && e.ctrlKey === false && e.pointerType === "mouse") {
138
- this.returnFocusOnClose = true;
139
- this.toggleSelect();
140
- }
141
- });
142
-
143
- // Handle click event for mobile devices
144
- this.trigger.addEventListener("click", (e) => {
145
- if (window.matchMedia("(pointer: coarse)").matches) {
146
- e.preventDefault();
147
- this.returnFocusOnClose = true;
148
- this.toggleSelect();
149
- }
150
- });
151
-
152
- // add enter or space key to select trigger to toggle select
153
- this.trigger.addEventListener("keydown", (e) => {
154
- if (e.key === "Enter" || e.key === " ") {
155
- e.preventDefault();
156
- this.returnFocusOnClose = true;
157
- this.toggleSelect();
158
- }
159
- });
160
-
161
- // Handle keyboard navigation inside select content
162
- this.content.addEventListener("keydown", (e) => {
163
- if (e.key === "Enter" || e.key === " ") {
164
- // set element based on current focused element
165
- const activeElement = document.activeElement;
166
- this.returnFocusOnClose = true;
167
- this.handleSelection(activeElement as HTMLElement);
168
- } else if (e.key === "Escape" && this.isOpen) {
169
- this.returnFocusOnClose = true;
170
- this.closeSelect();
171
- }
172
-
173
- // add key navigation for accessibility
174
- // "Home" goes to first element
175
- // "End" goes to last element
176
- // "ArrowUp" goes to previous element
177
- // "ArrowDown" goes to next element
178
- else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
179
- this.handleNavigationKeys(e);
180
- e.preventDefault();
181
- } else {
182
- const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
183
-
184
- // select should not be navigated using tab key so we prevent it
185
- if (e.key === "Tab") e.preventDefault();
186
-
187
- if (!isModifierKey && e.key.length === 1) {
188
- this.handleTypeahead(e.key);
189
- }
190
- }
191
- });
192
-
193
- // Handle hover on select items
194
- this.content.addEventListener("mouseover", (e) => {
195
- const target = e.target as HTMLElement;
196
- const option = target.closest('[role="option"]');
197
- if (option && option instanceof HTMLElement && this.isOpen === true) {
198
- option.focus();
199
- }
200
- });
201
-
202
- // handle pointerdown outside select content to close
203
- document.addEventListener("pointerdown", (e) => {
204
- // only close if not a mouse pointer
205
- if (!window.matchMedia("(pointer: coarse)").matches) {
206
- if (
207
- !(
208
- this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)
209
- ) &&
210
- this.isOpen
211
- ) {
212
- this.returnFocusOnClose = false;
213
- this.closeSelect();
214
- }
215
- }
216
- });
217
-
218
- // Handle click outside select content to close
219
- document.addEventListener("click", (e) => {
220
- if (
221
- !(this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)) &&
222
- this.isOpen
223
- ) {
224
- this.returnFocusOnClose = false;
225
- this.closeSelect();
226
- }
227
- });
228
-
229
- // Handle selection of items
230
- this.content?.addEventListener("click", (e) => {
231
- const item = (e.target as HTMLElement).closest("[role='option']");
232
- if (item instanceof HTMLElement) {
233
- this.returnFocusOnClose = true;
234
- this.handleSelection(item);
235
- }
236
- });
237
-
238
- // passive resize listener to call setSize()
239
- window.addEventListener("resize", () => this.setSize(), { passive: true });
240
- }
241
-
242
- private handleNavigationKeys(e: KeyboardEvent) {
243
- if (!this.content) return;
244
- const items = this.content.querySelectorAll('[role="option"]');
245
-
246
- // current, or first item, is focused upon opening the select
247
- const activeElement = document.activeElement;
248
- const currentIndex = Array.from(items).indexOf(activeElement as HTMLElement);
249
- if (e.key === "Home") {
250
- const firstEnabledItem = Array.from(items).find(
251
- (item) => item.getAttribute("data-disabled") !== "true",
252
- ) as HTMLElement;
253
- if (firstEnabledItem) {
254
- firstEnabledItem.focus();
255
- }
256
- return;
257
- }
258
- if (e.key === "End") {
259
- const lastEnabledItem = Array.from(items)
260
- .reverse()
261
- .find((item) => item.getAttribute("data-disabled") !== "true") as HTMLElement;
262
- if (lastEnabledItem) {
263
- lastEnabledItem.focus();
264
- }
265
- return;
266
- }
267
- if (e.key === "ArrowUp" && currentIndex > 0) {
268
- for (let i = currentIndex - 1; i >= 0; i--) {
269
- const item = items[i] as HTMLElement;
270
- if (item.getAttribute("data-disabled") !== "true") {
271
- item.focus();
272
- break;
273
- }
274
- }
275
- return;
276
- }
277
- if (e.key === "ArrowDown" && currentIndex < items.length - 1) {
278
- for (let i = currentIndex + 1; i < items.length; i++) {
279
- const item = items[i] as HTMLElement;
280
- if (item.getAttribute("data-disabled") !== "true") {
281
- item.focus();
282
- break;
283
- }
284
- }
285
- return;
286
- }
287
- }
288
-
289
- private handleTypeahead(key: string) {
290
- if (!this.content) return;
291
- const search = this.typeaheadSearch + key;
292
- const items = this.content.querySelectorAll('[role="option"]');
293
-
294
- // find and focus the first matching option
295
- const matches = Array.from(items).filter((item) =>
296
- item.textContent?.toLowerCase().trim().startsWith(search.toLowerCase()),
297
- ) as HTMLElement[];
298
- if (matches.length > 0) {
299
- matches[0].focus();
300
- }
301
-
302
- // update the typeahead search and reset the timer
303
- this.typeaheadSearch = search;
304
- if (this.typeaheadTimerRef) {
305
- window.clearTimeout(this.typeaheadTimerRef);
306
- }
307
-
308
- // set a timer to clear the search after 1 second
309
- this.typeaheadTimerRef = window.setTimeout(() => {
310
- this.typeaheadSearch = "";
311
- this.typeaheadTimerRef = null;
312
- }, 1000);
313
- }
314
-
315
- private setSize() {
316
- if (!this.trigger || !this.content) return;
317
- this.content.style.setProperty(
318
- "--starwind-select-content-width",
319
- `${this.content.offsetWidth}px`,
320
- );
321
-
322
- this.content.style.setProperty(
323
- "--starwind-select-trigger-width",
324
- `${this.trigger.offsetWidth}px`,
325
- );
326
- }
327
-
328
- private toggleSelect() {
329
- if (this.isOpen) {
330
- this.closeSelect();
331
- } else {
332
- this.openSelect();
333
- }
334
- }
335
-
336
- private openSelect() {
337
- if (!this.content || !this.trigger || this.trigger.disabled) return;
338
-
339
- this.isOpen = true;
340
- this.content.setAttribute("data-state", "open");
341
- this.trigger.setAttribute("aria-expanded", "true");
342
- this.content.style.removeProperty("display");
343
-
344
- // set focus on the current selected item
345
- if (this.selectedItem) {
346
- this.selectedItem.focus();
347
- } else {
348
- // if no item is selected, focus on the first item
349
- const firstItem = this.content.querySelector('[role="option"]') as HTMLElement;
350
- if (firstItem) {
351
- firstItem.focus();
352
- }
353
- }
354
-
355
- this.positionContent();
356
- }
357
-
358
- private closeSelect() {
359
- if (!this.content || !this.trigger) return;
360
-
361
- this.isOpen = false;
362
- this.content.setAttribute("data-state", "closed");
363
-
364
- // Remove focus from any currently focused element
365
- const activeElement = document.activeElement;
366
- if (activeElement instanceof HTMLElement) {
367
- activeElement.blur();
368
- }
369
-
370
- // Set focus on trigger if returnFocusOnClose is true
371
- if (this.returnFocusOnClose) {
372
- requestAnimationFrame(() => {
373
- if (!this.trigger) return;
374
- this.trigger.focus();
375
- });
376
- }
377
-
378
- // give the content time to animate before hiding
379
- setTimeout(() => {
380
- if (!this.content) return;
381
- this.content.style.display = "none";
382
- }, this.animationDuration);
383
-
384
- this.trigger.setAttribute("aria-expanded", "false");
385
- }
386
-
387
- private handleSelection(item: HTMLElement) {
388
- if (!this.trigger) return;
389
-
390
- // update the hidden select field
391
- const selectField = this.select.querySelector("select");
392
- if (selectField) {
393
- const newValue = item.getAttribute("data-value") || "";
394
- selectField.value = newValue;
395
-
396
- // Dispatch custom event with the new value
397
- const event = new CustomEvent<SelectChangeEvent["detail"]>("starwind-select:change", {
398
- detail: { value: newValue, selectId: this.select.id, label: item.textContent || "" },
399
- bubbles: true,
400
- cancelable: true,
401
- });
402
-
403
- selectField.dispatchEvent(event);
404
- }
405
-
406
- // Update trigger content
407
- const triggerSpan = this.trigger.firstElementChild as HTMLSpanElement;
408
- if (triggerSpan) {
409
- triggerSpan.textContent = item.textContent;
410
- }
411
-
412
- // Update selected states after select finishes closing
413
- setTimeout(() => {
414
- if (this.selectedItem) {
415
- this.selectedItem.setAttribute("aria-selected", "false");
416
- }
417
- item.setAttribute("aria-selected", "true");
418
- this.selectedItem = item;
419
- }, this.animationDuration);
420
-
421
- // Close the select
422
- this.closeSelect();
423
- }
424
-
425
- /**
426
- * Sets the initial state based on the default value attribute
427
- */
428
- private setInitialState(): void {
429
- const defaultValue = this.select.dataset.value;
430
- if (defaultValue) {
431
- const item = this.content?.querySelector(`[data-value="${defaultValue}"]`);
432
-
433
- if (item && item instanceof HTMLElement) {
434
- this.returnFocusOnClose = false;
435
- this.handleSelection(item);
436
- this.selectedItem = item;
437
- }
438
- }
439
- }
440
-
441
- /**
442
- * TODO: add position logic to avoid collisions with window boundary
443
- * It will need to switch to top or bottom depending on space available
444
- * It will also need to set the content max height so it doesn't overflow the viewport
445
- */
446
- private positionContent() {
447
- // if (!this.content || !this.trigger) return;
448
- // const triggerRect = this.trigger.getBoundingClientRect();
449
- // const contentRect = this.content.getBoundingClientRect();
450
- // const viewportHeight = window.innerHeight;
451
- // // Position the content below the trigger by default
452
- // let top = triggerRect.bottom;
453
- // // If there's not enough space below, position it above
454
- // if (top + contentRect.height > viewportHeight) {
455
- // top = triggerRect.top - contentRect.height;
456
- // }
457
- // this.content.style.position = "absolute";
458
- // this.content.style.top = `${top}px`;
459
- // this.content.style.left = `${triggerRect.left}px`;
460
- // this.content.style.width = `${triggerRect.width}px`;
461
- // this.content.style.zIndex = "50";
462
- }
463
- }
464
-
465
- // Store instances in a WeakMap to avoid memory leaks
466
- const selectInstances = new WeakMap<HTMLElement, SelectHandler>();
467
-
468
- // Initialize selects
469
- const initSelects = () => {
470
- document.querySelectorAll(".starwind-select").forEach((select, idx) => {
471
- if (select instanceof HTMLElement && !selectInstances.has(select)) {
472
- selectInstances.set(select, new SelectHandler(select, idx));
473
- }
474
- });
475
- };
476
-
477
- initSelects();
478
- document.addEventListener("astro:after-swap", initSelects);
30
+ import type { SelectChangeEvent, SelectEvent } from "./SelectTypes";
31
+
32
+ class SelectHandler {
33
+ private select: HTMLElement;
34
+ private trigger: HTMLButtonElement | null;
35
+ private content: HTMLElement | null;
36
+ private isOpen: boolean = false;
37
+ private selectedItem: HTMLElement | null = null;
38
+ private animationDuration = 150;
39
+ private typeaheadTimerRef: number | null = null;
40
+ private typeaheadSearch = "";
41
+ private returnFocusOnClose: boolean = true;
42
+
43
+ constructor(select: HTMLElement, selectIdx: number) {
44
+ this.select = select;
45
+ this.trigger = select.querySelector(".starwind-select-trigger");
46
+ this.content = select.querySelector(".starwind-select-content");
47
+
48
+ if (!this.trigger || !this.content) return;
49
+
50
+ // animationDuration is set with inline styles through passed prop to SelectContent
51
+ const animationDurationString = this.content.style.animationDuration;
52
+ if (animationDurationString.endsWith("ms")) {
53
+ this.animationDuration = parseFloat(animationDurationString);
54
+ } else if (animationDurationString.endsWith("s")) {
55
+ // using something like @playform/compress might optimize to use "s" instead of "ms"
56
+ this.animationDuration = parseFloat(animationDurationString) * 1000;
57
+ }
58
+
59
+ this.init(selectIdx);
60
+ }
61
+
62
+ private init(selectIdx: number) {
63
+ this.setupAccessibility(selectIdx);
64
+ this.setupEvents();
65
+ this.setupSelectField();
66
+ this.setInitialState();
67
+ }
68
+
69
+ private setupSelectField() {
70
+ if (!this.trigger || !this.content) return;
71
+ // build the standard select field
72
+ const selectField = document.createElement("select");
73
+ selectField.tabIndex = -1;
74
+ selectField.setAttribute("aria-hidden", "true");
75
+ selectField.setAttribute("placeholder", "select");
76
+ const selectName = this.select.getAttribute("data-name");
77
+ if (selectName) {
78
+ selectField.name = selectName;
79
+ }
80
+
81
+ // you can comment out this "sr-only" class line below if you want to see the native select in action
82
+ selectField.classList.add("starwind-sr-only");
83
+
84
+ // The first option is a placeholder
85
+ const placeholderOption = document.createElement("option");
86
+ placeholderOption.value = "";
87
+ placeholderOption.textContent = "Select";
88
+ placeholderOption.disabled = true;
89
+ placeholderOption.selected = true;
90
+ selectField.appendChild(placeholderOption);
91
+
92
+ // add all options to the select field
93
+ this.content.querySelectorAll('[role="option"]').forEach((option) => {
94
+ const optionValue = option.getAttribute("data-value");
95
+ const optionText = option.textContent;
96
+ const optionElement = document.createElement("option");
97
+ optionElement.value = optionValue || "";
98
+ optionElement.textContent = optionText || "";
99
+ selectField.appendChild(optionElement);
100
+ });
101
+ this.trigger.appendChild(selectField);
102
+
103
+ // add this select field right after the trigger
104
+ this.trigger.parentElement?.insertBefore(selectField, this.trigger.nextSibling);
105
+
106
+ this.setSize();
107
+ this.content.style.width = "var(--starwind-select-trigger-width)";
108
+ }
109
+
110
+ private setupAccessibility(selectIdx: number) {
111
+ if (!this.trigger || !this.content) return;
112
+
113
+ // Generate unique IDs for accessibility
114
+ this.trigger.id = `starwind-select${selectIdx}-trigger`;
115
+ this.content.id = `starwind-select${selectIdx}-content`;
116
+
117
+ // Set up additional ARIA attributes
118
+ this.trigger.setAttribute("aria-controls", this.content.id);
119
+ this.content.setAttribute("aria-labelledby", this.trigger.id);
120
+ }
121
+
122
+ private setupEvents() {
123
+ if (!this.trigger || !this.content) return;
124
+
125
+ // Handle pointerdown
126
+ this.trigger.addEventListener("pointerdown", (e) => {
127
+ // prevent implicit pointer capture
128
+ // https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture
129
+ const target = e.target as HTMLElement;
130
+ if (target.hasPointerCapture(e.pointerId)) {
131
+ target.releasePointerCapture(e.pointerId);
132
+ }
133
+
134
+ // prevent trigger from stealing focus from the active item after opening.
135
+ e.preventDefault();
136
+
137
+ // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
138
+ // but not when the control key is pressed (avoiding MacOS right click); also not for touch
139
+ // devices because that would open the menu on scroll. (pen devices behave as touch on iOS).
140
+ if (e.button === 0 && e.ctrlKey === false && e.pointerType === "mouse") {
141
+ this.returnFocusOnClose = true;
142
+ this.toggleSelect();
143
+ }
144
+ });
145
+
146
+ // Handle click event for mobile devices
147
+ this.trigger.addEventListener("click", (e) => {
148
+ if (window.matchMedia("(pointer: coarse)").matches) {
149
+ e.preventDefault();
150
+ this.returnFocusOnClose = true;
151
+ this.toggleSelect();
152
+ }
153
+ });
154
+
155
+ // add enter or space key to select trigger to toggle select
156
+ this.trigger.addEventListener("keydown", (e) => {
157
+ if (e.key === "Enter" || e.key === " ") {
158
+ e.preventDefault();
159
+ this.returnFocusOnClose = true;
160
+ this.toggleSelect();
161
+ }
162
+ });
163
+
164
+ // Handle keyboard navigation inside select content
165
+ this.content.addEventListener("keydown", (e) => {
166
+ if (e.key === "Enter" || e.key === " ") {
167
+ // set element based on current focused element
168
+ const activeElement = document.activeElement;
169
+ this.returnFocusOnClose = true;
170
+ this.handleSelection(activeElement as HTMLElement);
171
+ } else if (e.key === "Escape" && this.isOpen) {
172
+ this.returnFocusOnClose = true;
173
+ this.closeSelect();
174
+ }
175
+
176
+ // add key navigation for accessibility
177
+ // "Home" goes to first element
178
+ // "End" goes to last element
179
+ // "ArrowUp" goes to previous element
180
+ // "ArrowDown" goes to next element
181
+ else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
182
+ this.handleNavigationKeys(e);
183
+ e.preventDefault();
184
+ } else {
185
+ const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
186
+
187
+ // select should not be navigated using tab key so we prevent it
188
+ if (e.key === "Tab") e.preventDefault();
189
+
190
+ if (!isModifierKey && e.key.length === 1) {
191
+ this.handleTypeahead(e.key);
192
+ }
193
+ }
194
+ });
195
+
196
+ // Handle hover on select items
197
+ this.content.addEventListener("mouseover", (e) => {
198
+ const target = e.target as HTMLElement;
199
+ const option = target.closest('[role="option"]');
200
+ if (option && option instanceof HTMLElement && this.isOpen === true) {
201
+ option.focus();
202
+ }
203
+ });
204
+
205
+ // handle pointerdown outside select content to close
206
+ document.addEventListener("pointerdown", (e) => {
207
+ // only close if not a mouse pointer
208
+ if (!window.matchMedia("(pointer: coarse)").matches) {
209
+ if (
210
+ !(
211
+ this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)
212
+ ) &&
213
+ this.isOpen
214
+ ) {
215
+ this.returnFocusOnClose = false;
216
+ this.closeSelect();
217
+ }
218
+ }
219
+ });
220
+
221
+ // Handle click outside select content to close
222
+ document.addEventListener("click", (e) => {
223
+ if (
224
+ !(this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)) &&
225
+ this.isOpen
226
+ ) {
227
+ this.returnFocusOnClose = false;
228
+ this.closeSelect();
229
+ }
230
+ });
231
+
232
+ // Handle selection of items
233
+ this.content?.addEventListener("click", (e) => {
234
+ const item = (e.target as HTMLElement).closest("[role='option']");
235
+ if (item instanceof HTMLElement) {
236
+ this.returnFocusOnClose = true;
237
+ this.handleSelection(item);
238
+ }
239
+ });
240
+
241
+ // passive resize listener to call setSize()
242
+ window.addEventListener("resize", () => this.setSize(), { passive: true });
243
+
244
+ // Listen for programmatic selection events
245
+ document.addEventListener("starwind-select:select", (e: Event) => {
246
+ const selectEvent = e as SelectEvent;
247
+ const selectId = selectEvent.detail.selectId;
248
+ const selectName = selectEvent.detail.selectName;
249
+ const selectValue = selectEvent.detail.value;
250
+
251
+ // Check if this event is for this select
252
+ if (
253
+ (selectId && this.select.id === selectId) ||
254
+ (selectName && this.select.getAttribute("data-name") === selectName)
255
+ ) {
256
+ this.programmaticallySelect(selectValue);
257
+ }
258
+ });
259
+ }
260
+
261
+ private handleNavigationKeys(e: KeyboardEvent) {
262
+ if (!this.content) return;
263
+ const items = this.content.querySelectorAll('[role="option"]');
264
+
265
+ // current, or first item, is focused upon opening the select
266
+ const activeElement = document.activeElement;
267
+ const currentIndex = Array.from(items).indexOf(activeElement as HTMLElement);
268
+ if (e.key === "Home") {
269
+ const firstEnabledItem = Array.from(items).find(
270
+ (item) => item.getAttribute("data-disabled") !== "true",
271
+ ) as HTMLElement;
272
+ if (firstEnabledItem) {
273
+ firstEnabledItem.focus();
274
+ }
275
+ return;
276
+ }
277
+ if (e.key === "End") {
278
+ const lastEnabledItem = Array.from(items)
279
+ .reverse()
280
+ .find((item) => item.getAttribute("data-disabled") !== "true") as HTMLElement;
281
+ if (lastEnabledItem) {
282
+ lastEnabledItem.focus();
283
+ }
284
+ return;
285
+ }
286
+ if (e.key === "ArrowUp" && currentIndex > 0) {
287
+ for (let i = currentIndex - 1; i >= 0; i--) {
288
+ const item = items[i] as HTMLElement;
289
+ if (item.getAttribute("data-disabled") !== "true") {
290
+ item.focus();
291
+ break;
292
+ }
293
+ }
294
+ return;
295
+ }
296
+ if (e.key === "ArrowDown" && currentIndex < items.length - 1) {
297
+ for (let i = currentIndex + 1; i < items.length; i++) {
298
+ const item = items[i] as HTMLElement;
299
+ if (item.getAttribute("data-disabled") !== "true") {
300
+ item.focus();
301
+ break;
302
+ }
303
+ }
304
+ return;
305
+ }
306
+ }
307
+
308
+ private handleTypeahead(key: string) {
309
+ if (!this.content) return;
310
+ const search = this.typeaheadSearch + key;
311
+ const items = this.content.querySelectorAll('[role="option"]');
312
+
313
+ // find and focus the first matching option
314
+ const matches = Array.from(items).filter((item) =>
315
+ item.textContent?.toLowerCase().trim().startsWith(search.toLowerCase()),
316
+ ) as HTMLElement[];
317
+ if (matches.length > 0) {
318
+ matches[0].focus();
319
+ }
320
+
321
+ // update the typeahead search and reset the timer
322
+ this.typeaheadSearch = search;
323
+ if (this.typeaheadTimerRef) {
324
+ window.clearTimeout(this.typeaheadTimerRef);
325
+ }
326
+
327
+ // set a timer to clear the search after 1 second
328
+ this.typeaheadTimerRef = window.setTimeout(() => {
329
+ this.typeaheadSearch = "";
330
+ this.typeaheadTimerRef = null;
331
+ }, 1000);
332
+ }
333
+
334
+ private setSize() {
335
+ if (!this.trigger || !this.content) return;
336
+ this.content.style.setProperty(
337
+ "--starwind-select-content-width",
338
+ `${this.content.offsetWidth}px`,
339
+ );
340
+
341
+ this.content.style.setProperty(
342
+ "--starwind-select-trigger-width",
343
+ `${this.trigger.offsetWidth}px`,
344
+ );
345
+ }
346
+
347
+ private toggleSelect() {
348
+ if (this.isOpen) {
349
+ this.closeSelect();
350
+ } else {
351
+ this.openSelect();
352
+ }
353
+ }
354
+
355
+ private openSelect() {
356
+ if (!this.content || !this.trigger || this.trigger.disabled) return;
357
+
358
+ this.isOpen = true;
359
+ this.content.setAttribute("data-state", "open");
360
+ this.trigger.setAttribute("aria-expanded", "true");
361
+ this.content.style.removeProperty("display");
362
+
363
+ // set focus on the current selected item
364
+ if (this.selectedItem) {
365
+ this.selectedItem.focus();
366
+ } else {
367
+ // if no item is selected, focus on the first item
368
+ const firstItem = this.content.querySelector('[role="option"]') as HTMLElement;
369
+ if (firstItem) {
370
+ firstItem.focus();
371
+ }
372
+ }
373
+
374
+ this.positionContent();
375
+ }
376
+
377
+ private closeSelect() {
378
+ if (!this.content || !this.trigger) return;
379
+
380
+ this.isOpen = false;
381
+ this.content.setAttribute("data-state", "closed");
382
+
383
+ // Remove focus from any currently focused element
384
+ const activeElement = document.activeElement;
385
+ if (activeElement instanceof HTMLElement) {
386
+ activeElement.blur();
387
+ }
388
+
389
+ // Set focus on trigger if returnFocusOnClose is true
390
+ if (this.returnFocusOnClose) {
391
+ requestAnimationFrame(() => {
392
+ if (!this.trigger) return;
393
+ this.trigger.focus();
394
+ });
395
+ }
396
+
397
+ // give the content time to animate before hiding
398
+ setTimeout(() => {
399
+ if (!this.content) return;
400
+ this.content.style.display = "none";
401
+ }, this.animationDuration);
402
+
403
+ this.trigger.setAttribute("aria-expanded", "false");
404
+ }
405
+
406
+ private handleSelection(item: HTMLElement) {
407
+ if (!this.trigger) return;
408
+
409
+ // update the hidden select field
410
+ const selectField = this.select.querySelector("select");
411
+ if (selectField) {
412
+ const newValue = item.getAttribute("data-value") || "";
413
+ selectField.value = newValue;
414
+
415
+ // Dispatch custom event with the new value
416
+ const event = new CustomEvent<SelectChangeEvent["detail"]>("starwind-select:change", {
417
+ detail: { value: newValue, selectId: this.select.id, label: item.textContent || "" },
418
+ bubbles: true,
419
+ cancelable: true,
420
+ });
421
+
422
+ selectField.dispatchEvent(event);
423
+ }
424
+
425
+ // Update trigger content
426
+ const triggerSpan = this.trigger.firstElementChild as HTMLSpanElement;
427
+ if (triggerSpan) {
428
+ triggerSpan.textContent = item.textContent;
429
+ }
430
+
431
+ // Update selected states after select finishes closing
432
+ setTimeout(() => {
433
+ if (this.selectedItem) {
434
+ this.selectedItem.setAttribute("aria-selected", "false");
435
+ }
436
+ item.setAttribute("aria-selected", "true");
437
+ this.selectedItem = item;
438
+ }, this.animationDuration);
439
+
440
+ // Close the select
441
+ this.closeSelect();
442
+ }
443
+
444
+ /**
445
+ * Sets the initial state based on the default value attribute
446
+ */
447
+ private setInitialState(): void {
448
+ const defaultValue = this.select.dataset.value;
449
+ if (defaultValue) {
450
+ const item = this.content?.querySelector(`[data-value="${defaultValue}"]`);
451
+
452
+ if (item && item instanceof HTMLElement) {
453
+ this.returnFocusOnClose = false;
454
+ this.handleSelection(item);
455
+ this.selectedItem = item;
456
+ }
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Programmatically selects an option by value
462
+ */
463
+ private programmaticallySelect(value: string): void {
464
+ if (!this.content) return;
465
+
466
+ const item = this.content.querySelector(`[data-value="${value}"]`);
467
+ if (item instanceof HTMLElement) {
468
+ this.returnFocusOnClose = false;
469
+
470
+ // Update aria-selected attributes immediately
471
+ if (this.selectedItem) {
472
+ this.selectedItem.setAttribute("aria-selected", "false");
473
+ }
474
+ item.setAttribute("aria-selected", "true");
475
+
476
+ // Then call handleSelection which will update the rest
477
+ this.handleSelection(item);
478
+ }
479
+ }
480
+
481
+ /**
482
+ * TODO: add position logic to avoid collisions with window boundary
483
+ * It will need to switch to top or bottom depending on space available
484
+ * It will also need to set the content max height so it doesn't overflow the viewport
485
+ */
486
+ private positionContent() {
487
+ // if (!this.content || !this.trigger) return;
488
+ // const triggerRect = this.trigger.getBoundingClientRect();
489
+ // const contentRect = this.content.getBoundingClientRect();
490
+ // const viewportHeight = window.innerHeight;
491
+ // // Position the content below the trigger by default
492
+ // let top = triggerRect.bottom;
493
+ // // If there's not enough space below, position it above
494
+ // if (top + contentRect.height > viewportHeight) {
495
+ // top = triggerRect.top - contentRect.height;
496
+ // }
497
+ // this.content.style.position = "absolute";
498
+ // this.content.style.top = `${top}px`;
499
+ // this.content.style.left = `${triggerRect.left}px`;
500
+ // this.content.style.width = `${triggerRect.width}px`;
501
+ // this.content.style.zIndex = "50";
502
+ }
503
+ }
504
+
505
+ // Store instances in a WeakMap to avoid memory leaks
506
+ const selectInstances = new WeakMap<HTMLElement, SelectHandler>();
507
+
508
+ // Initialize selects
509
+ const initSelects = () => {
510
+ document.querySelectorAll(".starwind-select").forEach((select, idx) => {
511
+ if (select instanceof HTMLElement && !selectInstances.has(select)) {
512
+ selectInstances.set(select, new SelectHandler(select, idx));
513
+ }
514
+ });
515
+ };
516
+
517
+ initSelects();
518
+ document.addEventListener("astro:after-swap", initSelects);
479
519
  </script>
480
520
 
481
521
  <style is:global>
482
- .starwind-sr-only {
483
- position: absolute;
484
- width: 1px;
485
- height: 1px;
486
- padding: 0;
487
- margin: -1px;
488
- overflow: hidden;
489
- clip: rect(0, 0, 0, 0);
490
- white-space: nowrap;
491
- border-width: 0;
492
- }
522
+ .starwind-sr-only {
523
+ position: absolute;
524
+ width: 1px;
525
+ height: 1px;
526
+ padding: 0;
527
+ margin: -1px;
528
+ overflow: hidden;
529
+ clip: rect(0, 0, 0, 0);
530
+ white-space: nowrap;
531
+ border-width: 0;
532
+ }
493
533
  </style>