@elysn/astro 0.0.2 → 0.0.4

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 (54) hide show
  1. package/LICENSE +201 -0
  2. package/index.ts +46 -0
  3. package/package.json +36 -16
  4. package/src/AccessibleIcon.astro +14 -0
  5. package/src/AspectRatio.astro +14 -0
  6. package/src/Avatar.astro +54 -0
  7. package/src/Badge.astro +34 -0
  8. package/src/Blockquote.astro +21 -0
  9. package/src/Box.astro +13 -0
  10. package/src/Button.astro +27 -8
  11. package/src/Callout.astro +34 -0
  12. package/src/CalloutIcon.astro +12 -0
  13. package/src/CalloutText.astro +12 -0
  14. package/src/Card.astro +30 -0
  15. package/src/Checkbox.astro +212 -0
  16. package/src/Code.astro +21 -0
  17. package/src/Container.astro +26 -0
  18. package/src/DecoratedBox.astro +22 -0
  19. package/src/Em.astro +10 -0
  20. package/src/Flex.astro +25 -0
  21. package/src/Grid.astro +21 -0
  22. package/src/Heading.astro +43 -0
  23. package/src/InputControl.astro +40 -0
  24. package/src/InputField.astro +10 -0
  25. package/src/InputSlot.astro +41 -0
  26. package/src/Kbd.astro +11 -0
  27. package/src/Link.astro +33 -0
  28. package/src/Panel.astro +12 -0
  29. package/src/Progress.astro +63 -0
  30. package/src/Quote.astro +10 -0
  31. package/src/RadioCards.astro +71 -0
  32. package/src/RadioCardsItem.astro +41 -0
  33. package/src/RadioGroup.astro +70 -0
  34. package/src/RadioGroupItem.astro +53 -0
  35. package/src/Section.astro +23 -0
  36. package/src/SegmentedControl.astro +189 -0
  37. package/src/Separator.astro +25 -0
  38. package/src/Skeleton.astro +30 -0
  39. package/src/Spinner.astro +31 -0
  40. package/src/Strong.astro +10 -0
  41. package/src/Switch.astro +164 -0
  42. package/src/Tabs.astro +179 -0
  43. package/src/TabsContent.astro +20 -0
  44. package/src/TabsIndicator.astro +17 -0
  45. package/src/TabsList.astro +12 -0
  46. package/src/TabsTrigger.astro +23 -0
  47. package/src/Text.astro +43 -0
  48. package/src/TextArea.astro +30 -0
  49. package/src/TextField.astro +70 -0
  50. package/src/VisuallyHidden.astro +10 -0
  51. package/src/internal/radio-group.ts +82 -0
  52. package/src/internal/ssr.ts +18 -0
  53. package/src/internal/zag.ts +24 -0
  54. package/style.css +5 -0
package/src/Tabs.astro ADDED
@@ -0,0 +1,179 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Size = "xs" | "sm" | "md" | "lg";
5
+
6
+ interface Props extends Omit<HTMLAttributes<"div">, "color" | "dir"> {
7
+ activationMode?: "automatic" | "manual";
8
+ className?: string;
9
+ color?: string;
10
+ defaultValue?: string;
11
+ dir?: "ltr" | "rtl";
12
+ highContrast?: boolean;
13
+ loopFocus?: boolean;
14
+ orientation?: "horizontal" | "vertical";
15
+ size?: Size;
16
+ }
17
+ const {
18
+ activationMode = "automatic",
19
+ className,
20
+ color,
21
+ defaultValue,
22
+ dir,
23
+ highContrast,
24
+ loopFocus = true,
25
+ orientation = "horizontal",
26
+ size = "md",
27
+ ...rest
28
+ } = Astro.props as Props;
29
+
30
+ const sizes: Record<Size, string> = {
31
+ xs: "el-Tabs-size-xs",
32
+ sm: "el-Tabs-size-sm",
33
+ md: "el-Tabs-size-md",
34
+ lg: "el-Tabs-size-lg",
35
+ };
36
+
37
+ // Pre-hydration state, CSS only. Astro has no parent -> child context, so TabsTrigger/TabsContent
38
+ // cannot know the default value at render time; instead each instance emits a tiny scoped
39
+ // <style> that hides every panel but the default one and colours the default trigger as
40
+ // selected. `data-ready` (set by <el-tabs> once Zag has started) switches it off.
41
+ // Caveats:
42
+ // - Needs `defaultValue`; without it all panels are visible until the script runs.
43
+ // - ARIA/ids/roving tabindex exist only after hydration (Zag applies them).
44
+ // - The inline <style> needs a CSP that allows inline styles.
45
+ // - Nested tabs are safe: selectors skip anything inside a nested <el-tabs>.
46
+ const uid = crypto.randomUUID().slice(0, 8);
47
+ const cssValue = defaultValue?.replaceAll(/["\\<]/gu, (c) => (c === "<" ? "\\3c " : `\\${c}`));
48
+ const scope = `el-tabs[data-uid="${uid}"]:not([data-ready])`;
49
+ const ssrCss =
50
+ cssValue === undefined
51
+ ? undefined
52
+ : `${scope} .el-Tabs-content:not([data-value="${cssValue}"]):not(el-tabs[data-uid="${uid}"] el-tabs .el-Tabs-content){display:none}` +
53
+ `${scope} .el-Tabs-trigger[data-value="${cssValue}"]:not(el-tabs[data-uid="${uid}"] el-tabs .el-Tabs-trigger){color:var(--gray-12)}`;
54
+ ---
55
+
56
+ <el-tabs
57
+ {...rest}
58
+ data-uid={uid}
59
+ data-accent-color={color}
60
+ data-activation-mode={activationMode}
61
+ data-default-value={defaultValue}
62
+ data-dir={dir}
63
+ data-high-contrast={highContrast ? "true" : undefined}
64
+ data-loop-focus={loopFocus ? "true" : "false"}
65
+ data-orientation={orientation}
66
+ class:list={["el-Tabs", sizes[size], className]}
67
+ >
68
+ <slot />
69
+ </el-tabs>
70
+ {ssrCss && <style is:inline set:html={ssrCss} />}
71
+
72
+ <script>
73
+ import * as tabs from "@zag-js/tabs";
74
+ import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla";
75
+
76
+ type Machine = ReturnType<typeof createMachine>;
77
+ const createMachine = (props: tabs.Props) =>
78
+ new VanillaMachine(tabs.machine, props);
79
+
80
+ let instances = 0;
81
+
82
+ class ElTabs extends HTMLElement {
83
+ #machine?: Machine;
84
+ #unsubscribe?: () => void;
85
+ #cleanups = new Map<Element, () => void>();
86
+ // Zag ids are derived from this; unique per connection so cloned markup never collides.
87
+ #id = "";
88
+
89
+ connectedCallback() {
90
+ // Guard against double init (the browser can call this again only after a disconnect)
91
+ if (this.#machine) return;
92
+ this.#id = `el-tabs-${++instances}`;
93
+ const firstEnabled = this.#parts("[data-part='trigger']:not([disabled])")[0];
94
+ const machine = createMachine({
95
+ id: this.#id,
96
+ defaultValue: this.dataset["defaultValue"] ?? firstEnabled?.dataset["value"],
97
+ orientation: this.dataset["orientation"] === "vertical" ? "vertical" : "horizontal",
98
+ activationMode: this.dataset["activationMode"] === "manual" ? "manual" : "automatic",
99
+ loopFocus: this.dataset["loopFocus"] !== "false",
100
+ dir:
101
+ this.dataset["dir"] === "rtl" || getComputedStyle(this).direction === "rtl"
102
+ ? "rtl"
103
+ : "ltr",
104
+ getRootNode: () => this.getRootNode(),
105
+ onValueChange: ({ value }: { value: string }) =>
106
+ this.dispatchEvent(
107
+ new CustomEvent("el-tabs:value-change", { detail: { value }, bubbles: true }),
108
+ ),
109
+ });
110
+ this.#machine = machine;
111
+ // Ids/attrs must be on the DOM before start(): the entry action measures the trigger
112
+ // (looked up by id) for the indicator.
113
+ this.#render();
114
+ machine.start();
115
+ this.#render();
116
+ this.#unsubscribe = machine.subscribe(() => this.#render());
117
+ this.dataset["ready"] = "";
118
+ }
119
+
120
+ disconnectedCallback() {
121
+ this.#unsubscribe?.();
122
+ this.#cleanups.forEach((fn) => fn());
123
+ this.#cleanups.clear();
124
+ this.#machine?.stop();
125
+ this.#machine = undefined;
126
+ delete this.dataset["ready"];
127
+ }
128
+
129
+ /** Programmatic API: `el.value` / `el.value = "b"`. */
130
+ get value() {
131
+ return this.#api()?.value ?? null;
132
+ }
133
+ set value(value: string | null) {
134
+ this.#api()?.setValue(value as string);
135
+ }
136
+
137
+ #api() {
138
+ return this.#machine && tabs.connect(this.#machine.service, normalizeProps);
139
+ }
140
+
141
+ /** Own parts only: skips parts of nested el-tabs. */
142
+ #parts(selector: string) {
143
+ return Array.from(this.querySelectorAll<HTMLElement>(selector)).filter(
144
+ (el) => el.closest("el-tabs") === this,
145
+ );
146
+ }
147
+
148
+ #render() {
149
+ const api = this.#api();
150
+ if (!api) return;
151
+ const apply = (el: Element | undefined, props: object) => {
152
+ if (!el) return;
153
+ this.#cleanups.set(
154
+ el,
155
+ spreadProps(el as HTMLElement, props as Record<string, unknown>, this.#id),
156
+ );
157
+ };
158
+ // Zag never looks the root up by id, so keep the author's `id` instead of its generated one
159
+ const { id: _id, ...rootProps } = api.getRootProps();
160
+ apply(this, rootProps);
161
+ apply(this.#parts("[data-part='list']")[0], api.getListProps());
162
+ for (const el of this.#parts("[data-part='trigger']")) {
163
+ apply(
164
+ el,
165
+ api.getTriggerProps({
166
+ value: el.dataset["value"] ?? "",
167
+ disabled: el.hasAttribute("disabled"),
168
+ }),
169
+ );
170
+ }
171
+ for (const el of this.#parts("[data-part='content']")) {
172
+ apply(el, api.getContentProps({ value: el.dataset["value"] ?? "" }));
173
+ }
174
+ apply(this.#parts("[data-part='indicator']")[0], api.getIndicatorProps());
175
+ }
176
+ }
177
+
178
+ if (!customElements.get("el-tabs")) customElements.define("el-tabs", ElTabs);
179
+ </script>
@@ -0,0 +1,20 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ interface Props extends HTMLAttributes<"div"> {
5
+ className?: string;
6
+ value: string;
7
+ }
8
+ const { className, value, ...rest } = Astro.props as Props;
9
+ ---
10
+
11
+ <div
12
+ {...rest}
13
+ role="tabpanel"
14
+ data-scope="tabs"
15
+ data-part="content"
16
+ data-value={value}
17
+ class:list={["el-Tabs-content", className]}
18
+ >
19
+ <slot />
20
+ </div>
@@ -0,0 +1,17 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ interface Props extends HTMLAttributes<"div"> {
5
+ className?: string;
6
+ }
7
+ const { className, ...rest } = Astro.props as Props;
8
+ ---
9
+
10
+ <div
11
+ {...rest}
12
+ data-scope="tabs"
13
+ data-part="indicator"
14
+ hidden
15
+ class:list={["el-Tabs-indicator", className]}
16
+ >
17
+ </div>
@@ -0,0 +1,12 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ interface Props extends HTMLAttributes<"div"> {
5
+ className?: string;
6
+ }
7
+ const { className, ...rest } = Astro.props as Props;
8
+ ---
9
+
10
+ <div {...rest} role="tablist" data-scope="tabs" data-part="list" class:list={["el-Tabs-list", className]}>
11
+ <slot />
12
+ </div>
@@ -0,0 +1,23 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ interface Props extends Omit<HTMLAttributes<"button">, "value"> {
5
+ className?: string;
6
+ disabled?: boolean;
7
+ value: string;
8
+ }
9
+ const { className, disabled, value, ...rest } = Astro.props as Props;
10
+ ---
11
+
12
+ <button
13
+ {...rest}
14
+ role="tab"
15
+ type="button"
16
+ disabled={disabled}
17
+ data-scope="tabs"
18
+ data-part="trigger"
19
+ data-value={value}
20
+ class:list={["el-Tabs-trigger", className]}
21
+ >
22
+ <slot />
23
+ </button>
package/src/Text.astro ADDED
@@ -0,0 +1,43 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ interface Props extends Omit<HTMLAttributes<"p">, "color"> {
5
+ align?: "left" | "center" | "right";
6
+ as?: "p" | "span" | "div" | "label" | "b" | "i" | "s" | "u";
7
+ className?: string;
8
+ color?: string;
9
+ highContrast?: boolean;
10
+ size?: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
11
+ trim?: "normal" | "start" | "end" | "both";
12
+ truncate?: boolean;
13
+ weight?: "light" | "regular" | "medium" | "bold";
14
+ wrap?: "normal" | "nowrap" | "pretty" | "balance";
15
+ }
16
+ const {
17
+ align,
18
+ as: Tag = "p",
19
+ className,
20
+ color,
21
+ highContrast,
22
+ size,
23
+ trim,
24
+ truncate,
25
+ weight,
26
+ wrap,
27
+ ...rest
28
+ } = Astro.props as Props;
29
+ ---
30
+
31
+ <Tag
32
+ {...rest}
33
+ data-accent-color={color}
34
+ data-align={align}
35
+ data-high-contrast={highContrast ? true : undefined}
36
+ data-size={size}
37
+ data-trim={trim}
38
+ data-weight={weight}
39
+ data-wrap={wrap}
40
+ class:list={["el-Text", truncate && "el-Text-truncate", className]}
41
+ >
42
+ <slot />
43
+ </Tag>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Size = "xs" | "sm" | "md" | "lg";
5
+
6
+ interface Props extends Omit<HTMLAttributes<"textarea">, "color"> {
7
+ className?: string;
8
+ color?: string;
9
+ resize?: "none" | "vertical" | "horizontal" | "both";
10
+ size?: Size;
11
+ variant?: "soft" | "surface" | "outline";
12
+ }
13
+ const { className, color, resize = "vertical", size = "md", variant = "surface", ...rest } =
14
+ Astro.props as Props;
15
+
16
+ const sizes: Record<Size, string> = {
17
+ xs: "el-TextArea-size-xs",
18
+ sm: "el-TextArea-size-sm",
19
+ md: "el-TextArea-size-md",
20
+ lg: "el-TextArea-size-lg",
21
+ };
22
+ ---
23
+
24
+ <textarea
25
+ {...rest}
26
+ data-accent-color={color}
27
+ data-resize={resize}
28
+ data-variant={variant}
29
+ class:list={["el-TextArea", sizes[size], className]}><slot /></textarea
30
+ >
@@ -0,0 +1,70 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Size = "xs" | "sm" | "md" | "lg" | "xl";
5
+
6
+ interface Props extends Omit<HTMLAttributes<"div">, "color"> {
7
+ className?: string;
8
+ color?: string;
9
+ disabled?: boolean;
10
+ errorText?: string;
11
+ helperText?: string;
12
+ invalid?: boolean;
13
+ label?: string;
14
+ required?: boolean;
15
+ size?: Size;
16
+ variant?: "surface" | "soft";
17
+ }
18
+ const {
19
+ className,
20
+ color,
21
+ disabled,
22
+ errorText,
23
+ helperText,
24
+ invalid,
25
+ label,
26
+ required,
27
+ size = "md",
28
+ variant = "surface",
29
+ ...rest
30
+ } = Astro.props as Props;
31
+
32
+ const sizes: Record<Size, string> = {
33
+ xs: "el-TextField-size-xs",
34
+ sm: "el-TextField-size-sm",
35
+ md: "el-TextField-size-md",
36
+ lg: "el-TextField-size-lg",
37
+ xl: "el-TextField-size-xl",
38
+ };
39
+ ---
40
+
41
+ <div
42
+ {...rest}
43
+ data-accent-color={color}
44
+ data-disabled={disabled || undefined}
45
+ data-invalid={invalid || undefined}
46
+ data-required={required ? true : undefined}
47
+ data-variant={variant}
48
+ class:list={["el-TextField", sizes[size], className]}
49
+ >
50
+ {
51
+ label && (
52
+ <label class="el-TextField-label">
53
+ {label}
54
+ {required && (
55
+ <span aria-hidden="true" class="el-TextField-required">
56
+ *
57
+ </span>
58
+ )}
59
+ </label>
60
+ )
61
+ }
62
+ <div class="el-TextField-control"><slot /></div>
63
+ {
64
+ invalid && errorText ? (
65
+ <p class="el-TextField-error">{errorText}</p>
66
+ ) : helperText ? (
67
+ <p class="el-TextField-helper">{helperText}</p>
68
+ ) : null
69
+ }
70
+ </div>
@@ -0,0 +1,10 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ interface Props extends HTMLAttributes<"span"> {
5
+ className?: string;
6
+ }
7
+ const { className, ...rest } = Astro.props as Props;
8
+ ---
9
+
10
+ <span {...rest} class:list={["el-VisuallyHidden", className]}><slot /></span>
@@ -0,0 +1,82 @@
1
+ import * as zradio from "@zag-js/radio-group";
2
+ import { VanillaMachine, normalizeProps } from "@zag-js/vanilla";
3
+ import { createSpreader, nextId, textDirection } from "./zag";
4
+
5
+ type Machine = ReturnType<typeof createMachine>;
6
+ const createMachine = (props: zradio.Props) => new VanillaMachine(zradio.machine, props);
7
+
8
+ class ElRadioGroup extends HTMLElement {
9
+ #machine?: Machine;
10
+ #unsubscribe?: () => void;
11
+ #spread = createSpreader("");
12
+
13
+ connectedCallback() {
14
+ if (this.#machine) return;
15
+ const id = nextId("el-radio-group");
16
+ this.#spread = createSpreader(id);
17
+ const machine = createMachine({
18
+ id,
19
+ defaultValue: this.dataset["defaultValue"] ?? null,
20
+ name: this.dataset["name"],
21
+ disabled: this.hasAttribute("data-disabled"),
22
+ required: this.dataset["required"] === "true",
23
+ orientation: this.dataset["orientation"] === "horizontal" ? "horizontal" : "vertical",
24
+ dir: textDirection(this),
25
+ getRootNode: () => this.getRootNode(),
26
+ onValueChange: ({ value }: { value: string | null }) =>
27
+ this.dispatchEvent(
28
+ new CustomEvent("el-radio-group:value-change", { detail: { value }, bubbles: true }),
29
+ ),
30
+ });
31
+ this.#machine = machine;
32
+ machine.start();
33
+ this.#render();
34
+ this.#unsubscribe = machine.subscribe(() => this.#render());
35
+ this.dataset["ready"] = "";
36
+ }
37
+
38
+ disconnectedCallback() {
39
+ this.#unsubscribe?.();
40
+ this.#spread.clear();
41
+ this.#machine?.stop();
42
+ this.#machine = undefined;
43
+ delete this.dataset["ready"];
44
+ }
45
+
46
+ /** Programmatic API: `el.value` / `el.value = "b"`. */
47
+ get value() {
48
+ return this.#api()?.value ?? null;
49
+ }
50
+ set value(value: string | null) {
51
+ this.#api()?.setValue(value as string);
52
+ }
53
+
54
+ #api() {
55
+ return this.#machine && zradio.connect(this.#machine.service, normalizeProps);
56
+ }
57
+
58
+ #render() {
59
+ const api = this.#api();
60
+ if (!api) return;
61
+ // Keep the author's `id`, and `role`/`dir` come from the markup
62
+ const { id: _id, ...rootProps } = api.getRootProps();
63
+ this.#spread.apply(this, rootProps);
64
+ for (const item of this.querySelectorAll<HTMLElement>("[data-part='item']")) {
65
+ if (item.closest("el-radio-group") !== this) continue;
66
+ const props = {
67
+ value: item.dataset["itemValue"] ?? "",
68
+ disabled: item.hasAttribute("data-item-disabled"),
69
+ };
70
+ const part = (name: string) => item.querySelector<HTMLElement>(`[data-part='${name}']`);
71
+ this.#spread.apply(item, api.getItemProps(props));
72
+ this.#spread.apply(part("item-text"), api.getItemTextProps(props));
73
+ this.#spread.apply(part("item-control"), api.getItemControlProps(props));
74
+ this.#spread.apply(part("hidden-input"), api.getItemHiddenInputProps(props));
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Registers `<el-radio-group>`. Called explicitly: the package is `sideEffects: false`. */
80
+ export function defineRadioGroup() {
81
+ if (!customElements.get("el-radio-group")) customElements.define("el-radio-group", ElRadioGroup);
82
+ }
@@ -0,0 +1,18 @@
1
+ const escapeAttr = (value: string) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
2
+
3
+ /**
4
+ * Pre-hydration state for item-based controls. Astro has no parent -> child context, so items
5
+ * always render unchecked and the root patches the rendered HTML: every tag carrying
6
+ * `data-item-value="<value>"` becomes checked (and its hidden input gets `checked`).
7
+ */
8
+ export function markChecked(html: string, value: string | undefined) {
9
+ if (value === undefined) return html;
10
+ const needle = `data-item-value="${escapeAttr(value)}"`;
11
+ return html.replaceAll(/<[^<>]*>/gu, (tag) => {
12
+ if (!tag.includes(needle)) return tag;
13
+ const next = tag.replace('data-state="unchecked"', 'data-state="checked"');
14
+ return tag.includes('data-part="hidden-input"')
15
+ ? next.replace("<input", "<input checked")
16
+ : next;
17
+ });
18
+ }
@@ -0,0 +1,24 @@
1
+ import { spreadProps } from "@zag-js/vanilla";
2
+
3
+ let instances = 0;
4
+
5
+ /** Unique per connection so cloned markup never collides on Zag-derived ids. */
6
+ export const nextId = (prefix: string) => `${prefix}-${++instances}`;
7
+
8
+ /** Applies Zag prop objects to DOM elements and tracks the cleanups for `disconnectedCallback`. */
9
+ export function createSpreader(id: string) {
10
+ const cleanups = new Map<Element, () => void>();
11
+ return {
12
+ apply(el: Element | null | undefined, props: object) {
13
+ if (!el) return;
14
+ cleanups.set(el, spreadProps(el as HTMLElement, props as Record<string, unknown>, id));
15
+ },
16
+ clear() {
17
+ cleanups.forEach((fn) => fn());
18
+ cleanups.clear();
19
+ },
20
+ };
21
+ }
22
+
23
+ export const textDirection = (el: Element) =>
24
+ el.getAttribute("data-dir") === "rtl" || getComputedStyle(el).direction === "rtl" ? "rtl" : "ltr";
package/style.css ADDED
@@ -0,0 +1,5 @@
1
+ /* Usage: `@import "@elysn/astro";` after `@import "tailwindcss";` */
2
+ @import '@elysn/theme';
3
+
4
+ /* Lets Tailwind generate the utility classes used by these components */
5
+ @source "./src";