@grove-dev/astro 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/ui/Badge.astro +38 -0
- package/src/ui/Button.astro +42 -0
- package/src/ui/EmptyState.astro +38 -0
- package/src/ui/FilterDrawer.astro +104 -0
- package/src/ui/PageHeader.astro +39 -0
- package/src/ui/SearchField.astro +50 -0
- package/src/ui/button.test.ts +54 -0
- package/src/ui/button.ts +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grove-dev/astro",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Composable Astro UI, data adapters, and integration for Grove directories.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"src/layouts",
|
|
35
35
|
"src/lib",
|
|
36
36
|
"src/server",
|
|
37
|
+
"src/ui",
|
|
37
38
|
"src/index.ts",
|
|
38
39
|
"src/styles.css",
|
|
39
40
|
"README.md"
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"sanitize-html": "^2.17.5",
|
|
60
61
|
"shiki": "^1.29.2",
|
|
61
62
|
"yaml": "^2.9.0",
|
|
62
|
-
"@grove-dev/core": "0.5.
|
|
63
|
+
"@grove-dev/core": "0.5.1"
|
|
63
64
|
},
|
|
64
65
|
"peerDependencies": {
|
|
65
66
|
"astro": "^6.0.0 || ^7.0.0"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Badge — small labeled state indicator.
|
|
4
|
+
*
|
|
5
|
+
* Variant is the MEANING, not the color:
|
|
6
|
+
* - `neutral` — plain metadata (counts, kinds).
|
|
7
|
+
* - `selected` — "you are here" (neutral inversion, never green).
|
|
8
|
+
* - `success` / `warning` / `danger` / `info` — actual status. These
|
|
9
|
+
* use the status tokens, so green here means "healthy/succeeded",
|
|
10
|
+
* not "brand" and not "selected".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type BadgeVariant = "neutral" | "selected" | "success" | "warning" | "danger" | "info";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
variant?: BadgeVariant;
|
|
17
|
+
class?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const VARIANTS: Record<BadgeVariant, string> = {
|
|
22
|
+
neutral: "border-border bg-secondary text-secondary-foreground",
|
|
23
|
+
selected: "border-transparent bg-selected text-selected-foreground",
|
|
24
|
+
success: "border-transparent bg-success text-success-foreground",
|
|
25
|
+
warning: "border-transparent bg-warning text-warning-foreground",
|
|
26
|
+
danger: "border-transparent bg-danger text-danger-foreground",
|
|
27
|
+
info: "border-transparent bg-info text-info-foreground",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const { variant = "neutral", class: className = "", ...rest } = Astro.props;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<span
|
|
34
|
+
class={`inline-flex min-h-5 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium leading-none ${VARIANTS[variant]} ${className}`}
|
|
35
|
+
{...rest}
|
|
36
|
+
>
|
|
37
|
+
<slot />
|
|
38
|
+
</span>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Button — the shared interactive control.
|
|
4
|
+
*
|
|
5
|
+
* Renders an `<a>` when `href` is given, otherwise a `<button>`.
|
|
6
|
+
* Styling comes exclusively from `buttonClass()` (ui/button.ts) so
|
|
7
|
+
* server components, consumer pages, and the client script share one
|
|
8
|
+
* definition. Pass layout-only extras (widths, one-off spacing)
|
|
9
|
+
* through `class`.
|
|
10
|
+
*/
|
|
11
|
+
import { buttonClass, type ButtonSize, type ButtonVariant } from "./button.js";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
variant?: ButtonVariant;
|
|
15
|
+
size?: ButtonSize;
|
|
16
|
+
href?: string;
|
|
17
|
+
type?: "button" | "submit" | "reset";
|
|
18
|
+
rel?: string;
|
|
19
|
+
target?: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
id?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
variant = "secondary",
|
|
28
|
+
size = "sm",
|
|
29
|
+
href,
|
|
30
|
+
type = "button",
|
|
31
|
+
class: className = "",
|
|
32
|
+
...rest
|
|
33
|
+
} = Astro.props;
|
|
34
|
+
|
|
35
|
+
const classes = buttonClass(variant, size, className);
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
{href ? (
|
|
39
|
+
<a href={href} class={classes} {...rest}><slot /></a>
|
|
40
|
+
) : (
|
|
41
|
+
<button type={type} class={classes} {...rest}><slot /></button>
|
|
42
|
+
)}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* EmptyState — a deliberate "nothing here" block that cannot be
|
|
4
|
+
* mistaken for normal content. Explains WHY the area is empty and
|
|
5
|
+
* offers a recovery action.
|
|
6
|
+
*
|
|
7
|
+
* Marked with `data-grove-empty-state` so quality gates can assert
|
|
8
|
+
* that empty regions render as empty states, not silent gaps.
|
|
9
|
+
*/
|
|
10
|
+
import { buttonClass, type ButtonVariant } from "./button.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title: string;
|
|
14
|
+
/** Why it's empty / what will make it non-empty. */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Recovery action (rendered as a button-styled link). */
|
|
17
|
+
action?: { label: string; href: string; variant?: ButtonVariant };
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { title, description, action, class: className = "" } = Astro.props;
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<div
|
|
25
|
+
data-grove-empty-state
|
|
26
|
+
class={`flex flex-col items-center gap-2 rounded-[calc(var(--radius)+0.25rem)] border border-dashed border-border bg-surface-sunken px-6 py-10 text-center ${className}`}
|
|
27
|
+
>
|
|
28
|
+
<p class="m-0 text-sm font-medium text-foreground">{title}</p>
|
|
29
|
+
{description && (
|
|
30
|
+
<p class="m-0 max-w-[52ch] text-sm leading-relaxed text-muted-foreground">{description}</p>
|
|
31
|
+
)}
|
|
32
|
+
<slot />
|
|
33
|
+
{action && (
|
|
34
|
+
<a href={action.href} class={buttonClass(action.variant ?? "secondary", "sm", "mt-2")}>
|
|
35
|
+
{action.label}
|
|
36
|
+
</a>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FilterDrawer — mobile filter surface built on native `<dialog>`.
|
|
4
|
+
*
|
|
5
|
+
* `showModal()` gives focus trapping, Escape handling, and the
|
|
6
|
+
* top-layer backdrop for free — no dependency, no hand-rolled trap.
|
|
7
|
+
* On viewports ≥ md the trigger button hides and the slotted filter
|
|
8
|
+
* controls render inline through the same slot used on desktop, so
|
|
9
|
+
* there is exactly one copy of the filter markup.
|
|
10
|
+
*
|
|
11
|
+
* No-JS fallback: the slot content stays in normal flow (the dialog
|
|
12
|
+
* only wraps it once the client script moves it), so filters remain
|
|
13
|
+
* usable without JavaScript.
|
|
14
|
+
*/
|
|
15
|
+
import { buttonClass } from "./button.js";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Trigger button label. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Count of active filters shown on the trigger. */
|
|
21
|
+
activeCount?: number;
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { label = "Filters", activeCount = 0, class: className = "" } = Astro.props;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<div class={`grove-filter-drawer ${className}`} data-grove-filter-drawer>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
data-drawer-open
|
|
32
|
+
class={buttonClass("secondary", "md", "w-full md:hidden")}
|
|
33
|
+
aria-haspopup="dialog"
|
|
34
|
+
>
|
|
35
|
+
{label}
|
|
36
|
+
{/* Always in the DOM (hidden at zero) so the client script can
|
|
37
|
+
update it from the live URL state on this prerendered page. */}
|
|
38
|
+
<span
|
|
39
|
+
data-drawer-count
|
|
40
|
+
class:list={[
|
|
41
|
+
"inline-flex min-w-5 items-center justify-center rounded-full bg-selected px-1.5 py-0.5 font-mono text-2xs leading-none text-selected-foreground",
|
|
42
|
+
activeCount === 0 && "hidden",
|
|
43
|
+
]}
|
|
44
|
+
>
|
|
45
|
+
{activeCount}
|
|
46
|
+
</span>
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
{/* On mobile the client script moves this content into the dialog;
|
|
50
|
+
on ≥ md it stays inline. Not hidden by CSS — without JS the
|
|
51
|
+
filters must remain visible in flow on every viewport. */}
|
|
52
|
+
<div data-drawer-content class="md:contents">
|
|
53
|
+
<slot />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<dialog
|
|
57
|
+
data-drawer-dialog
|
|
58
|
+
class="m-0 mt-auto w-full max-w-none rounded-t-[calc(var(--radius)+0.5rem)] border-0 bg-surface-overlay p-0 text-foreground backdrop:bg-black/40 md:hidden"
|
|
59
|
+
aria-label={label}
|
|
60
|
+
>
|
|
61
|
+
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
|
62
|
+
<p class="m-0 text-sm font-semibold">{label}</p>
|
|
63
|
+
<button type="button" data-drawer-close class={buttonClass("ghost", "sm")}>
|
|
64
|
+
Done
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div data-drawer-body class="max-h-[70vh] overflow-y-auto p-4"></div>
|
|
68
|
+
</dialog>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<script>
|
|
72
|
+
document.querySelectorAll<HTMLElement>("[data-grove-filter-drawer]").forEach((root) => {
|
|
73
|
+
const dialog = root.querySelector<HTMLDialogElement>("[data-drawer-dialog]");
|
|
74
|
+
const content = root.querySelector<HTMLElement>("[data-drawer-content]");
|
|
75
|
+
const body = root.querySelector<HTMLElement>("[data-drawer-body]");
|
|
76
|
+
const open = root.querySelector<HTMLButtonElement>("[data-drawer-open]");
|
|
77
|
+
const close = root.querySelector<HTMLButtonElement>("[data-drawer-close]");
|
|
78
|
+
if (!dialog || !content || !body || !open) return;
|
|
79
|
+
|
|
80
|
+
const mobile = window.matchMedia("(max-width: 47.999rem)");
|
|
81
|
+
const place = () => {
|
|
82
|
+
// One copy of the filter markup, moved between the inline slot
|
|
83
|
+
// (desktop) and the dialog body (mobile).
|
|
84
|
+
if (mobile.matches) {
|
|
85
|
+
body.append(...content.children);
|
|
86
|
+
content.classList.add("hidden");
|
|
87
|
+
} else {
|
|
88
|
+
if (dialog.open) dialog.close();
|
|
89
|
+
content.append(...body.children);
|
|
90
|
+
content.classList.remove("hidden");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
place();
|
|
94
|
+
mobile.addEventListener("change", place);
|
|
95
|
+
|
|
96
|
+
open.addEventListener("click", () => dialog.showModal());
|
|
97
|
+
close?.addEventListener("click", () => dialog.close());
|
|
98
|
+
dialog.addEventListener("click", (event) => {
|
|
99
|
+
// Backdrop click closes: the dialog element itself is the
|
|
100
|
+
// target only when the click lands outside the panel content.
|
|
101
|
+
if (event.target === dialog) dialog.close();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PageHeader — the page's title block. Always renders a semantic
|
|
4
|
+
* heading (h1 by default — every route needs exactly one) with an
|
|
5
|
+
* optional eyebrow and description.
|
|
6
|
+
*
|
|
7
|
+
* For section headings INSIDE a page keep using `SectionHeader`
|
|
8
|
+
* (layouts/), which defaults to h2/h3.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
eyebrow?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Escape hatch for embedded use; the default 1 is the point. */
|
|
16
|
+
level?: 1 | 2;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { eyebrow, title, description, level = 1, class: className = "" } = Astro.props;
|
|
21
|
+
const Heading = `h${level}` as "h1" | "h2";
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<header class={`flex flex-col gap-1 ${className}`}>
|
|
25
|
+
{eyebrow && (
|
|
26
|
+
<span class="text-2xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
27
|
+
{eyebrow}
|
|
28
|
+
</span>
|
|
29
|
+
)}
|
|
30
|
+
<Heading class="m-0 text-[1.875rem] font-semibold tracking-tight text-foreground sm:text-[2.125rem]">
|
|
31
|
+
{title}
|
|
32
|
+
</Heading>
|
|
33
|
+
{description && (
|
|
34
|
+
<p class="m-0 mt-1 max-w-[64ch] text-[0.9375rem] leading-relaxed text-muted-foreground">
|
|
35
|
+
{description}
|
|
36
|
+
</p>
|
|
37
|
+
)}
|
|
38
|
+
<slot />
|
|
39
|
+
</header>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* SearchField — labeled search input with the leading glass icon.
|
|
4
|
+
*
|
|
5
|
+
* The label is visually hidden but always present; placeholder text
|
|
6
|
+
* is display copy, not the accessible name. Pass the derived
|
|
7
|
+
* placeholder from `getDirectoryIndexModel().searchPlaceholder` so it
|
|
8
|
+
* only names ENABLED facets.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
label: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
id?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
label,
|
|
22
|
+
name = "q",
|
|
23
|
+
id = "search-input",
|
|
24
|
+
value = "",
|
|
25
|
+
placeholder,
|
|
26
|
+
class: className = "",
|
|
27
|
+
} = Astro.props;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<label class={`relative block ${className}`}>
|
|
31
|
+
<span class="sr-only">{label}</span>
|
|
32
|
+
<svg
|
|
33
|
+
viewBox="0 0 16 16"
|
|
34
|
+
width="16"
|
|
35
|
+
height="16"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
38
|
+
fill="currentColor"
|
|
39
|
+
>
|
|
40
|
+
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
|
|
41
|
+
</svg>
|
|
42
|
+
<input
|
|
43
|
+
id={id}
|
|
44
|
+
type="search"
|
|
45
|
+
name={name}
|
|
46
|
+
value={value}
|
|
47
|
+
placeholder={placeholder}
|
|
48
|
+
class="h-10 w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20"
|
|
49
|
+
/>
|
|
50
|
+
</label>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buttonClass, chipClass, filterTriggerClass, lensTabClass } from "./button.js";
|
|
5
|
+
|
|
6
|
+
describe("button class builders", () => {
|
|
7
|
+
it("keeps selection state on the selected tokens, never the brand primary", () => {
|
|
8
|
+
for (const cls of [
|
|
9
|
+
buttonClass("selected"),
|
|
10
|
+
filterTriggerClass(true),
|
|
11
|
+
lensTabClass(true),
|
|
12
|
+
]) {
|
|
13
|
+
expect(cls).toContain("bg-selected");
|
|
14
|
+
expect(cls).not.toContain("bg-primary");
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses the brand primary only for the primary variant", () => {
|
|
19
|
+
expect(buttonClass("primary")).toContain("bg-primary");
|
|
20
|
+
expect(buttonClass("secondary")).not.toContain("bg-primary");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("guarantees a 44px mobile hit target on md and lg sizes", () => {
|
|
24
|
+
expect(buttonClass("secondary", "md")).toContain("max-sm:min-h-11");
|
|
25
|
+
expect(buttonClass("secondary", "lg")).toContain("h-11");
|
|
26
|
+
expect(filterTriggerClass(false)).toContain("max-sm:min-h-11");
|
|
27
|
+
expect(lensTabClass(false)).toContain("max-sm:min-h-11");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps the filter-trigger hook class through client reassignment", () => {
|
|
31
|
+
expect(filterTriggerClass(true)).toContain("grove-filter-trigger");
|
|
32
|
+
expect(filterTriggerClass(false)).toContain("grove-filter-trigger");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is the single source for chip classes on both server and client", () => {
|
|
36
|
+
// The browse page (server) and DirectoryIndexClient (client JS)
|
|
37
|
+
// must render byte-identical chips. Both now import chipClass —
|
|
38
|
+
// assert neither has regressed to an inline copy of the string.
|
|
39
|
+
const clientSource = readFileSync(
|
|
40
|
+
resolve(import.meta.dirname, "../components/DirectoryIndexClient.astro"),
|
|
41
|
+
"utf8",
|
|
42
|
+
);
|
|
43
|
+
const serverPage = readFileSync(
|
|
44
|
+
resolve(import.meta.dirname, "../../../../apps/example/src/pages/[slug]/index.astro"),
|
|
45
|
+
"utf8",
|
|
46
|
+
);
|
|
47
|
+
expect(clientSource).toContain("chipClass()");
|
|
48
|
+
expect(serverPage).toContain("chipClass()");
|
|
49
|
+
const inlineChip = "min-h-5 items-center justify-center gap-1 rounded-full";
|
|
50
|
+
expect(clientSource).not.toContain(inlineChip);
|
|
51
|
+
expect(serverPage).not.toContain(inlineChip);
|
|
52
|
+
expect(chipClass()).toContain(inlineChip);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/ui/button.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button / chip class builders — the single source for interactive
|
|
3
|
+
* control styling.
|
|
4
|
+
*
|
|
5
|
+
* Plain TS (not an .astro component) on purpose: `DirectoryIndexClient`
|
|
6
|
+
* rebuilds pagination links and active-filter chips in CLIENT-side JS,
|
|
7
|
+
* and those class strings must stay byte-identical with the server
|
|
8
|
+
* render. Both sides import these builders, so they cannot drift.
|
|
9
|
+
*
|
|
10
|
+
* Variant semantics (see styles.css):
|
|
11
|
+
* - `primary` — the brand action (config `theme.primaryColor`).
|
|
12
|
+
* - `secondary` — default chrome buttons.
|
|
13
|
+
* - `outline` / `ghost` — quieter chrome.
|
|
14
|
+
* - `selected` — "you are here" state (active filter, current lens,
|
|
15
|
+
* current page). Neutral inversion via the `selected` tokens —
|
|
16
|
+
* deliberately NOT the brand primary, so green never doubles as a
|
|
17
|
+
* selection indicator.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "selected";
|
|
21
|
+
/** `bare` emits no size classes — pass custom geometry via `extra`. */
|
|
22
|
+
export type ButtonSize = "sm" | "md" | "lg" | "bare";
|
|
23
|
+
|
|
24
|
+
const BASE =
|
|
25
|
+
"inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `md`/`lg` guarantee a ≥44px hit target on small viewports
|
|
29
|
+
* (`max-sm:min-h-11`) while keeping the compact desktop height.
|
|
30
|
+
*/
|
|
31
|
+
const SIZES: Record<ButtonSize, string> = {
|
|
32
|
+
sm: "h-8 px-3 text-sm",
|
|
33
|
+
md: "h-9 px-3 text-sm max-sm:min-h-11",
|
|
34
|
+
lg: "h-11 px-4 text-sm",
|
|
35
|
+
bare: "",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const VARIANTS: Record<ButtonVariant, string> = {
|
|
39
|
+
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
40
|
+
secondary: "border-border bg-secondary text-foreground hover:bg-accent",
|
|
41
|
+
outline: "border-border bg-transparent text-foreground hover:bg-accent",
|
|
42
|
+
ghost: "text-foreground hover:bg-accent",
|
|
43
|
+
selected: "bg-selected text-selected-foreground hover:bg-selected/90",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function buttonClass(
|
|
47
|
+
variant: ButtonVariant = "secondary",
|
|
48
|
+
size: ButtonSize = "sm",
|
|
49
|
+
extra = "",
|
|
50
|
+
): string {
|
|
51
|
+
return [BASE, SIZES[size], VARIANTS[variant], extra].filter(Boolean).join(" ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Layout extras for pagination controls — shared by `Pagination.astro`
|
|
56
|
+
* and the client-side pagination rebuild in `DirectoryIndexClient`.
|
|
57
|
+
*/
|
|
58
|
+
export const PAGINATION_EXTRA = "min-w-9 px-2 py-1.5 text-2xs";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Facet filter trigger (the "Stack: Any" dropdown buttons). Includes
|
|
62
|
+
* the `grove-filter-trigger` hook class because the client script
|
|
63
|
+
* reassigns the whole className when the active state changes — the
|
|
64
|
+
* hook must survive that reassignment.
|
|
65
|
+
*/
|
|
66
|
+
export function filterTriggerClass(active: boolean): string {
|
|
67
|
+
return [
|
|
68
|
+
"grove-filter-trigger",
|
|
69
|
+
BASE,
|
|
70
|
+
"h-9 px-3 text-[0.8125rem] max-sm:min-h-11",
|
|
71
|
+
active ? `${VARIANTS.selected} hover:text-selected-foreground` : VARIANTS.secondary,
|
|
72
|
+
].join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lens tab (the All / Hot / Mature row). Same geometry as the filter
|
|
77
|
+
* trigger; `data-lens-id` targeting keeps its own hook attribute.
|
|
78
|
+
*/
|
|
79
|
+
export function lensTabClass(active: boolean): string {
|
|
80
|
+
return [
|
|
81
|
+
BASE,
|
|
82
|
+
"h-9 px-3 text-[0.8125rem] max-sm:min-h-11",
|
|
83
|
+
active ? `${VARIANTS.selected} hover:text-selected-foreground` : VARIANTS.secondary,
|
|
84
|
+
].join(" ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Active-filter chip (the removable "Stack: Python ×" pills). One
|
|
89
|
+
* definition for the server render and the client rebuild.
|
|
90
|
+
*/
|
|
91
|
+
export function chipClass(extra = ""): string {
|
|
92
|
+
return [
|
|
93
|
+
"inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground transition-colors hover:border-ink-400",
|
|
94
|
+
extra,
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(" ");
|
|
98
|
+
}
|