@starwind-ui/core 1.4.1 → 1.5.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.
package/dist/index.js CHANGED
@@ -10,10 +10,12 @@ var registry_default = {
10
10
  { name: "alert", type: "component", version: "1.1.1", dependencies: [] },
11
11
  { name: "avatar", type: "component", version: "1.1.1", dependencies: [] },
12
12
  { name: "badge", type: "component", version: "1.1.1", dependencies: [] },
13
+ { name: "breadcrumb", type: "component", version: "1.0.0", dependencies: [] },
13
14
  { name: "button", type: "component", version: "2.0.1", dependencies: [] },
14
15
  { name: "card", type: "component", version: "1.1.0", dependencies: [] },
15
16
  { name: "checkbox", type: "component", version: "1.2.0", dependencies: [] },
16
17
  { name: "dialog", type: "component", version: "1.1.1", dependencies: [] },
18
+ { name: "dropdown", type: "component", version: "1.0.0", dependencies: [] },
17
19
  { name: "input", type: "component", version: "1.1.1", dependencies: [] },
18
20
  { name: "label", type: "component", version: "1.1.1", dependencies: [] },
19
21
  { name: "pagination", type: "component", version: "2.0.1", dependencies: [] },
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/registry.json"],"sourcesContent":["import { join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport componentRegistry from \"./registry.json\" with { type: \"json\" };\n\n/**\n * Component metadata interface describing a Starwind UI component\n */\nexport interface ComponentMeta {\n\tname: string;\n\tversion: string;\n\ttype: \"component\";\n\tdependencies: string[];\n}\n\n/**\n * Registry interface containing all available components\n */\nexport interface Registry {\n\tcomponents: ComponentMeta[];\n}\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\n\n/**\n * Get the absolute path to a component file\n * @param {string} componentName - The name of the component\n * @param {string} fileName - The name of the file within the component\n * @returns {string} The absolute path to the component file\n */\nexport const getComponentPath = (componentName: string, fileName: string): string => {\n\t// In production (when installed as a dependency), the components will be in dist/src/components\n\t// In development, they will be in src/components\n\tconst componentsDir = __dirname.includes(\"dist\") ? \"src/components\" : \"src/components\";\n\treturn join(__dirname, componentsDir, componentName, fileName);\n};\n\n/**\n * Map of all components and their metadata from registry\n */\nexport const registry = componentRegistry.components as ComponentMeta[];\n","{\n\t\"$schema\": \"https://starwind.dev/registry-schema.json\",\n\t\"components\": [\n\t\t{ \"name\": \"accordion\", \"type\": \"component\", \"version\": \"1.1.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"alert\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"avatar\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"badge\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"button\", \"type\": \"component\", \"version\": \"2.0.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"card\", \"type\": \"component\", \"version\": \"1.1.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"checkbox\", \"type\": \"component\", \"version\": \"1.2.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"dialog\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"input\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"label\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"pagination\", \"type\": \"component\", \"version\": \"2.0.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"select\", \"type\": \"component\", \"version\": \"1.2.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"switch\", \"type\": \"component\", \"version\": \"1.1.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"tabs\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"textarea\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"tooltip\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] }\n\t]\n}\n"],"mappings":";AAAA,SAAS,YAAY;AACrB,SAAS,qBAAqB;;;ACD9B;AAAA,EACC,SAAW;AAAA,EACX,YAAc;AAAA,IACb,EAAE,MAAQ,aAAa,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IACnF,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,QAAQ,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC9E,EAAE,MAAQ,YAAY,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAClF,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,cAAc,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IACpF,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,QAAQ,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC9E,EAAE,MAAQ,YAAY,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAClF,EAAE,MAAQ,WAAW,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,EAClF;AACD;;;ADCA,IAAM,YAAY,cAAc,IAAI,IAAI,KAAK,YAAY,GAAG,CAAC;AAQtD,IAAM,mBAAmB,CAAC,eAAuB,aAA6B;AAGpF,QAAM,gBAAgB,UAAU,SAAS,MAAM,IAAI,mBAAmB;AACtE,SAAO,KAAK,WAAW,eAAe,eAAe,QAAQ;AAC9D;AAKO,IAAM,WAAW,iBAAkB;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/registry.json"],"sourcesContent":["import { join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport componentRegistry from \"./registry.json\" with { type: \"json\" };\n\n/**\n * Component metadata interface describing a Starwind UI component\n */\nexport interface ComponentMeta {\n\tname: string;\n\tversion: string;\n\ttype: \"component\";\n\tdependencies: string[];\n}\n\n/**\n * Registry interface containing all available components\n */\nexport interface Registry {\n\tcomponents: ComponentMeta[];\n}\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\n\n/**\n * Get the absolute path to a component file\n * @param {string} componentName - The name of the component\n * @param {string} fileName - The name of the file within the component\n * @returns {string} The absolute path to the component file\n */\nexport const getComponentPath = (componentName: string, fileName: string): string => {\n\t// In production (when installed as a dependency), the components will be in dist/src/components\n\t// In development, they will be in src/components\n\tconst componentsDir = __dirname.includes(\"dist\") ? \"src/components\" : \"src/components\";\n\treturn join(__dirname, componentsDir, componentName, fileName);\n};\n\n/**\n * Map of all components and their metadata from registry\n */\nexport const registry = componentRegistry.components as ComponentMeta[];\n","{\n\t\"$schema\": \"https://starwind.dev/registry-schema.json\",\n\t\"components\": [\n\t\t{ \"name\": \"accordion\", \"type\": \"component\", \"version\": \"1.1.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"alert\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"avatar\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"badge\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"breadcrumb\", \"type\": \"component\", \"version\": \"1.0.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"button\", \"type\": \"component\", \"version\": \"2.0.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"card\", \"type\": \"component\", \"version\": \"1.1.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"checkbox\", \"type\": \"component\", \"version\": \"1.2.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"dialog\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"dropdown\", \"type\": \"component\", \"version\": \"1.0.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"input\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"label\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"pagination\", \"type\": \"component\", \"version\": \"2.0.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"select\", \"type\": \"component\", \"version\": \"1.2.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"switch\", \"type\": \"component\", \"version\": \"1.1.0\", \"dependencies\": [] },\n\t\t{ \"name\": \"tabs\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"textarea\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] },\n\t\t{ \"name\": \"tooltip\", \"type\": \"component\", \"version\": \"1.1.1\", \"dependencies\": [] }\n\t]\n}\n"],"mappings":";AAAA,SAAS,YAAY;AACrB,SAAS,qBAAqB;;;ACD9B;AAAA,EACC,SAAW;AAAA,EACX,YAAc;AAAA,IACb,EAAE,MAAQ,aAAa,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IACnF,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,cAAc,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IACpF,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,QAAQ,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC9E,EAAE,MAAQ,YAAY,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAClF,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,YAAY,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAClF,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,SAAS,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC/E,EAAE,MAAQ,cAAc,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IACpF,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,UAAU,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAChF,EAAE,MAAQ,QAAQ,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAC9E,EAAE,MAAQ,YAAY,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,IAClF,EAAE,MAAQ,WAAW,MAAQ,aAAa,SAAW,SAAS,cAAgB,CAAC,EAAE;AAAA,EAClF;AACD;;;ADDA,IAAM,YAAY,cAAc,IAAI,IAAI,KAAK,YAAY,GAAG,CAAC;AAQtD,IAAM,mBAAmB,CAAC,eAAuB,aAA6B;AAGpF,QAAM,gBAAgB,UAAU,SAAS,MAAM,IAAI,mBAAmB;AACtE,SAAO,KAAK,WAAW,eAAe,eAAe,QAAQ;AAC9D;AAKO,IAAM,WAAW,iBAAkB;","names":[]}
@@ -0,0 +1,11 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"nav">;
5
+
6
+ const { class: className, ...rest } = Astro.props;
7
+ ---
8
+
9
+ <nav aria-label="breadcrumb" class={className} {...rest}>
10
+ <slot />
11
+ </nav>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import Dots from "@tabler/icons/outline/dots.svg";
3
+ import type { HTMLAttributes } from "astro/types";
4
+ import { tv } from "tailwind-variants";
5
+
6
+ type Props = HTMLAttributes<"span">;
7
+
8
+ const breadcrumbEllipsis = tv({ base: "flex size-6 items-center justify-center [&>svg]:size-4" });
9
+
10
+ const { class: className, ...rest } = Astro.props;
11
+ ---
12
+
13
+ <span
14
+ role="presentation"
15
+ aria-hidden="true"
16
+ class={breadcrumbEllipsis({ class: className })}
17
+ {...rest}
18
+ >
19
+ <Dots />
20
+ <span class="sr-only">More</span>
21
+ </span>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = HTMLAttributes<"li">;
6
+
7
+ const breadcrumbItem = tv({ base: "inline-flex items-center gap-1.5" });
8
+
9
+ const { class: className, ...rest } = Astro.props;
10
+ ---
11
+
12
+ <li class={breadcrumbItem({ class: className })} {...rest}>
13
+ <slot />
14
+ </li>
@@ -0,0 +1,20 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = HTMLAttributes<"a"> & {
6
+ asChild?: boolean;
7
+ };
8
+
9
+ const breadcrumbLink = tv({ base: "hover:text-foreground transition-colors" });
10
+
11
+ const { class: className, asChild = false, ...rest } = Astro.props;
12
+ ---
13
+
14
+ {asChild ? (
15
+ <slot />
16
+ ) : (
17
+ <a class={breadcrumbLink({ class: className })} {...rest}>
18
+ <slot />
19
+ </a>
20
+ )}
@@ -0,0 +1,16 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = HTMLAttributes<"ol">;
6
+
7
+ const breadcrumbList = tv({
8
+ base: "text-muted-foreground flex flex-wrap items-center gap-1.5 break-words sm:gap-2",
9
+ });
10
+
11
+ const { class: className, ...rest } = Astro.props;
12
+ ---
13
+
14
+ <ol class={breadcrumbList({ class: className })} {...rest}>
15
+ <slot />
16
+ </ol>
@@ -0,0 +1,20 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = HTMLAttributes<"span">;
6
+
7
+ const breadcrumbPage = tv({ base: "text-foreground font-normal" });
8
+
9
+ const { class: className, ...rest } = Astro.props;
10
+ ---
11
+
12
+ <span
13
+ role="link"
14
+ aria-disabled="true"
15
+ aria-current="page"
16
+ class={breadcrumbPage({ class: className })}
17
+ {...rest}
18
+ >
19
+ <slot />
20
+ </span>
@@ -0,0 +1,22 @@
1
+ ---
2
+ import ChevronRight from "@tabler/icons/outline/chevron-right.svg";
3
+ import type { HTMLAttributes } from "astro/types";
4
+ import { tv } from "tailwind-variants";
5
+
6
+ type Props = HTMLAttributes<"li">;
7
+
8
+ const breadcrumbSeparator = tv({ base: "[&>svg]:size-4" });
9
+
10
+ const { class: className, ...rest } = Astro.props;
11
+ ---
12
+
13
+ <li
14
+ role="presentation"
15
+ aria-hidden="true"
16
+ class={breadcrumbSeparator({ class: className })}
17
+ {...rest}
18
+ >
19
+ <slot>
20
+ <ChevronRight />
21
+ </slot>
22
+ </li>
@@ -0,0 +1,27 @@
1
+ import Breadcrumb from "./Breadcrumb.astro";
2
+ import BreadcrumbList from "./BreadcrumbList.astro";
3
+ import BreadcrumbEllipsis from "./BreadcrumbEllipsis.astro";
4
+ import BreadcrumbItem from "./BreadcrumbItem.astro";
5
+ import BreadcrumbLink from "./BreadcrumbLink.astro";
6
+ import BreadcrumbSeparator from "./BreadcrumbSeparator.astro";
7
+ import BreadcrumbPage from "./BreadcrumbPage.astro";
8
+
9
+ export {
10
+ Breadcrumb,
11
+ BreadcrumbList,
12
+ BreadcrumbEllipsis,
13
+ BreadcrumbItem,
14
+ BreadcrumbLink,
15
+ BreadcrumbSeparator,
16
+ BreadcrumbPage,
17
+ };
18
+
19
+ export default {
20
+ Root: Breadcrumb,
21
+ List: BreadcrumbList,
22
+ Ellipsis: BreadcrumbEllipsis,
23
+ Item: BreadcrumbItem,
24
+ Link: BreadcrumbLink,
25
+ Separator: BreadcrumbSeparator,
26
+ Page: BreadcrumbPage,
27
+ };
@@ -0,0 +1,343 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Props = HTMLAttributes<"div"> & {
5
+ name?: string;
6
+ /**
7
+ * When true, the dropdown will open on hover in addition to click
8
+ */
9
+ openOnHover?: boolean;
10
+ /**
11
+ * Time in milliseconds to wait before closing when hover open is enabled
12
+ * @default 200
13
+ */
14
+ closeDelay?: number;
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ children: any;
17
+ };
18
+
19
+ const { class: className, name, openOnHover = false, closeDelay = 200, ...rest } = Astro.props;
20
+ ---
21
+
22
+ <div
23
+ class:list={["starwind-dropdown", "relative", className]}
24
+ data-name={name}
25
+ data-open-on-hover={openOnHover ? "true" : undefined}
26
+ data-close-delay={closeDelay}
27
+ {...rest}
28
+ >
29
+ <slot />
30
+ </div>
31
+
32
+ <script>
33
+ class DropdownHandler {
34
+ private dropdown: HTMLElement;
35
+ private trigger: HTMLButtonElement | null;
36
+ private content: HTMLElement | null;
37
+ private items: HTMLElement[] = [];
38
+ private currentFocusIndex: number = -1;
39
+ private isOpen: boolean = false;
40
+ private animationDuration = 150;
41
+ private openOnHover: boolean;
42
+ private closeDelay: number;
43
+ private closeTimerRef: number | null = null;
44
+
45
+ constructor(dropdown: HTMLElement, dropdownIdx: number) {
46
+ this.dropdown = dropdown;
47
+ this.openOnHover = dropdown.getAttribute("data-open-on-hover") === "true";
48
+ this.closeDelay = parseInt(dropdown.getAttribute("data-close-delay") || "200");
49
+
50
+ // Get the temporary trigger element
51
+ const tempTrigger = dropdown.querySelector(".starwind-dropdown-trigger") as HTMLElement;
52
+
53
+ // if trigger is set with asChild, use the first child element for trigger button
54
+ if (tempTrigger?.hasAttribute("data-as-child")) {
55
+ this.trigger = tempTrigger.firstElementChild as HTMLButtonElement;
56
+ } else {
57
+ this.trigger = tempTrigger as HTMLButtonElement;
58
+ }
59
+
60
+ this.content = dropdown.querySelector(".starwind-dropdown-content");
61
+
62
+ if (!this.trigger || !this.content) return;
63
+
64
+ // Get animation duration from inline styles if available
65
+ const animationDurationString = this.content.style.animationDuration;
66
+ if (animationDurationString.endsWith("ms")) {
67
+ this.animationDuration = parseFloat(animationDurationString);
68
+ } else if (animationDurationString.endsWith("s")) {
69
+ this.animationDuration = parseFloat(animationDurationString) * 1000;
70
+ }
71
+
72
+ this.init(dropdownIdx);
73
+ }
74
+
75
+ private init(dropdownIdx: number) {
76
+ this.setupAccessibility(dropdownIdx);
77
+ this.setupEvents();
78
+ }
79
+
80
+ private setupAccessibility(dropdownIdx: number) {
81
+ if (!this.trigger || !this.content) return;
82
+
83
+ // Generate unique IDs for accessibility
84
+ this.trigger.id = `starwind-dropdown${dropdownIdx}-trigger`;
85
+ this.content.id = `starwind-dropdown${dropdownIdx}-content`;
86
+
87
+ // Set up additional ARIA attributes
88
+ this.trigger.setAttribute("aria-controls", this.content.id);
89
+ this.content.setAttribute("aria-labelledby", this.trigger.id);
90
+ }
91
+
92
+ private setupEvents() {
93
+ if (!this.trigger || !this.content) return;
94
+
95
+ // Handle trigger click
96
+ this.trigger.addEventListener("click", (e) => {
97
+ e.preventDefault();
98
+ this.toggleDropdown();
99
+ });
100
+
101
+ // Handle keyboard navigation
102
+ this.trigger.addEventListener("keydown", (e) => {
103
+ if (e.key === "Enter" || e.key === " ") {
104
+ e.preventDefault();
105
+ this.toggleDropdown();
106
+ } else if (e.key === "Escape" && this.isOpen) {
107
+ e.preventDefault();
108
+ this.closeDropdown();
109
+ } else if (this.isOpen && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
110
+ e.preventDefault();
111
+ this.updateDropdownItems();
112
+ if (e.key === "ArrowDown") {
113
+ this.focusItem(0); // Focus first item when opening with arrow down
114
+ } else {
115
+ this.focusItem(this.items.length - 1); // Focus last item when opening with arrow up
116
+ }
117
+ }
118
+ });
119
+
120
+ // Close dropdown when clicking outside
121
+ document.addEventListener("pointerdown", (e) => {
122
+ if (this.isOpen && !this.dropdown.contains(e.target as Node)) {
123
+ this.closeDropdown();
124
+ }
125
+ });
126
+
127
+ // Handle keyboard navigation and item selection within dropdown
128
+ this.content.addEventListener("keydown", (e) => {
129
+ if (e.key === "Escape") {
130
+ e.preventDefault();
131
+ this.closeDropdown();
132
+ this.trigger?.focus();
133
+ } else if (this.isOpen) {
134
+ this.handleMenuKeydown(e);
135
+ }
136
+ });
137
+
138
+ // Handle item selection
139
+ this.content.addEventListener("click", (e) => {
140
+ const target = e.target as HTMLElement;
141
+ const item = target.closest('[role="menuitem"]');
142
+ if (item && !(item as HTMLElement).hasAttribute("data-disabled")) {
143
+ // Close the dropdown after item selection
144
+ this.closeDropdown();
145
+ }
146
+ });
147
+
148
+ // Handle hover on dropdown items
149
+ this.content.addEventListener("mouseover", (e) => {
150
+ const target = e.target as HTMLElement;
151
+ const menuItem = target.closest('[role="menuitem"]');
152
+ if (menuItem && menuItem instanceof HTMLElement && this.isOpen === true) {
153
+ // Update items list before focusing to ensure the index is correct
154
+ this.updateDropdownItems();
155
+
156
+ // Focus the item when hovering
157
+ menuItem.focus();
158
+
159
+ // Update the current focus index
160
+ this.currentFocusIndex = this.items.indexOf(menuItem);
161
+ }
162
+ });
163
+
164
+ if (this.openOnHover) {
165
+ // Use mouseenter instead of mouseover to avoid flickering when moving between child elements
166
+ this.trigger.addEventListener("mouseenter", () => {
167
+ if (!this.isOpen) {
168
+ this.openDropdown();
169
+ } else {
170
+ // If the dropdown is already open, make sure to clear any close timer
171
+ this.clearCloseTimer();
172
+ }
173
+ });
174
+
175
+ // Use mouseleave instead of mouseout to only trigger when leaving the entire dropdown
176
+ this.dropdown.addEventListener("mouseleave", () => {
177
+ if (this.isOpen) {
178
+ this.closeDropdownDelayed();
179
+ }
180
+ });
181
+
182
+ // When content is available, also add mouseenter to it to cancel close timer
183
+ if (this.content) {
184
+ this.content.addEventListener("mouseenter", () => {
185
+ // If the user moves the mouse to the content, cancel the close timer
186
+ this.clearCloseTimer();
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ private handleMenuKeydown(e: KeyboardEvent) {
193
+ // Make sure we've got an updated list of menu items
194
+ this.updateDropdownItems();
195
+
196
+ // Skip if no items
197
+ if (this.items.length === 0) return;
198
+
199
+ const currentIdx = this.currentFocusIndex;
200
+
201
+ switch (e.key) {
202
+ case "ArrowDown":
203
+ e.preventDefault();
204
+ this.focusItem(currentIdx === -1 ? 0 : currentIdx + 1);
205
+ break;
206
+ case "ArrowUp":
207
+ e.preventDefault();
208
+ this.focusItem(currentIdx === -1 ? this.items.length - 1 : currentIdx - 1);
209
+ break;
210
+ case "Home":
211
+ e.preventDefault();
212
+ this.focusItem(0);
213
+ break;
214
+ case "End":
215
+ e.preventDefault();
216
+ this.focusItem(this.items.length - 1);
217
+ break;
218
+ case "Enter":
219
+ case " ":
220
+ if (currentIdx !== -1) {
221
+ e.preventDefault();
222
+ this.items[currentIdx].click();
223
+ }
224
+ break;
225
+ }
226
+ }
227
+
228
+ private updateDropdownItems() {
229
+ if (!this.content) return;
230
+ // Get all interactive menuitem elements
231
+ this.items = Array.from(
232
+ this.content.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])'),
233
+ ) as HTMLElement[];
234
+ }
235
+
236
+ private focusItem(idx: number) {
237
+ // Ensure the index wraps around properly
238
+ const targetIdx = (idx + this.items.length) % this.items.length;
239
+
240
+ if (this.items[targetIdx]) {
241
+ this.items[targetIdx].focus();
242
+ this.currentFocusIndex = targetIdx;
243
+ }
244
+ }
245
+
246
+ private toggleDropdown() {
247
+ if (this.isOpen) {
248
+ this.closeDropdown();
249
+ } else {
250
+ this.openDropdown();
251
+ }
252
+ }
253
+
254
+ private openDropdown() {
255
+ if (!this.content || !this.trigger || this.trigger.disabled) return;
256
+
257
+ this.isOpen = true;
258
+ this.content.setAttribute("data-state", "open");
259
+ this.trigger.setAttribute("aria-expanded", "true");
260
+ this.content.style.removeProperty("display");
261
+
262
+ // Update the list of dropdown items
263
+ this.updateDropdownItems();
264
+
265
+ // Reset focus index when opening
266
+ this.currentFocusIndex = -1;
267
+
268
+ this.positionContent();
269
+ }
270
+
271
+ private closeDropdown() {
272
+ if (!this.content || !this.trigger) return;
273
+
274
+ this.isOpen = false;
275
+ this.content.setAttribute("data-state", "closed");
276
+
277
+ // Set focus back on trigger
278
+ requestAnimationFrame(() => {
279
+ if (!this.trigger) return;
280
+ this.trigger.focus();
281
+ });
282
+
283
+ // Give the content time to animate before hiding
284
+ setTimeout(() => {
285
+ if (!this.content) return;
286
+ this.content.style.display = "none";
287
+ }, this.animationDuration - 10);
288
+
289
+ this.trigger.setAttribute("aria-expanded", "false");
290
+
291
+ // Reset focus index when closing
292
+ this.currentFocusIndex = -1;
293
+ }
294
+
295
+ private closeDropdownDelayed() {
296
+ if (!this.content || !this.trigger) return;
297
+
298
+ // Clear any existing close timer
299
+ this.clearCloseTimer();
300
+
301
+ // Set a new timer to close the dropdown after the delay
302
+ this.closeTimerRef = window.setTimeout(() => {
303
+ if (this.isOpen) {
304
+ this.closeDropdown();
305
+ }
306
+ this.closeTimerRef = null;
307
+ }, this.closeDelay);
308
+ }
309
+
310
+ private clearCloseTimer() {
311
+ if (this.closeTimerRef !== null) {
312
+ window.clearTimeout(this.closeTimerRef);
313
+ this.closeTimerRef = null;
314
+ }
315
+ }
316
+
317
+ private positionContent() {
318
+ if (!this.content || !this.trigger) return;
319
+
320
+ // Set content width to match trigger width
321
+ this.content.style.width = "var(--starwind-dropdown-trigger-width)";
322
+ this.content.style.setProperty(
323
+ "--starwind-dropdown-trigger-width",
324
+ `${this.trigger.offsetWidth}px`,
325
+ );
326
+ }
327
+ }
328
+
329
+ // Store instances in a WeakMap to avoid memory leaks
330
+ const dropdownInstances = new WeakMap<HTMLElement, DropdownHandler>();
331
+
332
+ // Initialize dropdowns
333
+ const initDropdowns = () => {
334
+ document.querySelectorAll(".starwind-dropdown").forEach((dropdown, idx) => {
335
+ if (dropdown instanceof HTMLElement && !dropdownInstances.has(dropdown)) {
336
+ dropdownInstances.set(dropdown, new DropdownHandler(dropdown, idx));
337
+ }
338
+ });
339
+ };
340
+
341
+ initDropdowns();
342
+ document.addEventListener("astro:after-swap", initDropdowns);
343
+ </script>
@@ -0,0 +1,80 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = HTMLAttributes<"div"> & {
6
+ /**
7
+ * Side of the dropdown
8
+ * @default bottom
9
+ */
10
+ side?: "top" | "bottom";
11
+ /**
12
+ * Alignment of the dropdown
13
+ * @default start
14
+ */
15
+ align?: "start" | "center" | "end";
16
+ /**
17
+ * Offset distance in pixels
18
+ * @default 4
19
+ */
20
+ sideOffset?: number;
21
+ /**
22
+ * Open and close animation duration in milliseconds
23
+ * @default 150
24
+ */
25
+ animationDuration?: number;
26
+ };
27
+
28
+ const dropdownContent = tv({
29
+ base: [
30
+ "starwind-dropdown-content",
31
+ "bg-popover text-popover-foreground z-50 min-w-[9rem] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
32
+ "animate-in fade-in-0 zoom-in-95",
33
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
34
+ "absolute will-change-transform",
35
+ ],
36
+ variants: {
37
+ side: {
38
+ bottom: "slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 top-full",
39
+ top: "slide-in-from-bottom-2 data-[state=closed]:slide-out-to-bottom-2 bottom-full",
40
+ },
41
+ align: {
42
+ start: "slide-in-from-left-1 data-[state=closed]:slide-out-to-left-1 left-0",
43
+ center: "left-1/2 -translate-x-1/2",
44
+ end: "slide-in-from-right-1 data-[state=closed]:slide-out-to-right-1 right-0",
45
+ },
46
+ },
47
+ defaultVariants: {
48
+ side: "bottom",
49
+ align: "start",
50
+ },
51
+ });
52
+
53
+ const {
54
+ class: className,
55
+ side = "bottom",
56
+ align = "start",
57
+ sideOffset = 4,
58
+ animationDuration = 150,
59
+ ...rest
60
+ } = Astro.props;
61
+ ---
62
+
63
+ <div
64
+ class={dropdownContent({ side, align, class: className })}
65
+ role="menu"
66
+ data-side={side}
67
+ data-align={align}
68
+ data-state="closed"
69
+ tabindex="-1"
70
+ aria-orientation="vertical"
71
+ style={{
72
+ display: "none",
73
+ animationDuration: `${animationDuration}ms`,
74
+ marginTop: side === "bottom" ? `${sideOffset}px` : undefined,
75
+ marginBottom: side === "top" ? `${sideOffset}px` : undefined,
76
+ }}
77
+ {...rest}
78
+ >
79
+ <slot />
80
+ </div>
@@ -0,0 +1,47 @@
1
+ ---
2
+ import type { HTMLTag, Polymorphic } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
6
+ /**
7
+ * Whether the item is inset (has left padding)
8
+ */
9
+ inset?: boolean;
10
+ /**
11
+ * Whether the item is disabled
12
+ */
13
+ disabled?: boolean;
14
+ };
15
+
16
+ const dropdownItem = tv({
17
+ base: [
18
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 transition-colors outline-none select-none",
19
+ "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
20
+ "[&>svg]:size-4 [&>svg]:shrink-0",
21
+ ],
22
+ variants: {
23
+ inset: {
24
+ true: "pl-8",
25
+ },
26
+ disabled: {
27
+ true: "pointer-events-none opacity-50",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ inset: false,
32
+ disabled: false,
33
+ },
34
+ });
35
+
36
+ const { class: className, inset = false, disabled = false, as: Tag = "div", ...rest } = Astro.props;
37
+ ---
38
+
39
+ <Tag
40
+ class={dropdownItem({ inset, disabled, class: className })}
41
+ role="menuitem"
42
+ tabindex={disabled ? "-1" : "0"}
43
+ data-disabled={disabled ? "true" : undefined}
44
+ {...rest}
45
+ >
46
+ <slot />
47
+ </Tag>
@@ -0,0 +1,29 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = HTMLAttributes<"div"> & {
6
+ /**
7
+ * Whether the label is inset (has left padding)
8
+ */
9
+ inset?: boolean;
10
+ };
11
+
12
+ const dropdownLabel = tv({
13
+ base: ["px-2 py-1.5 font-semibold"],
14
+ variants: {
15
+ inset: {
16
+ true: "pl-8",
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ inset: false,
21
+ },
22
+ });
23
+
24
+ const { class: className, inset = false, ...rest } = Astro.props;
25
+ ---
26
+
27
+ <div class={dropdownLabel({ inset, class: className })} {...rest}>
28
+ <slot />
29
+ </div>
@@ -0,0 +1,20 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = HTMLAttributes<"div">;
6
+
7
+ const dropdownSeparator = tv({
8
+ base: "bg-muted -mx-1 my-1 h-px",
9
+ });
10
+
11
+ const { class: className, ...rest } = Astro.props;
12
+ ---
13
+
14
+ <div
15
+ class={dropdownSeparator({ class: className })}
16
+ role="separator"
17
+ aria-orientation="horizontal"
18
+ {...rest}
19
+ >
20
+ </div>
@@ -0,0 +1,47 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ type Props = Omit<HTMLAttributes<"button">, "role" | "type"> & {
6
+ /**
7
+ * When true, the component will render its child element with a simple wrapper instead of a button component
8
+ */
9
+ asChild?: boolean;
10
+ };
11
+
12
+ const dropdownTrigger = tv({
13
+ base: [
14
+ "starwind-dropdown-trigger",
15
+ "inline-flex items-center justify-center",
16
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
17
+ ],
18
+ });
19
+
20
+ const { class: className, asChild = false, ...rest } = Astro.props;
21
+
22
+ // Get the first child element if asChild is true
23
+ let hasChildren = false;
24
+ if (Astro.slots.has("default")) {
25
+ hasChildren = true;
26
+ }
27
+ ---
28
+
29
+ {
30
+ asChild && hasChildren ? (
31
+ <div class="starwind-dropdown-trigger" data-as-child>
32
+ <slot />
33
+ </div>
34
+ ) : (
35
+ <button
36
+ class={dropdownTrigger({ class: className })}
37
+ type="button"
38
+ role="button"
39
+ aria-haspopup="true"
40
+ aria-expanded="false"
41
+ data-state="closed"
42
+ {...rest}
43
+ >
44
+ <slot />
45
+ </button>
46
+ )
47
+ }
@@ -0,0 +1,24 @@
1
+ import Dropdown from "./Dropdown.astro";
2
+ import DropdownTrigger from "./DropdownTrigger.astro";
3
+ import DropdownContent from "./DropdownContent.astro";
4
+ import DropdownItem from "./DropdownItem.astro";
5
+ import DropdownLabel from "./DropdownLabel.astro";
6
+ import DropdownSeparator from "./DropdownSeparator.astro";
7
+
8
+ export {
9
+ Dropdown,
10
+ DropdownTrigger,
11
+ DropdownContent,
12
+ DropdownItem,
13
+ DropdownLabel,
14
+ DropdownSeparator,
15
+ };
16
+
17
+ export default {
18
+ Root: Dropdown,
19
+ Trigger: DropdownTrigger,
20
+ Content: DropdownContent,
21
+ Item: DropdownItem,
22
+ Label: DropdownLabel,
23
+ Separator: DropdownSeparator,
24
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starwind-ui/core",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Starwind UI core components and registry",
5
5
  "license": "MIT",
6
6
  "author": {