@starwind-ui/core 0.0.1 → 0.1.0

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 (70) hide show
  1. package/dist/index.js +86 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/src/components/accordion/Accordion.astro +248 -0
  4. package/dist/src/components/accordion/AccordionContent.astro +28 -0
  5. package/dist/src/components/accordion/AccordionItem.astro +25 -0
  6. package/dist/src/components/accordion/AccordionTrigger.astro +26 -0
  7. package/dist/src/components/accordion/index.ts +13 -0
  8. package/dist/src/components/alert/Alert.astro +44 -0
  9. package/dist/src/components/alert/AlertDescription.astro +11 -0
  10. package/dist/src/components/alert/AlertTitle.astro +17 -0
  11. package/dist/src/components/alert/index.ts +11 -0
  12. package/dist/src/components/avatar/Avatar.astro +44 -0
  13. package/dist/src/components/avatar/AvatarFallback.astro +16 -0
  14. package/dist/src/components/avatar/AvatarImage.astro +48 -0
  15. package/dist/src/components/avatar/index.ts +11 -0
  16. package/dist/src/components/card/Card.astro +14 -0
  17. package/dist/src/components/card/CardContent.astro +11 -0
  18. package/dist/src/components/card/CardDescription.astro +11 -0
  19. package/dist/src/components/card/CardFooter.astro +11 -0
  20. package/dist/src/components/card/CardHeader.astro +11 -0
  21. package/dist/src/components/card/CardTitle.astro +11 -0
  22. package/dist/src/components/card/index.ts +17 -0
  23. package/dist/src/components/checkbox/Checkbox.astro +105 -0
  24. package/dist/src/components/checkbox/index.ts +5 -0
  25. package/dist/src/components/dialog/Dialog.astro +175 -0
  26. package/dist/src/components/dialog/DialogClose.astro +30 -0
  27. package/dist/src/components/dialog/DialogContent.astro +57 -0
  28. package/dist/src/components/dialog/DialogDescription.astro +11 -0
  29. package/dist/src/components/dialog/DialogFooter.astro +11 -0
  30. package/dist/src/components/dialog/DialogHeader.astro +11 -0
  31. package/dist/src/components/dialog/DialogTitle.astro +16 -0
  32. package/dist/src/components/dialog/DialogTrigger.astro +35 -0
  33. package/dist/src/components/dialog/index.ts +30 -0
  34. package/dist/src/components/input/Input.astro +26 -0
  35. package/dist/src/components/input/index.ts +5 -0
  36. package/dist/src/components/label/Label.astro +25 -0
  37. package/dist/src/components/label/index.ts +5 -0
  38. package/dist/src/components/pagination/Pagination.astro +18 -0
  39. package/dist/src/components/pagination/PaginationContent.astro +13 -0
  40. package/dist/src/components/pagination/PaginationEllipsis.astro +15 -0
  41. package/dist/src/components/pagination/PaginationItem.astro +13 -0
  42. package/dist/src/components/pagination/PaginationLink.astro +50 -0
  43. package/dist/src/components/pagination/PaginationNext.astro +23 -0
  44. package/dist/src/components/pagination/PaginationPrevious.astro +23 -0
  45. package/dist/src/components/pagination/index.ts +27 -0
  46. package/dist/src/components/select/Select.astro +452 -0
  47. package/dist/src/components/select/SelectContent.astro +57 -0
  48. package/dist/src/components/select/SelectGroup.astro +10 -0
  49. package/dist/src/components/select/SelectItem.astro +41 -0
  50. package/dist/src/components/select/SelectLabel.astro +11 -0
  51. package/dist/src/components/select/SelectSeparator.astro +9 -0
  52. package/dist/src/components/select/SelectTrigger.astro +40 -0
  53. package/dist/src/components/select/SelectTypes.ts +7 -0
  54. package/dist/src/components/select/SelectValue.astro +16 -0
  55. package/dist/src/components/select/index.ts +30 -0
  56. package/dist/src/components/switch/Switch.astro +189 -0
  57. package/dist/src/components/switch/SwitchTypes.ts +6 -0
  58. package/dist/src/components/switch/index.ts +6 -0
  59. package/dist/src/components/tabs/Tabs.astro +246 -0
  60. package/dist/src/components/tabs/TabsContent.astro +22 -0
  61. package/dist/src/components/tabs/TabsList.astro +19 -0
  62. package/dist/src/components/tabs/TabsTrigger.astro +27 -0
  63. package/dist/src/components/tabs/index.ts +13 -0
  64. package/dist/src/components/textarea/Textarea.astro +25 -0
  65. package/dist/src/components/textarea/index.ts +5 -0
  66. package/dist/src/components/tooltip/Tooltip.astro +233 -0
  67. package/dist/src/components/tooltip/TooltipContent.astro +76 -0
  68. package/dist/src/components/tooltip/TooltipTrigger.astro +11 -0
  69. package/dist/src/components/tooltip/index.ts +11 -0
  70. package/package.json +1 -1
@@ -0,0 +1,13 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"ul"> & {
5
+ children: any;
6
+ };
7
+
8
+ const { class: className, ...rest } = Astro.props;
9
+ ---
10
+
11
+ <ul class:list={["flex flex-row items-center gap-1", className]} {...rest}>
12
+ <slot />
13
+ </ul>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import Dots from "@tabler/icons/outline/dots.svg";
4
+
5
+ type Props = HTMLAttributes<"span"> & {
6
+ children?: any;
7
+ };
8
+
9
+ const { class: className, ...rest } = Astro.props;
10
+ ---
11
+
12
+ <span aria-hidden class:list={["flex h-9 w-9 items-center justify-center", className]} {...rest}>
13
+ <Dots class="size-4" />
14
+ <span class="sr-only">More pages</span>
15
+ </span>
@@ -0,0 +1,13 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"li"> & {
5
+ children: any;
6
+ };
7
+
8
+ const { class: className, ...rest } = Astro.props;
9
+ ---
10
+
11
+ <li class:list={[className]} {...rest}>
12
+ <slot />
13
+ </li>
@@ -0,0 +1,50 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ interface Props extends HTMLAttributes<"a"> {
5
+ isActive?: boolean;
6
+ size?: "sm" | "md" | "lg" | "icon";
7
+ radius?: "none" | "xs" | "sm" | "md" | "lg" | "xl" | "full";
8
+ }
9
+
10
+ const { class: className, isActive, size = "icon", radius = "md", ...rest } = Astro.props;
11
+ ---
12
+
13
+ <a
14
+ aria-current={isActive ? "page" : undefined}
15
+ class:list={[
16
+ // default starwind button styles
17
+ "inline-flex items-center justify-center gap-1.5 font-medium whitespace-nowrap",
18
+ "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
19
+ "starwind-transition-colors",
20
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
21
+ "disabled:pointer-events-none disabled:opacity-50",
22
+ {
23
+ // default starwind button variant="outline" styles
24
+ "border-border hover:bg-border hover:text-foreground focus-visible:outline-outline border":
25
+ isActive,
26
+ // default starwind button variant="ghost" styles
27
+ "hover:bg-foreground/10 hover:text-foreground focus-visible:outline-outline bg-transparent":
28
+ !isActive,
29
+ },
30
+ {
31
+ "h-9 px-3 py-2 text-sm": size === "sm",
32
+ "h-11 px-4 py-2 text-base": size === "md",
33
+ "h-12 px-8 py-2 text-lg": size === "lg",
34
+ "h-11 w-11": size === "icon",
35
+ },
36
+ {
37
+ "rounded-none": radius === "none",
38
+ "rounded-xs": radius === "xs",
39
+ "rounded-sm": radius === "sm",
40
+ "rounded-md": radius === "md",
41
+ "rounded-lg": radius === "lg",
42
+ "rounded-xl": radius === "xl",
43
+ "rounded-full": radius === "full",
44
+ },
45
+ className,
46
+ ]}
47
+ {...rest}
48
+ >
49
+ <slot />
50
+ </a>
@@ -0,0 +1,23 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import ChevronRight from "@tabler/icons/outline/chevron-right.svg";
4
+ import PaginationLink from "./PaginationLink.astro";
5
+
6
+ type Props = HTMLAttributes<"a"> & {
7
+ size?: "sm" | "md" | "lg" | "icon";
8
+ radius?: "none" | "xs" | "sm" | "md" | "lg" | "xl" | "full";
9
+ };
10
+
11
+ const { class: className, size = "md", radius = "md", ...rest } = Astro.props;
12
+ ---
13
+
14
+ <PaginationLink
15
+ aria-label="Go to next page"
16
+ size={size}
17
+ radius={radius}
18
+ class:list={["group gap-1", className]}
19
+ {...rest}
20
+ >
21
+ <slot />
22
+ <ChevronRight class="size-4 transition-transform group-hover:translate-x-1" />
23
+ </PaginationLink>
@@ -0,0 +1,23 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import ChevronLeft from "@tabler/icons/outline/chevron-left.svg";
4
+ import PaginationLink from "./PaginationLink.astro";
5
+
6
+ type Props = HTMLAttributes<"a"> & {
7
+ size?: "sm" | "md" | "lg" | "icon";
8
+ radius?: "none" | "xs" | "sm" | "md" | "lg" | "xl" | "full";
9
+ };
10
+
11
+ const { class: className, size = "md", radius = "md", ...rest } = Astro.props;
12
+ ---
13
+
14
+ <PaginationLink
15
+ aria-label="Go to previous page"
16
+ size={size}
17
+ radius={radius}
18
+ class:list={["group gap-1", className]}
19
+ {...rest}
20
+ >
21
+ <ChevronLeft class="size-4 transition-transform group-hover:-translate-x-1" />
22
+ <slot />
23
+ </PaginationLink>
@@ -0,0 +1,27 @@
1
+ import Pagination from "./Pagination.astro";
2
+ import PaginationContent from "./PaginationContent.astro";
3
+ import PaginationEllipsis from "./PaginationEllipsis.astro";
4
+ import PaginationItem from "./PaginationItem.astro";
5
+ import PaginationLink from "./PaginationLink.astro";
6
+ import PaginationNext from "./PaginationNext.astro";
7
+ import PaginationPrevious from "./PaginationPrevious.astro";
8
+
9
+ export {
10
+ Pagination,
11
+ PaginationContent,
12
+ PaginationEllipsis,
13
+ PaginationItem,
14
+ PaginationLink,
15
+ PaginationNext,
16
+ PaginationPrevious,
17
+ };
18
+
19
+ export default {
20
+ Root: Pagination,
21
+ Content: PaginationContent,
22
+ Ellipsis: PaginationEllipsis,
23
+ Item: PaginationItem,
24
+ Link: PaginationLink,
25
+ Next: PaginationNext,
26
+ Previous: PaginationPrevious,
27
+ };
@@ -0,0 +1,452 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"div"> & {
5
+ children: any;
6
+ };
7
+
8
+ const { class: className, ...rest } = Astro.props;
9
+ ---
10
+
11
+ <div class:list={["starwind-select", "relative", className]} {...rest}>
12
+ <slot />
13
+ </div>
14
+
15
+ <script>
16
+ import type { SelectChangeEvent } from "./SelectTypes";
17
+
18
+ class SelectHandler {
19
+ private select: HTMLElement;
20
+ private trigger: HTMLButtonElement | null;
21
+ private content: HTMLElement | null;
22
+ private isOpen: boolean = false;
23
+ private selectedItem: HTMLElement | null = null;
24
+ private animationDuration = 150;
25
+ private typeaheadTimerRef: number | null = null;
26
+ private typeaheadSearch = "";
27
+
28
+ constructor(select: HTMLElement, selectIdx: number) {
29
+ this.select = select;
30
+ this.trigger = select.querySelector(".starwind-select-trigger");
31
+ this.content = select.querySelector(".starwind-select-content");
32
+
33
+ if (!this.trigger || !this.content) return;
34
+
35
+ // animationDuration is set with inline styles through passed prop to SelectContent
36
+ const animationDurationString = this.content.style.animationDuration;
37
+ if (animationDurationString.endsWith("ms")) {
38
+ this.animationDuration = parseFloat(animationDurationString);
39
+ } else if (animationDurationString.endsWith("s")) {
40
+ // using something like @playform/compress might optimize to use "s" instead of "ms"
41
+ this.animationDuration = parseFloat(animationDurationString) * 1000;
42
+ }
43
+
44
+ this.init(selectIdx);
45
+ }
46
+
47
+ private init(selectIdx: number) {
48
+ this.setupAccessibility(selectIdx);
49
+ this.setupEvents();
50
+ this.setupSelectField();
51
+ }
52
+
53
+ private setupSelectField() {
54
+ if (!this.trigger || !this.content) return;
55
+ // build the standard select field
56
+ const selectField = document.createElement("select");
57
+ selectField.tabIndex = -1;
58
+ selectField.setAttribute("aria-hidden", "true");
59
+ selectField.setAttribute("placeholder", "select");
60
+
61
+ // you can comment out this "sr-only" class line below if you want to see the native select in action
62
+ selectField.classList.add("starwind-sr-only");
63
+
64
+ // The first option is a placeholder
65
+ const placeholderOption = document.createElement("option");
66
+ placeholderOption.value = "";
67
+ placeholderOption.textContent = "Select";
68
+ placeholderOption.disabled = true;
69
+ placeholderOption.selected = true;
70
+ selectField.appendChild(placeholderOption);
71
+
72
+ // add all options to the select field
73
+ this.content.querySelectorAll('[role="option"]').forEach((option) => {
74
+ const optionValue = option.getAttribute("data-value");
75
+ const optionText = option.textContent;
76
+ const optionElement = document.createElement("option");
77
+ optionElement.value = optionValue || "";
78
+ optionElement.textContent = optionText || "";
79
+ selectField.appendChild(optionElement);
80
+ });
81
+ this.trigger.appendChild(selectField);
82
+
83
+ // add this select field right after the trigger
84
+ this.trigger.parentElement?.insertBefore(selectField, this.trigger.nextSibling);
85
+
86
+ this.setSize();
87
+ this.content.style.width = "var(--starwind-select-trigger-width)";
88
+ }
89
+
90
+ private setupAccessibility(selectIdx: number) {
91
+ if (!this.trigger || !this.content) return;
92
+
93
+ // Generate unique IDs for accessibility
94
+ this.trigger.id = `starwind-select${selectIdx}-trigger`;
95
+ this.content.id = `starwind-select${selectIdx}-content`;
96
+
97
+ // Set up additional ARIA attributes
98
+ this.trigger.setAttribute("aria-controls", this.content.id);
99
+ this.content.setAttribute("aria-labelledby", this.trigger.id);
100
+ }
101
+
102
+ private setupEvents() {
103
+ if (!this.trigger || !this.content) return;
104
+
105
+ // Handle pointerdown
106
+ this.trigger.addEventListener("pointerdown", (e) => {
107
+ // prevent implicit pointer capture
108
+ // https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture
109
+ const target = e.target as HTMLElement;
110
+ if (target.hasPointerCapture(e.pointerId)) {
111
+ target.releasePointerCapture(e.pointerId);
112
+ }
113
+
114
+ // prevent trigger from stealing focus from the active item after opening.
115
+ e.preventDefault();
116
+
117
+ // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
118
+ // but not when the control key is pressed (avoiding MacOS right click); also not for touch
119
+ // devices because that would open the menu on scroll. (pen devices behave as touch on iOS).
120
+ if (e.button === 0 && e.ctrlKey === false && e.pointerType === "mouse") {
121
+ this.toggleSelect();
122
+ }
123
+ });
124
+
125
+ // Handle click event for mobile devices
126
+ this.trigger.addEventListener("click", (e) => {
127
+ if (window.matchMedia("(pointer: coarse)").matches) {
128
+ e.preventDefault();
129
+ this.toggleSelect();
130
+ }
131
+ });
132
+
133
+ // add enter or space key to select trigger to toggle select
134
+ this.trigger.addEventListener("keydown", (e) => {
135
+ if (e.key === "Enter" || e.key === " ") {
136
+ e.preventDefault();
137
+ this.toggleSelect();
138
+ }
139
+ });
140
+
141
+ // Handle keyboard navigation inside select content
142
+ this.content.addEventListener("keydown", (e) => {
143
+ if (e.key === "Enter" || e.key === " ") {
144
+ // set element based on current focused element
145
+ const activeElement = document.activeElement;
146
+ this.handleSelection(activeElement as HTMLElement);
147
+ } else if (e.key === "Escape" && this.isOpen) {
148
+ this.closeSelect();
149
+ }
150
+
151
+ // add key navigation for accessibility
152
+ // "Home" goes to first element
153
+ // "End" goes to last element
154
+ // "ArrowUp" goes to previous element
155
+ // "ArrowDown" goes to next element
156
+ else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
157
+ this.handleNavigationKeys(e);
158
+ e.preventDefault();
159
+ } else {
160
+ const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
161
+
162
+ // select should not be navigated using tab key so we prevent it
163
+ if (e.key === "Tab") e.preventDefault();
164
+
165
+ if (!isModifierKey && e.key.length === 1) {
166
+ this.handleTypeahead(e.key);
167
+ }
168
+ }
169
+ });
170
+
171
+ // Handle hover on select items
172
+ this.content.addEventListener("mouseover", (e) => {
173
+ const target = e.target as HTMLElement;
174
+ const option = target.closest('[role="option"]');
175
+ if (option && option instanceof HTMLElement && this.isOpen === true) {
176
+ option.focus();
177
+ }
178
+ });
179
+
180
+ // handle pointerdown outside select content to close
181
+ document.addEventListener("pointerdown", (e) => {
182
+ // only close if not a mouse pointer
183
+ if (!window.matchMedia("(pointer: coarse)").matches) {
184
+ if (
185
+ !(
186
+ this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)
187
+ ) &&
188
+ this.isOpen
189
+ ) {
190
+ this.closeSelect();
191
+ }
192
+ }
193
+ });
194
+
195
+ // Handle click outside select content to close
196
+ document.addEventListener("click", (e) => {
197
+ if (
198
+ !(this.trigger?.contains(e.target as Node) || this.content?.contains(e.target as Node)) &&
199
+ this.isOpen
200
+ ) {
201
+ this.closeSelect();
202
+ }
203
+ });
204
+
205
+ // Handle selection of items
206
+ this.content?.addEventListener("click", (e) => {
207
+ const item = (e.target as HTMLElement).closest("[role='option']");
208
+ if (item instanceof HTMLElement) {
209
+ this.handleSelection(item);
210
+ }
211
+ });
212
+
213
+ // passive resize listener to call setSize() on window resize
214
+ window.addEventListener("resize", () => this.setSize(), {
215
+ passive: true,
216
+ });
217
+ }
218
+
219
+ private handleNavigationKeys(e: KeyboardEvent) {
220
+ if (!this.content) return;
221
+ const items = this.content.querySelectorAll('[role="option"]');
222
+
223
+ // current, or first item, is focused upon opening the select
224
+ const activeElement = document.activeElement;
225
+ const currentIndex = Array.from(items).indexOf(activeElement as HTMLElement);
226
+ if (e.key === "Home") {
227
+ const firstEnabledItem = Array.from(items).find(
228
+ (item) => item.getAttribute("data-disabled") !== "true"
229
+ ) as HTMLElement;
230
+ if (firstEnabledItem) {
231
+ firstEnabledItem.focus();
232
+ }
233
+ return;
234
+ }
235
+ if (e.key === "End") {
236
+ const lastEnabledItem = Array.from(items)
237
+ .reverse()
238
+ .find((item) => item.getAttribute("data-disabled") !== "true") as HTMLElement;
239
+ if (lastEnabledItem) {
240
+ lastEnabledItem.focus();
241
+ }
242
+ return;
243
+ }
244
+ if (e.key === "ArrowUp" && currentIndex > 0) {
245
+ for (let i = currentIndex - 1; i >= 0; i--) {
246
+ const item = items[i] as HTMLElement;
247
+ if (item.getAttribute("data-disabled") !== "true") {
248
+ item.focus();
249
+ break;
250
+ }
251
+ }
252
+ return;
253
+ }
254
+ if (e.key === "ArrowDown" && currentIndex < items.length - 1) {
255
+ for (let i = currentIndex + 1; i < items.length; i++) {
256
+ const item = items[i] as HTMLElement;
257
+ if (item.getAttribute("data-disabled") !== "true") {
258
+ item.focus();
259
+ break;
260
+ }
261
+ }
262
+ return;
263
+ }
264
+ }
265
+
266
+ private handleTypeahead(key: string) {
267
+ if (!this.content) return;
268
+ const search = this.typeaheadSearch + key;
269
+ const items = this.content.querySelectorAll('[role="option"]');
270
+
271
+ // find and focus the first matching option
272
+ const matches = Array.from(items).filter((item) =>
273
+ item.textContent?.toLowerCase().trim().startsWith(search.toLowerCase()),
274
+ ) as HTMLElement[];
275
+ if (matches.length > 0) {
276
+ matches[0].focus();
277
+ }
278
+
279
+ // update the typeahead search and reset the timer
280
+ this.typeaheadSearch = search;
281
+ if (this.typeaheadTimerRef) {
282
+ window.clearTimeout(this.typeaheadTimerRef);
283
+ }
284
+
285
+ // set a timer to clear the search after 1 second
286
+ this.typeaheadTimerRef = window.setTimeout(() => {
287
+ this.typeaheadSearch = "";
288
+ this.typeaheadTimerRef = null;
289
+ }, 1000);
290
+ }
291
+
292
+ private setSize() {
293
+ if (!this.trigger || !this.content) return;
294
+ this.content.style.setProperty(
295
+ "--starwind-select-content-width",
296
+ `${this.content.offsetWidth}px`,
297
+ );
298
+
299
+ this.content.style.setProperty(
300
+ "--starwind-select-trigger-width",
301
+ `${this.trigger.offsetWidth}px`,
302
+ );
303
+ }
304
+
305
+ private toggleSelect() {
306
+ this.isOpen ? this.closeSelect() : this.openSelect();
307
+ }
308
+
309
+ private openSelect() {
310
+ if (!this.content || !this.trigger || this.trigger.disabled) return;
311
+
312
+ this.isOpen = true;
313
+ this.content.setAttribute("data-state", "open");
314
+ this.trigger.setAttribute("aria-expanded", "true");
315
+ this.content.style.removeProperty("display");
316
+
317
+ // set focus on the current selected item
318
+ if (this.selectedItem) {
319
+ this.selectedItem.focus();
320
+ } else {
321
+ // if no item is selected, focus on the first item
322
+ const firstItem = this.content.querySelector('[role="option"]') as HTMLElement;
323
+ if (firstItem) {
324
+ firstItem.focus();
325
+ }
326
+ }
327
+
328
+ this.positionContent();
329
+ }
330
+
331
+ private closeSelect() {
332
+ if (!this.content || !this.trigger) return;
333
+
334
+ this.isOpen = false;
335
+ this.content.setAttribute("data-state", "closed");
336
+
337
+ // Remove focus from any currently focused element
338
+ const activeElement = document.activeElement;
339
+ if (activeElement instanceof HTMLElement) {
340
+ activeElement.blur();
341
+ }
342
+
343
+ // Set focus on trigger
344
+ requestAnimationFrame(() => {
345
+ if (!this.trigger) return;
346
+ this.trigger.focus();
347
+ });
348
+
349
+ // give the content time to animate before hiding
350
+ setTimeout(() => {
351
+ if (!this.content) return;
352
+ this.content.style.display = "none";
353
+ }, this.animationDuration - 10);
354
+
355
+ this.trigger.setAttribute("aria-expanded", "false");
356
+ }
357
+
358
+ private handleSelection(item: HTMLElement) {
359
+ if (!this.trigger) return;
360
+
361
+ // update the hidden select field
362
+ const selectField = this.select.querySelector("select");
363
+ if (selectField) {
364
+ const newValue = item.getAttribute("data-value") || "";
365
+ selectField.value = newValue;
366
+
367
+ // Dispatch custom event with the new value
368
+ const event = new CustomEvent<SelectChangeEvent["detail"]>("starwind-select:change", {
369
+ detail: {
370
+ value: newValue,
371
+ selectId: this.select.id,
372
+ label: item.textContent || "",
373
+ },
374
+ bubbles: true,
375
+ cancelable: true,
376
+ });
377
+
378
+ selectField.dispatchEvent(event);
379
+ }
380
+
381
+ // Update trigger content
382
+ const triggerSpan = this.trigger.firstElementChild as HTMLSpanElement;
383
+ if (triggerSpan) {
384
+ triggerSpan.textContent = item.textContent;
385
+ }
386
+
387
+ // Update selected states after select finishes closing
388
+ setTimeout(() => {
389
+ if (this.selectedItem) {
390
+ this.selectedItem.setAttribute("aria-selected", "false");
391
+ }
392
+ item.setAttribute("aria-selected", "true");
393
+ this.selectedItem = item;
394
+ }, this.animationDuration);
395
+
396
+ // Close the select
397
+ this.closeSelect();
398
+ }
399
+
400
+ /**
401
+ * TODO: add position logic to avoid collisions with window boundary
402
+ * It will need to switch to top or bottom depending on space available
403
+ * It will also need to set the content max height so it doesn't overflow the viewport
404
+ */
405
+ private positionContent() {
406
+ // if (!this.content || !this.trigger) return;
407
+ // const triggerRect = this.trigger.getBoundingClientRect();
408
+ // const contentRect = this.content.getBoundingClientRect();
409
+ // const viewportHeight = window.innerHeight;
410
+ // // Position the content below the trigger by default
411
+ // let top = triggerRect.bottom;
412
+ // // If there's not enough space below, position it above
413
+ // if (top + contentRect.height > viewportHeight) {
414
+ // top = triggerRect.top - contentRect.height;
415
+ // }
416
+ // this.content.style.position = "absolute";
417
+ // this.content.style.top = `${top}px`;
418
+ // this.content.style.left = `${triggerRect.left}px`;
419
+ // this.content.style.width = `${triggerRect.width}px`;
420
+ // this.content.style.zIndex = "50";
421
+ }
422
+ }
423
+
424
+ // Store instances in a WeakMap to avoid memory leaks
425
+ const selectInstances = new WeakMap<HTMLElement, SelectHandler>();
426
+
427
+ // Initialize selects
428
+ const initSelects = () => {
429
+ document.querySelectorAll(".starwind-select").forEach((select, idx) => {
430
+ if (select instanceof HTMLElement && !selectInstances.has(select)) {
431
+ selectInstances.set(select, new SelectHandler(select, idx));
432
+ }
433
+ });
434
+ };
435
+
436
+ initSelects();
437
+ document.addEventListener("astro:after-swap", initSelects);
438
+ </script>
439
+
440
+ <style is:global>
441
+ .starwind-sr-only {
442
+ position: absolute;
443
+ width: 1px;
444
+ height: 1px;
445
+ padding: 0;
446
+ margin: -1px;
447
+ overflow: hidden;
448
+ clip: rect(0, 0, 0, 0);
449
+ white-space: nowrap;
450
+ border-width: 0;
451
+ }
452
+ </style>
@@ -0,0 +1,57 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"div"> & {
5
+ /**
6
+ * Side of the tooltip
7
+ * @default bottom
8
+ */
9
+ side?: "top" | "bottom";
10
+ /**
11
+ * Offset distance in pixels
12
+ * @default 4
13
+ */
14
+ sideOffset?: number;
15
+ /**
16
+ * Open and close animation duration in milliseconds
17
+ * @default 150
18
+ */
19
+ animationDuration?: number;
20
+ };
21
+
22
+ const {
23
+ class: className,
24
+ side = "bottom",
25
+ sideOffset = 4,
26
+ animationDuration = 150,
27
+ ...rest
28
+ } = Astro.props;
29
+ ---
30
+
31
+ <div
32
+ class:list={[
33
+ "starwind-select-content",
34
+ "bg-popover text-popover-foreground absolute z-50 min-w-[8rem] rounded-md border shadow-md",
35
+ "fade-in-0 zoom-in-95 animate-in overflow-hidden will-change-transform",
36
+ "data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out",
37
+ "data-[side=bottom]:slide-in-from-top-4 data-[side=bottom]:top-(--select-content-offset)",
38
+ "data-[side=top]:slide-in-from-bottom-4 data-[side=top]:bottom-(--select-content-offset)",
39
+ "left-0",
40
+ className,
41
+ ]}
42
+ role="listbox"
43
+ data-side={side}
44
+ data-state="closed"
45
+ tabindex="-1"
46
+ style={{
47
+ "--select-content-offset": `calc(100% + ${sideOffset}px)`,
48
+ // hide the content initially. Script will remove this
49
+ display: "none",
50
+ animationDuration: `${animationDuration}ms`,
51
+ }}
52
+ {...rest}
53
+ >
54
+ <div class:list={["max-h-96 w-full min-w-(--select-trigger-width) overflow-y-auto p-1"]}>
55
+ <slot />
56
+ </div>
57
+ </div>
@@ -0,0 +1,10 @@
1
+ ---
2
+ /**
3
+ * This current doesn't do anything
4
+ */
5
+ type Props = {
6
+ children: any;
7
+ };
8
+ ---
9
+
10
+ <slot />