@starwind-ui/core 1.15.1 → 1.15.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 (216) hide show
  1. package/dist/index.d.ts +28 -0
  2. package/dist/index.js +108 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/src/components/accordion/Accordion.astro +254 -0
  5. package/dist/src/components/accordion/AccordionContent.astro +33 -0
  6. package/dist/src/components/accordion/AccordionItem.astro +27 -0
  7. package/dist/src/components/accordion/AccordionTrigger.astro +32 -0
  8. package/dist/src/components/accordion/index.ts +15 -0
  9. package/dist/src/components/alert/Alert.astro +31 -0
  10. package/dist/src/components/alert/AlertDescription.astro +14 -0
  11. package/dist/src/components/alert/AlertTitle.astro +16 -0
  12. package/dist/src/components/alert/index.ts +13 -0
  13. package/dist/src/components/alert-dialog/AlertDialog.astro +275 -0
  14. package/dist/src/components/alert-dialog/AlertDialogAction.astro +44 -0
  15. package/dist/src/components/alert-dialog/AlertDialogCancel.astro +45 -0
  16. package/dist/src/components/alert-dialog/AlertDialogContent.astro +52 -0
  17. package/dist/src/components/alert-dialog/AlertDialogDescription.astro +18 -0
  18. package/dist/src/components/alert-dialog/AlertDialogFooter.astro +16 -0
  19. package/dist/src/components/alert-dialog/AlertDialogHeader.astro +14 -0
  20. package/dist/src/components/alert-dialog/AlertDialogTitle.astro +20 -0
  21. package/dist/src/components/alert-dialog/AlertDialogTrigger.astro +47 -0
  22. package/dist/src/components/alert-dialog/index.ts +46 -0
  23. package/dist/src/components/aspect-ratio/AspectRatio.astro +32 -0
  24. package/dist/src/components/aspect-ratio/index.ts +7 -0
  25. package/dist/src/components/avatar/Avatar.astro +29 -0
  26. package/dist/src/components/avatar/AvatarFallback.astro +18 -0
  27. package/dist/src/components/avatar/AvatarImage.astro +49 -0
  28. package/dist/src/components/avatar/index.ts +13 -0
  29. package/dist/src/components/badge/Badge.astro +55 -0
  30. package/dist/src/components/badge/index.ts +7 -0
  31. package/dist/src/components/breadcrumb/Breadcrumb.astro +11 -0
  32. package/dist/src/components/breadcrumb/BreadcrumbEllipsis.astro +28 -0
  33. package/dist/src/components/breadcrumb/BreadcrumbItem.astro +14 -0
  34. package/dist/src/components/breadcrumb/BreadcrumbLink.astro +22 -0
  35. package/dist/src/components/breadcrumb/BreadcrumbList.astro +16 -0
  36. package/dist/src/components/breadcrumb/BreadcrumbPage.astro +21 -0
  37. package/dist/src/components/breadcrumb/BreadcrumbSeparator.astro +23 -0
  38. package/dist/src/components/breadcrumb/index.ts +37 -0
  39. package/dist/src/components/button/Button.astro +55 -0
  40. package/dist/src/components/button/index.ts +7 -0
  41. package/dist/src/components/button-group/ButtonGroup.astro +62 -0
  42. package/dist/src/components/button-group/ButtonGroupSeparator.astro +27 -0
  43. package/dist/src/components/button-group/ButtonGroupText.astro +19 -0
  44. package/dist/src/components/button-group/index.ts +17 -0
  45. package/dist/src/components/card/Card.astro +14 -0
  46. package/dist/src/components/card/CardContent.astro +14 -0
  47. package/dist/src/components/card/CardDescription.astro +14 -0
  48. package/dist/src/components/card/CardFooter.astro +14 -0
  49. package/dist/src/components/card/CardHeader.astro +14 -0
  50. package/dist/src/components/card/CardTitle.astro +14 -0
  51. package/dist/src/components/card/index.ts +26 -0
  52. package/dist/src/components/carousel/Carousel.astro +55 -0
  53. package/dist/src/components/carousel/CarouselContent.astro +26 -0
  54. package/dist/src/components/carousel/CarouselItem.astro +26 -0
  55. package/dist/src/components/carousel/CarouselNext.astro +37 -0
  56. package/dist/src/components/carousel/CarouselPrevious.astro +37 -0
  57. package/dist/src/components/carousel/carousel-script.ts +191 -0
  58. package/dist/src/components/carousel/index.ts +32 -0
  59. package/dist/src/components/checkbox/Checkbox.astro +128 -0
  60. package/dist/src/components/checkbox/index.ts +7 -0
  61. package/dist/src/components/collapsible/Collapsible.astro +161 -0
  62. package/dist/src/components/collapsible/CollapsibleContent.astro +22 -0
  63. package/dist/src/components/collapsible/CollapsibleTrigger.astro +44 -0
  64. package/dist/src/components/collapsible/index.ts +13 -0
  65. package/dist/src/components/dialog/Dialog.astro +389 -0
  66. package/dist/src/components/dialog/DialogClose.astro +35 -0
  67. package/dist/src/components/dialog/DialogContent.astro +78 -0
  68. package/dist/src/components/dialog/DialogDescription.astro +14 -0
  69. package/dist/src/components/dialog/DialogFooter.astro +14 -0
  70. package/dist/src/components/dialog/DialogHeader.astro +14 -0
  71. package/dist/src/components/dialog/DialogTitle.astro +22 -0
  72. package/dist/src/components/dialog/DialogTrigger.astro +47 -0
  73. package/dist/src/components/dialog/index.ts +45 -0
  74. package/dist/src/components/dropdown/Dropdown.astro +377 -0
  75. package/dist/src/components/dropdown/DropdownContent.astro +81 -0
  76. package/dist/src/components/dropdown/DropdownItem.astro +48 -0
  77. package/dist/src/components/dropdown/DropdownLabel.astro +29 -0
  78. package/dist/src/components/dropdown/DropdownSeparator.astro +21 -0
  79. package/dist/src/components/dropdown/DropdownTrigger.astro +52 -0
  80. package/dist/src/components/dropdown/index.ts +33 -0
  81. package/dist/src/components/dropzone/Dropzone.astro +236 -0
  82. package/dist/src/components/dropzone/DropzoneFilesList.astro +26 -0
  83. package/dist/src/components/dropzone/DropzoneLoadingIndicator.astro +10 -0
  84. package/dist/src/components/dropzone/DropzoneUploadIndicator.astro +10 -0
  85. package/dist/src/components/dropzone/index.ts +24 -0
  86. package/dist/src/components/image/Image.astro +24 -0
  87. package/dist/src/components/image/index.ts +9 -0
  88. package/dist/src/components/input/Input.astro +25 -0
  89. package/dist/src/components/input/index.ts +7 -0
  90. package/dist/src/components/input-otp/InputOtp.astro +352 -0
  91. package/dist/src/components/input-otp/InputOtpGroup.astro +16 -0
  92. package/dist/src/components/input-otp/InputOtpSeparator.astro +25 -0
  93. package/dist/src/components/input-otp/InputOtpSlot.astro +48 -0
  94. package/dist/src/components/input-otp/InputOtpTypes.ts +6 -0
  95. package/dist/src/components/input-otp/index.ts +33 -0
  96. package/dist/src/components/item/Item.astro +52 -0
  97. package/dist/src/components/item/ItemActions.astro +16 -0
  98. package/dist/src/components/item/ItemContent.astro +16 -0
  99. package/dist/src/components/item/ItemDescription.astro +19 -0
  100. package/dist/src/components/item/ItemFooter.astro +16 -0
  101. package/dist/src/components/item/ItemGroup.astro +16 -0
  102. package/dist/src/components/item/ItemHeader.astro +16 -0
  103. package/dist/src/components/item/ItemMedia.astro +40 -0
  104. package/dist/src/components/item/ItemSeparator.astro +21 -0
  105. package/dist/src/components/item/ItemTitle.astro +16 -0
  106. package/dist/src/components/item/index.ts +50 -0
  107. package/dist/src/components/kbd/Kbd.astro +21 -0
  108. package/dist/src/components/kbd/KbdGroup.astro +16 -0
  109. package/dist/src/components/kbd/index.ts +11 -0
  110. package/dist/src/components/label/Label.astro +22 -0
  111. package/dist/src/components/label/index.ts +7 -0
  112. package/dist/src/components/pagination/Pagination.astro +20 -0
  113. package/dist/src/components/pagination/PaginationContent.astro +16 -0
  114. package/dist/src/components/pagination/PaginationEllipsis.astro +35 -0
  115. package/dist/src/components/pagination/PaginationItem.astro +16 -0
  116. package/dist/src/components/pagination/PaginationLink.astro +24 -0
  117. package/dist/src/components/pagination/PaginationNext.astro +30 -0
  118. package/dist/src/components/pagination/PaginationPrevious.astro +30 -0
  119. package/dist/src/components/pagination/index.ts +38 -0
  120. package/dist/src/components/progress/Progress.astro +155 -0
  121. package/dist/src/components/progress/index.ts +10 -0
  122. package/dist/src/components/prose/Prose.astro +617 -0
  123. package/dist/src/components/prose/index.ts +9 -0
  124. package/dist/src/components/radio-group/RadioGroup.astro +162 -0
  125. package/dist/src/components/radio-group/RadioGroupItem.astro +129 -0
  126. package/dist/src/components/radio-group/RadioGroupTypes.ts +6 -0
  127. package/dist/src/components/radio-group/index.ts +23 -0
  128. package/dist/src/components/select/Select.astro +752 -0
  129. package/dist/src/components/select/SelectContent.astro +94 -0
  130. package/dist/src/components/select/SelectGroup.astro +9 -0
  131. package/dist/src/components/select/SelectItem.astro +51 -0
  132. package/dist/src/components/select/SelectLabel.astro +14 -0
  133. package/dist/src/components/select/SelectSearch.astro +49 -0
  134. package/dist/src/components/select/SelectSeparator.astro +12 -0
  135. package/dist/src/components/select/SelectTrigger.astro +54 -0
  136. package/dist/src/components/select/SelectTypes.ts +13 -0
  137. package/dist/src/components/select/SelectValue.astro +19 -0
  138. package/dist/src/components/select/index.ts +49 -0
  139. package/dist/src/components/separator/Separator.astro +36 -0
  140. package/dist/src/components/separator/index.ts +7 -0
  141. package/dist/src/components/sheet/Sheet.astro +13 -0
  142. package/dist/src/components/sheet/SheetClose.astro +13 -0
  143. package/dist/src/components/sheet/SheetContent.astro +92 -0
  144. package/dist/src/components/sheet/SheetDescription.astro +16 -0
  145. package/dist/src/components/sheet/SheetFooter.astro +16 -0
  146. package/dist/src/components/sheet/SheetHeader.astro +16 -0
  147. package/dist/src/components/sheet/SheetTitle.astro +16 -0
  148. package/dist/src/components/sheet/SheetTrigger.astro +13 -0
  149. package/dist/src/components/sheet/index.ts +41 -0
  150. package/dist/src/components/sidebar/Sidebar.astro +213 -0
  151. package/dist/src/components/sidebar/SidebarContent.astro +24 -0
  152. package/dist/src/components/sidebar/SidebarFooter.astro +21 -0
  153. package/dist/src/components/sidebar/SidebarGroup.astro +21 -0
  154. package/dist/src/components/sidebar/SidebarGroupContent.astro +21 -0
  155. package/dist/src/components/sidebar/SidebarGroupLabel.astro +52 -0
  156. package/dist/src/components/sidebar/SidebarHeader.astro +21 -0
  157. package/dist/src/components/sidebar/SidebarInput.astro +22 -0
  158. package/dist/src/components/sidebar/SidebarInset.astro +21 -0
  159. package/dist/src/components/sidebar/SidebarMenu.astro +21 -0
  160. package/dist/src/components/sidebar/SidebarMenuAction.astro +59 -0
  161. package/dist/src/components/sidebar/SidebarMenuBadge.astro +30 -0
  162. package/dist/src/components/sidebar/SidebarMenuButton.astro +129 -0
  163. package/dist/src/components/sidebar/SidebarMenuItem.astro +21 -0
  164. package/dist/src/components/sidebar/SidebarMenuSkeleton.astro +40 -0
  165. package/dist/src/components/sidebar/SidebarMenuSub.astro +24 -0
  166. package/dist/src/components/sidebar/SidebarMenuSubButton.astro +49 -0
  167. package/dist/src/components/sidebar/SidebarMenuSubItem.astro +16 -0
  168. package/dist/src/components/sidebar/SidebarProvider.astro +213 -0
  169. package/dist/src/components/sidebar/SidebarRail.astro +71 -0
  170. package/dist/src/components/sidebar/SidebarSeparator.astro +22 -0
  171. package/dist/src/components/sidebar/SidebarTrigger.astro +66 -0
  172. package/dist/src/components/sidebar/index.ts +103 -0
  173. package/dist/src/components/skeleton/Skeleton.astro +14 -0
  174. package/dist/src/components/skeleton/index.ts +9 -0
  175. package/dist/src/components/slider/Slider.astro +411 -0
  176. package/dist/src/components/slider/index.ts +9 -0
  177. package/dist/src/components/spinner/Spinner.astro +21 -0
  178. package/dist/src/components/spinner/index.ts +7 -0
  179. package/dist/src/components/switch/Switch.astro +192 -0
  180. package/dist/src/components/switch/SwitchTypes.ts +6 -0
  181. package/dist/src/components/switch/index.ts +12 -0
  182. package/dist/src/components/table/Table.astro +18 -0
  183. package/dist/src/components/table/TableBody.astro +16 -0
  184. package/dist/src/components/table/TableCaption.astro +16 -0
  185. package/dist/src/components/table/TableCell.astro +16 -0
  186. package/dist/src/components/table/TableFoot.astro +16 -0
  187. package/dist/src/components/table/TableHead.astro +16 -0
  188. package/dist/src/components/table/TableHeader.astro +16 -0
  189. package/dist/src/components/table/TableRow.astro +16 -0
  190. package/dist/src/components/table/index.ts +42 -0
  191. package/dist/src/components/tabs/Tabs.astro +271 -0
  192. package/dist/src/components/tabs/TabsContent.astro +28 -0
  193. package/dist/src/components/tabs/TabsList.astro +22 -0
  194. package/dist/src/components/tabs/TabsTrigger.astro +34 -0
  195. package/dist/src/components/tabs/index.ts +20 -0
  196. package/dist/src/components/textarea/Textarea.astro +29 -0
  197. package/dist/src/components/textarea/index.ts +9 -0
  198. package/dist/src/components/theme-toggle/ThemeToggle.astro +208 -0
  199. package/dist/src/components/theme-toggle/index.ts +7 -0
  200. package/dist/src/components/toast/ToastDescription.astro +21 -0
  201. package/dist/src/components/toast/ToastItem.astro +54 -0
  202. package/dist/src/components/toast/ToastTemplate.astro +25 -0
  203. package/dist/src/components/toast/ToastTitle.astro +57 -0
  204. package/dist/src/components/toast/Toaster.astro +982 -0
  205. package/dist/src/components/toast/index.ts +29 -0
  206. package/dist/src/components/toast/toast-manager.ts +216 -0
  207. package/dist/src/components/toggle/Toggle.astro +174 -0
  208. package/dist/src/components/toggle/ToggleTypes.ts +14 -0
  209. package/dist/src/components/toggle/index.ts +8 -0
  210. package/dist/src/components/tooltip/Tooltip.astro +282 -0
  211. package/dist/src/components/tooltip/TooltipContent.astro +89 -0
  212. package/dist/src/components/tooltip/TooltipTrigger.astro +10 -0
  213. package/dist/src/components/tooltip/index.ts +16 -0
  214. package/dist/src/components/video/Video.astro +120 -0
  215. package/dist/src/components/video/index.ts +9 -0
  216. package/package.json +1 -1
@@ -0,0 +1,752 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"div"> & {
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
+ * Whether the select field is required in a form context
15
+ */
16
+ required?: boolean;
17
+
18
+ children: any;
19
+ };
20
+
21
+ const { class: className, name, defaultValue, required, ...rest } = Astro.props;
22
+ ---
23
+
24
+ <div
25
+ class:list={["starwind-select", "relative", className]}
26
+ data-name={name}
27
+ data-value={defaultValue}
28
+ data-required={required}
29
+ data-slot="select"
30
+ {...rest}
31
+ >
32
+ <slot />
33
+ </div>
34
+
35
+ <script>
36
+ import type { SelectChangeEvent, SelectEvent } from "./SelectTypes";
37
+
38
+ class SelectHandler {
39
+ private select: HTMLElement;
40
+ private trigger: HTMLButtonElement | null;
41
+ private content: HTMLElement | null;
42
+ private searchInput: HTMLInputElement | null = null;
43
+ private emptyElement: HTMLElement | null = null;
44
+ private isOpen: boolean = false;
45
+ private selectedItem: HTMLElement | null = null;
46
+ private activeItem: HTMLElement | null = null;
47
+ private animationDuration = 150;
48
+ private typeaheadTimerRef: number | null = null;
49
+ private typeaheadSearch = "";
50
+ private returnFocusOnClose: boolean = true;
51
+ private required: boolean = false;
52
+
53
+ constructor(select: HTMLElement, selectIdx: number) {
54
+ this.select = select;
55
+ this.trigger = select.querySelector(".starwind-select-trigger");
56
+ this.content = select.querySelector(".starwind-select-content");
57
+ this.searchInput = select.querySelector('[data-slot="select-search"]');
58
+ this.emptyElement = select.querySelector('[data-slot="select-empty"]');
59
+
60
+ if (!this.trigger || !this.content) return;
61
+
62
+ this.required = this.select.getAttribute("data-required") === "true";
63
+
64
+ // animationDuration is set with inline styles through passed prop to SelectContent
65
+ const animationDurationString = this.content.style.animationDuration;
66
+ if (animationDurationString.endsWith("ms")) {
67
+ this.animationDuration = parseFloat(animationDurationString);
68
+ } else if (animationDurationString.endsWith("s")) {
69
+ // using something like @playform/compress might optimize to use "s" instead of "ms"
70
+ this.animationDuration = parseFloat(animationDurationString) * 1000;
71
+ }
72
+
73
+ this.init(selectIdx);
74
+ }
75
+
76
+ private init(selectIdx: number) {
77
+ this.setupAccessibility(selectIdx);
78
+ this.setupEvents();
79
+ this.setupSelectField();
80
+ this.setInitialState();
81
+ }
82
+
83
+ private setupSelectField() {
84
+ if (!this.trigger || !this.content) return;
85
+ // build the standard select field
86
+ const selectField = document.createElement("select");
87
+ selectField.tabIndex = -1;
88
+ selectField.setAttribute("aria-hidden", "true");
89
+ selectField.setAttribute("placeholder", "select");
90
+ const selectName = this.select.getAttribute("data-name");
91
+
92
+ if (this.required) {
93
+ selectField.required = true;
94
+ }
95
+
96
+ if (selectName) {
97
+ selectField.name = selectName;
98
+ }
99
+
100
+ // you can comment out this "sr-only" class line below if you want to see the native select in action
101
+ selectField.classList.add("starwind-sr-only");
102
+
103
+ // The first option is a placeholder
104
+ const placeholderOption = document.createElement("option");
105
+ placeholderOption.value = "";
106
+ placeholderOption.textContent = "Select";
107
+ placeholderOption.disabled = true;
108
+ placeholderOption.selected = true;
109
+ selectField.appendChild(placeholderOption);
110
+
111
+ // add all options to the select field
112
+ this.content.querySelectorAll('[role="option"]').forEach((option) => {
113
+ const optionValue = option.getAttribute("data-value");
114
+ const optionText = option.textContent;
115
+ const optionElement = document.createElement("option");
116
+ optionElement.value = optionValue || "";
117
+ optionElement.textContent = optionText || "";
118
+ selectField.appendChild(optionElement);
119
+ });
120
+ this.trigger.appendChild(selectField);
121
+
122
+ // add this select field right after the trigger
123
+ this.trigger.parentElement?.insertBefore(selectField, this.trigger.nextSibling);
124
+
125
+ // Intercept invalid event to show error on trigger instead of hidden select
126
+ if (this.required) {
127
+ selectField.addEventListener("invalid", (e) => {
128
+ this.showValidationError();
129
+ e.preventDefault();
130
+ });
131
+ }
132
+
133
+ this.setSize();
134
+ this.content.style.width = "var(--starwind-select-trigger-width)";
135
+ }
136
+
137
+ private setupAccessibility(selectIdx: number) {
138
+ if (!this.trigger || !this.content) return;
139
+
140
+ // Generate unique IDs for accessibility
141
+ this.trigger.id = `starwind-select${selectIdx}-trigger`;
142
+ this.content.id = `starwind-select${selectIdx}-content`;
143
+
144
+ // Set up additional ARIA attributes
145
+ this.trigger.setAttribute("aria-controls", this.content.id);
146
+ this.trigger.setAttribute("aria-required", this.required ? "true" : "false");
147
+ this.content.setAttribute("aria-labelledby", this.trigger.id);
148
+
149
+ // If search input exists, add IDs to all items for aria-activedescendant
150
+ if (this.searchInput) {
151
+ if (!this.searchInput.id) {
152
+ this.searchInput.id = `starwind-select${selectIdx}-search`;
153
+ }
154
+ // Link search input to the listbox it filters
155
+ this.searchInput.setAttribute("aria-controls", this.content.id);
156
+
157
+ const items = this.content.querySelectorAll('[role="option"]');
158
+ items.forEach((item, index) => {
159
+ if (!item.id) {
160
+ item.id = `starwind-select${selectIdx}-option${index}`;
161
+ }
162
+ });
163
+ }
164
+ }
165
+
166
+ private setupEvents() {
167
+ if (!this.trigger || !this.content) return;
168
+
169
+ // Handle search input if it exists
170
+ if (this.searchInput) {
171
+ this.setupSearchInput();
172
+ }
173
+
174
+ // Handle pointerdown
175
+ this.trigger.addEventListener("pointerdown", (e) => {
176
+ // prevent implicit pointer capture
177
+ // https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture
178
+ const target = e.target as HTMLElement;
179
+ if (target.hasPointerCapture(e.pointerId)) {
180
+ target.releasePointerCapture(e.pointerId);
181
+ }
182
+
183
+ // prevent trigger from stealing focus from the active item after opening.
184
+ e.preventDefault();
185
+
186
+ // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
187
+ // but not when the control key is pressed (avoiding MacOS right click); also not for touch
188
+ // devices because that would open the menu on scroll. (pen devices behave as touch on iOS).
189
+ if (e.button === 0 && e.ctrlKey === false && e.pointerType === "mouse") {
190
+ this.returnFocusOnClose = true;
191
+ this.toggleSelect();
192
+ }
193
+ });
194
+
195
+ // Handle click event for mobile devices
196
+ this.trigger.addEventListener("click", (e) => {
197
+ if (window.matchMedia("(pointer: coarse)").matches) {
198
+ e.preventDefault();
199
+ this.returnFocusOnClose = true;
200
+ this.toggleSelect();
201
+ }
202
+ });
203
+
204
+ // add enter or space key to select trigger to toggle select
205
+ this.trigger.addEventListener("keydown", (e) => {
206
+ if (e.key === "Enter" || e.key === " ") {
207
+ e.preventDefault();
208
+ this.returnFocusOnClose = true;
209
+ this.toggleSelect();
210
+ }
211
+ });
212
+
213
+ // Handle keyboard navigation inside select content
214
+ this.content.addEventListener("keydown", (e) => {
215
+ // Check if the event originated from the search input
216
+ const isFromSearchInput = e.target === this.searchInput;
217
+
218
+ if (e.key === "Enter" || e.key === " ") {
219
+ // Only handle selection if not typing in search input
220
+ if (!isFromSearchInput) {
221
+ e.preventDefault();
222
+ // set element based on current focused element
223
+ const activeElement = document.activeElement;
224
+ this.returnFocusOnClose = true;
225
+ this.handleSelection(activeElement as HTMLElement);
226
+ }
227
+ // If from search input, don't handle it here - let the input handle it naturally
228
+ } else if (e.key === "Escape" && this.isOpen) {
229
+ this.returnFocusOnClose = true;
230
+ this.closeSelect();
231
+ }
232
+
233
+ // add key navigation for accessibility
234
+ // "Home" goes to first element
235
+ // "End" goes to last element
236
+ // "ArrowUp" goes to previous element
237
+ // "ArrowDown" goes to next element
238
+ else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
239
+ this.handleNavigationKeys(e);
240
+ e.preventDefault();
241
+ } else {
242
+ const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
243
+
244
+ // select should not be navigated using tab key so we prevent it
245
+ if (e.key === "Tab") e.preventDefault();
246
+
247
+ if (!isModifierKey && e.key.length === 1 && !this.searchInput) {
248
+ this.handleTypeahead(e.key);
249
+ }
250
+ }
251
+ });
252
+
253
+ // Handle hover on select items
254
+ this.content.addEventListener("mouseover", (e) => {
255
+ const target = e.target as HTMLElement;
256
+ const option = target.closest('[role="option"]');
257
+ if (option && option instanceof HTMLElement && this.isOpen === true) {
258
+ this.setActiveItem(option);
259
+ }
260
+ });
261
+
262
+ // handle pointerdown outside select content to close
263
+ document.addEventListener("pointerdown", (e) => {
264
+ // only close if not a mouse pointer
265
+ if (!window.matchMedia("(pointer: coarse)").matches) {
266
+ if (
267
+ !(
268
+ this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)
269
+ ) &&
270
+ this.isOpen
271
+ ) {
272
+ this.returnFocusOnClose = false;
273
+ this.closeSelect();
274
+ }
275
+ }
276
+ });
277
+
278
+ // Handle click outside select content to close
279
+ document.addEventListener("click", (e) => {
280
+ if (
281
+ !(this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)) &&
282
+ this.isOpen
283
+ ) {
284
+ this.returnFocusOnClose = false;
285
+ this.closeSelect();
286
+ }
287
+ });
288
+
289
+ // Handle selection of items
290
+ this.content?.addEventListener("click", (e) => {
291
+ const item = (e.target as HTMLElement).closest("[role='option']");
292
+ if (item instanceof HTMLElement) {
293
+ this.returnFocusOnClose = true;
294
+ this.handleSelection(item);
295
+ }
296
+ });
297
+
298
+ // passive resize listener to call setSize()
299
+ window.addEventListener("resize", () => this.setSize(), { passive: true });
300
+
301
+ // Listen for programmatic selection events
302
+ document.addEventListener("starwind-select:select", (e: Event) => {
303
+ const selectEvent = e as SelectEvent;
304
+ const selectId = selectEvent.detail.selectId;
305
+ const selectName = selectEvent.detail.selectName;
306
+ const selectValue = selectEvent.detail.value;
307
+
308
+ // Check if this event is for this select
309
+ if (
310
+ (selectId && this.select.id === selectId) ||
311
+ (selectName && this.select.getAttribute("data-name") === selectName)
312
+ ) {
313
+ this.programmaticallySelect(selectValue);
314
+ }
315
+ });
316
+ }
317
+
318
+ private setActiveItem(item: HTMLElement | null) {
319
+ if (!this.content) return;
320
+
321
+ // Remove active state from previous item
322
+ if (this.activeItem) {
323
+ this.activeItem.removeAttribute("data-active");
324
+ }
325
+
326
+ // Set new active item
327
+ this.activeItem = item;
328
+ if (item) {
329
+ item.setAttribute("data-active", "true");
330
+
331
+ // Scroll item into view if needed
332
+ item.scrollIntoView({ block: "nearest" });
333
+
334
+ // For search mode, set aria-activedescendant for assistive technologies
335
+ if (this.searchInput) {
336
+ // Item should already have an ID from setupAccessibility
337
+ this.searchInput.setAttribute("aria-activedescendant", item.id);
338
+ } else {
339
+ // For non-search mode, set focus for keyboard accessibility
340
+ item.focus();
341
+ }
342
+ } else if (this.searchInput) {
343
+ // Clear aria-activedescendant when no item is active
344
+ this.searchInput.removeAttribute("aria-activedescendant");
345
+ }
346
+ }
347
+
348
+ private findNavigableItem(
349
+ items: NodeListOf<Element>,
350
+ startIndex: number,
351
+ direction: "forward" | "backward",
352
+ ): HTMLElement | null {
353
+ const step = direction === "forward" ? 1 : -1;
354
+ const end = direction === "forward" ? items.length : -1;
355
+
356
+ for (let i = startIndex; i !== end; i += step) {
357
+ const item = items[i] as HTMLElement;
358
+ if (
359
+ item.getAttribute("data-disabled") !== "true" &&
360
+ item.getAttribute("data-filtered") !== "true"
361
+ ) {
362
+ return item;
363
+ }
364
+ }
365
+ return null;
366
+ }
367
+
368
+ private handleNavigationKeys(e: KeyboardEvent) {
369
+ if (!this.content) return;
370
+ const items = this.content.querySelectorAll('[role="option"]');
371
+ const currentIndex = Array.from(items).indexOf(this.activeItem as HTMLElement);
372
+
373
+ let targetItem: HTMLElement | null = null;
374
+
375
+ switch (e.key) {
376
+ case "Home":
377
+ targetItem = this.findNavigableItem(items, 0, "forward");
378
+ break;
379
+
380
+ case "End":
381
+ targetItem = this.findNavigableItem(items, items.length - 1, "backward");
382
+ break;
383
+
384
+ case "ArrowUp":
385
+ if (currentIndex > 0) {
386
+ targetItem = this.findNavigableItem(items, currentIndex - 1, "backward");
387
+ }
388
+ break;
389
+
390
+ case "ArrowDown":
391
+ if (currentIndex < items.length - 1) {
392
+ targetItem = this.findNavigableItem(items, currentIndex + 1, "forward");
393
+ }
394
+ break;
395
+ }
396
+
397
+ if (targetItem) {
398
+ this.setActiveItem(targetItem);
399
+ }
400
+ }
401
+
402
+ private setupSearchInput() {
403
+ if (!this.searchInput || !this.content) return;
404
+
405
+ this.searchInput.addEventListener("input", (e) => {
406
+ const target = e.target as HTMLInputElement;
407
+ const searchValue = target.value.toLowerCase().trim();
408
+ this.filterItems(searchValue);
409
+ });
410
+
411
+ // Handle keyboard navigation from search input
412
+ this.searchInput.addEventListener("keydown", (e) => {
413
+ if (e.key === "Escape") {
414
+ e.stopPropagation();
415
+ this.returnFocusOnClose = true;
416
+ this.closeSelect();
417
+ }
418
+ // Allow arrow keys to navigate to items
419
+ else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
420
+ e.preventDefault();
421
+ e.stopPropagation();
422
+ this.handleNavigationKeys(e);
423
+ }
424
+ // Handle Enter to select the active or first visible item
425
+ else if (e.key === "Enter") {
426
+ e.preventDefault();
427
+ const itemToSelect = this.activeItem || this.getFirstVisibleItem();
428
+ if (itemToSelect) {
429
+ this.returnFocusOnClose = true;
430
+ this.handleSelection(itemToSelect);
431
+ }
432
+ }
433
+ // Prevent space from scrolling the page, but allow it to be typed
434
+ else if (e.key === " ") {
435
+ e.stopPropagation();
436
+ }
437
+ });
438
+ }
439
+
440
+ private getFirstVisibleItem(): HTMLElement | null {
441
+ if (!this.content) return null;
442
+
443
+ const items = this.content.querySelectorAll('[role="option"]');
444
+ for (const item of items) {
445
+ if (
446
+ item.getAttribute("data-disabled") !== "true" &&
447
+ item.getAttribute("data-filtered") !== "true"
448
+ ) {
449
+ return item as HTMLElement;
450
+ }
451
+ }
452
+ return null;
453
+ }
454
+
455
+ private filterItems(searchValue: string) {
456
+ if (!this.content) return;
457
+
458
+ const items = this.content.querySelectorAll('[role="option"]');
459
+ let visibleCount = 0;
460
+ let firstVisibleItem: HTMLElement | null = null;
461
+
462
+ items.forEach((item) => {
463
+ const itemText = item.textContent?.toLowerCase().trim() || "";
464
+ const matches = itemText.includes(searchValue);
465
+
466
+ if (matches || searchValue === "") {
467
+ item.classList.remove("starwind-sr-only");
468
+ item.removeAttribute("data-filtered");
469
+ visibleCount++;
470
+ if (!firstVisibleItem) {
471
+ firstVisibleItem = item as HTMLElement;
472
+ }
473
+ } else {
474
+ item.classList.add("starwind-sr-only");
475
+ item.setAttribute("data-filtered", "true");
476
+ }
477
+ });
478
+
479
+ // Update active item to first visible item after filtering
480
+ if (this.searchInput && firstVisibleItem) {
481
+ this.setActiveItem(firstVisibleItem);
482
+ } else if (this.searchInput && visibleCount === 0) {
483
+ this.setActiveItem(null);
484
+ }
485
+
486
+ // Show/hide empty message
487
+ if (this.emptyElement) {
488
+ if (visibleCount === 0 && searchValue !== "") {
489
+ this.emptyElement.classList.remove("hidden");
490
+ } else {
491
+ this.emptyElement.classList.add("hidden");
492
+ }
493
+ }
494
+ }
495
+
496
+ private handleTypeahead(key: string) {
497
+ if (!this.content) return;
498
+ const search = this.typeaheadSearch + key;
499
+ const items = this.content.querySelectorAll('[role="option"]');
500
+
501
+ // find and set active the first matching option
502
+ const matches = Array.from(items).filter((item) =>
503
+ item.textContent?.toLowerCase().trim().startsWith(search.toLowerCase()),
504
+ ) as HTMLElement[];
505
+ if (matches.length > 0) {
506
+ this.setActiveItem(matches[0]);
507
+ }
508
+
509
+ // update the typeahead search and reset the timer
510
+ this.typeaheadSearch = search;
511
+ if (this.typeaheadTimerRef) {
512
+ window.clearTimeout(this.typeaheadTimerRef);
513
+ }
514
+
515
+ // set a timer to clear the search after 1 second
516
+ this.typeaheadTimerRef = window.setTimeout(() => {
517
+ this.typeaheadSearch = "";
518
+ this.typeaheadTimerRef = null;
519
+ }, 1000);
520
+ }
521
+
522
+ private setSize() {
523
+ if (!this.trigger || !this.content) return;
524
+ this.content.style.setProperty(
525
+ "--starwind-select-content-width",
526
+ `${this.content.offsetWidth}px`,
527
+ );
528
+
529
+ this.content.style.setProperty(
530
+ "--starwind-select-trigger-width",
531
+ `${this.trigger.offsetWidth}px`,
532
+ );
533
+ }
534
+
535
+ private toggleSelect() {
536
+ if (this.isOpen) {
537
+ this.closeSelect();
538
+ } else {
539
+ this.openSelect();
540
+ }
541
+ }
542
+
543
+ private openSelect() {
544
+ if (!this.content || !this.trigger || this.trigger.disabled) return;
545
+
546
+ this.isOpen = true;
547
+ this.content.setAttribute("data-state", "open");
548
+ this.trigger.setAttribute("aria-expanded", "true");
549
+ this.content.style.removeProperty("display");
550
+
551
+ // If search input exists, focus it and clear any previous search
552
+ if (this.searchInput) {
553
+ this.searchInput.value = "";
554
+ this.filterItems("");
555
+
556
+ // Set the selected item or first item as active
557
+ const initialItem = this.selectedItem || this.getFirstVisibleItem();
558
+ if (initialItem) {
559
+ this.setActiveItem(initialItem);
560
+ }
561
+
562
+ requestAnimationFrame(() => {
563
+ this.searchInput?.focus();
564
+ });
565
+ } else {
566
+ // set active on the current selected item
567
+ if (this.selectedItem) {
568
+ this.setActiveItem(this.selectedItem);
569
+ } else {
570
+ // if no item is selected, set active on the first item
571
+ const firstItem = this.content.querySelector('[role="option"]') as HTMLElement;
572
+ if (firstItem) {
573
+ this.setActiveItem(firstItem);
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ private closeSelect() {
580
+ if (!this.content || !this.trigger) return;
581
+
582
+ this.isOpen = false;
583
+ this.content.setAttribute("data-state", "closed");
584
+
585
+ // Remove focus from any currently focused element
586
+ const activeElement = document.activeElement;
587
+ if (activeElement instanceof HTMLElement) {
588
+ activeElement.blur();
589
+ }
590
+
591
+ // Set focus on trigger if returnFocusOnClose is true
592
+ if (this.returnFocusOnClose) {
593
+ requestAnimationFrame(() => {
594
+ if (!this.trigger) return;
595
+ this.trigger.focus();
596
+ });
597
+ }
598
+
599
+ // give the content time to animate before hiding
600
+ setTimeout(() => {
601
+ if (!this.content) return;
602
+ this.content.style.display = "none";
603
+
604
+ // Clear search and show all items after animation completes
605
+ if (this.searchInput) {
606
+ this.searchInput.value = "";
607
+ this.filterItems("");
608
+ this.setActiveItem(null);
609
+ }
610
+ }, this.animationDuration);
611
+
612
+ this.trigger.setAttribute("aria-expanded", "false");
613
+ }
614
+
615
+ private handleSelection(item: HTMLElement) {
616
+ if (!this.trigger) return;
617
+
618
+ // Clear any validation error when a selection is made
619
+ this.clearValidationError();
620
+
621
+ // update the hidden select field
622
+ const selectField = this.select.querySelector("select");
623
+ if (selectField) {
624
+ const newValue = item.getAttribute("data-value") || "";
625
+ selectField.value = newValue;
626
+
627
+ // Dispatch custom event with the new value
628
+ const event = new CustomEvent<SelectChangeEvent["detail"]>("starwind-select:change", {
629
+ detail: { value: newValue, selectId: this.select.id, label: item.textContent || "" },
630
+ bubbles: true,
631
+ cancelable: true,
632
+ });
633
+
634
+ selectField.dispatchEvent(event);
635
+ }
636
+
637
+ // Update trigger content
638
+ const triggerSpan = this.trigger.firstElementChild as HTMLSpanElement;
639
+ if (triggerSpan) {
640
+ triggerSpan.textContent = item.textContent;
641
+ }
642
+
643
+ // Update selected states after select finishes closing
644
+ setTimeout(() => {
645
+ if (this.selectedItem) {
646
+ this.selectedItem.setAttribute("aria-selected", "false");
647
+ }
648
+ item.setAttribute("aria-selected", "true");
649
+ this.selectedItem = item;
650
+ }, this.animationDuration);
651
+
652
+ // Close the select
653
+ this.closeSelect();
654
+ }
655
+
656
+ /**
657
+ * Sets the initial state based on the default value attribute
658
+ */
659
+ private setInitialState(): void {
660
+ const defaultValue = this.select.dataset.value;
661
+ if (defaultValue) {
662
+ const item = this.content?.querySelector(`[data-value="${defaultValue}"]`);
663
+
664
+ if (item && item instanceof HTMLElement) {
665
+ this.returnFocusOnClose = false;
666
+ this.handleSelection(item);
667
+ this.selectedItem = item;
668
+ }
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Programmatically selects an option by value
674
+ */
675
+ private programmaticallySelect(value: string): void {
676
+ if (!this.content) return;
677
+
678
+ // Skip if already selected (prevents infinite loops with synced selects)
679
+ if (this.selectedItem?.getAttribute("data-value") === value) return;
680
+
681
+ const item = this.content.querySelector(`[data-value="${value}"]`);
682
+ if (item instanceof HTMLElement) {
683
+ this.returnFocusOnClose = false;
684
+
685
+ // Update aria-selected attributes immediately
686
+ if (this.selectedItem) {
687
+ this.selectedItem.setAttribute("aria-selected", "false");
688
+ }
689
+ item.setAttribute("aria-selected", "true");
690
+
691
+ // Then call handleSelection which will update the rest
692
+ this.handleSelection(item);
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Shows validation error on the trigger
698
+ */
699
+ private showValidationError(): void {
700
+ if (!this.trigger) return;
701
+
702
+ // Add error state to trigger
703
+ this.trigger.setAttribute("aria-invalid", "true");
704
+
705
+ // scroll trigger into view
706
+ this.trigger.scrollIntoView({ behavior: "auto", block: "center" });
707
+
708
+ // Focus the trigger so user knows where the error is
709
+ this.trigger.focus();
710
+ }
711
+
712
+ /**
713
+ * Clears validation error from the trigger
714
+ */
715
+ private clearValidationError(): void {
716
+ if (!this.trigger) return;
717
+
718
+ this.trigger.setAttribute("aria-invalid", "false");
719
+ }
720
+ }
721
+
722
+ // Store instances in a WeakMap to avoid memory leaks
723
+ const selectInstances = new WeakMap<HTMLElement, SelectHandler>();
724
+ let selectCounter = 0;
725
+
726
+ // Initialize selects
727
+ const initSelects = () => {
728
+ document.querySelectorAll(".starwind-select").forEach((select) => {
729
+ if (select instanceof HTMLElement && !selectInstances.has(select)) {
730
+ selectInstances.set(select, new SelectHandler(select, selectCounter++));
731
+ }
732
+ });
733
+ };
734
+
735
+ initSelects();
736
+ document.addEventListener("astro:after-swap", initSelects);
737
+ document.addEventListener("starwind:init", initSelects);
738
+ </script>
739
+
740
+ <style is:global>
741
+ .starwind-sr-only {
742
+ position: absolute;
743
+ width: 1px;
744
+ height: 1px;
745
+ padding: 0;
746
+ margin: -1px;
747
+ overflow: hidden;
748
+ clip: rect(0, 0, 0, 0);
749
+ white-space: nowrap;
750
+ border-width: 0;
751
+ }
752
+ </style>