diff --git a/web_ui/.gitignore b/web_ui/.gitignore
index 3110c21..3e4bf52 100644
--- a/web_ui/.gitignore
+++ b/web_ui/.gitignore
@@ -8,3 +8,4 @@
node_modules/
coverage/
*.tsbuildinfo
+.astro/
diff --git a/web_ui/README.md b/web_ui/README.md
index a094bb5..dcdc810 100644
--- a/web_ui/README.md
+++ b/web_ui/README.md
@@ -13,17 +13,35 @@ this package is its executable form for the web.
```jsonc
// package.json — pnpm only, see "Why the &path: suffix" below.
"dependencies": {
- "@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui"
+ "@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.5.0&path:/web_ui"
}
```
-```ts
-import "@kuhyx/web-ui/tokens.css"; // once, at the app entry point
-import "@kuhyx/web-ui/range-slider.css"; // only if you use RangeSlider
+Astro is the only supported framework (`peerDependencies.astro >=7.4.0`).
-import { RangeSlider, fuzzyMatch, quantileValue } from "@kuhyx/web-ui";
+```astro
+---
+// Layout / page frontmatter
+import RangeSlider from "@kuhyx/web-ui/RangeSlider.astro";
+import "@kuhyx/web-ui/tokens.css"; // once, in the shared layout
+import "@kuhyx/web-ui/range-slider.css"; // only if you use RangeSlider
+---
+
+
```
+Pure helpers (`fuzzyMatch`, `quantileValue`, ...) come from
+`import { fuzzyMatch } from "@kuhyx/web-ui";`. That entry point touches no DOM,
+so it is safe in frontmatter.
+
### Why the `&path:/web_ui` suffix
`utils` is a monorepo with no root `package.json`, and **npm cannot install from
@@ -33,24 +51,32 @@ pnpm supports the subdirectory via `&path:/
`, which is why both consumers
are pnpm. This is the only difference from the Dart (`git: {url, ref, path}`) and
Python (`#subdirectory=`) conventions the rest of `utils` follows.
-### Why `dist/` is committed
+### Why `dist/` is committed, and why `.astro` ships as source
-Vite does not transpile dependencies, so shipping raw `.tsx` would force
-per-repo bundler config on every consumer. The tag carries a prebuilt `dist/`
-(ESM + `.d.ts`, JSX already compiled, `.ts` specifiers rewritten to `.js`), so
-consumers stay at zero config. **`pnpm build` before tagging, always** — a tag
-whose `dist/` is stale ships old code to every consumer with no error.
+tsc cannot emit `.astro`, so `src/RangeSlider.astro` ships as it is and Astro
+compiles it from the dependency. Everything TypeScript ships prebuilt: the tag
+carries `dist/` (ESM + `.d.ts`, `.ts` specifiers rewritten to `.js`), and the
+`.astro` file imports `../dist/...`, never the `.ts` source. **`pnpm build`
+before tagging, always** — a tag whose `dist/` is stale ships old code to every
+consumer with no error.
## What's here
| Export | Notes |
| --- | --- |
| `tokens.css` | The `:root` block: palette, spacing, radius, type, shadows, focus ring. Also sets `:focus-visible` globally. |
-| `RangeSlider` | Dual-thumb slider over a value *distribution*, not a linear ramp. |
+| `RangeSlider.astro` | Server-rendered shell for ``. Props: `values`, `lo`, `hi`, `label`, `format` (server-side, first paint of the head only). Its `
diff --git a/web_ui/src/RangeSlider.astro.test.ts b/web_ui/src/RangeSlider.astro.test.ts
new file mode 100644
index 0000000..40cefb4
--- /dev/null
+++ b/web_ui/src/RangeSlider.astro.test.ts
@@ -0,0 +1,73 @@
+// @vitest-environment node
+import { experimental_AstroContainer as AstroContainer } from "astro/container";
+import { describe, expect, it } from "vitest";
+
+import RangeSlider from "./RangeSlider.astro";
+
+const VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
+
+async function render(props: Record): Promise {
+ const container = await AstroContainer.create();
+ return container.renderToString(RangeSlider, { props });
+}
+
+describe("RangeSlider.astro", () => {
+ it("wraps the markup in a range-slider element", async () => {
+ const html = await render({ values: VALUES });
+ expect(html).toContain(" {
+ const html = await render({ hi: 70, label: "Stars", lo: 20, values: VALUES });
+ expect(html.match(/role="slider"/gu)).toHaveLength(2);
+ expect(html).toContain('aria-label="Stars minimum"');
+ expect(html).toContain('aria-label="Stars maximum"');
+ expect(html).toContain('aria-valuemin="0"');
+ expect(html).toContain('aria-valuemax="90"');
+ expect(html).toContain('aria-valuenow="20"');
+ expect(html).toContain('aria-valuenow="70"');
+ });
+
+ it("positions the thumbs from the value distribution", async () => {
+ const html = await render({ hi: 70, lo: 20, values: VALUES });
+ expect(html).toContain(`left: ${String((2 / 9) * 100)}%`);
+ expect(html).toContain(`left: ${String((7 / 9) * 100)}%`);
+ });
+
+ it("defaults the bounds to the distribution ends", async () => {
+ const html = await render({ values: VALUES });
+ expect(html).toContain('aria-valuenow="0"');
+ expect(html).toContain('aria-valuenow="90"');
+ });
+
+ it("falls back to a generic name without a label", async () => {
+ expect(await render({ values: VALUES })).toContain('aria-label="Range minimum"');
+ });
+
+ it("renders the head only with a label and a format", async () => {
+ const withHead = await render({ format: (v: number) => `${String(v)}*`, label: "Stars", values: VALUES });
+ expect(withHead).toContain("slider-head");
+ expect(withHead).toContain("0* – 90*");
+ expect(await render({ label: "Stars", values: VALUES })).not.toContain("slider-head");
+ expect(await render({ format: String, values: VALUES })).not.toContain("slider-head");
+ });
+
+ it("hides the slider until there are values to range over", async () => {
+ const html = await render({});
+ expect(html).toMatch(/class="slider"[^>]*hidden/u);
+ expect(html).not.toContain("slider-head");
+ });
+
+ it("does not hide it once there are values", async () => {
+ expect(await render({ values: VALUES })).not.toMatch(/class="slider"[^>]*hidden/u);
+ });
+
+ it("forwards lo, hi and label to the element as attributes", async () => {
+ const html = await render({ hi: 70, label: "Stars", lo: 20, values: VALUES });
+ expect(html).toMatch(/]*lo="20"/u);
+ expect(html).toMatch(/]*hi="70"/u);
+ expect(html).toMatch(/]*label="Stars"/u);
+ });
+});
diff --git a/web_ui/src/astro-modules.d.ts b/web_ui/src/astro-modules.d.ts
new file mode 100644
index 0000000..1d1d682
--- /dev/null
+++ b/web_ui/src/astro-modules.d.ts
@@ -0,0 +1,6 @@
+declare module "*.astro" {
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
+
+ const component: AstroComponentFactory;
+ export default component;
+}
diff --git a/web_ui/src/index.ts b/web_ui/src/index.ts
index 627105c..dd97d61 100644
--- a/web_ui/src/index.ts
+++ b/web_ui/src/index.ts
@@ -1,10 +1,12 @@
/**
* The shared web token + component layer.
*
- * `tokens.css` is not re-exported here — CSS is imported by path:
- * `import "@kuhyx/web-ui/tokens.css";`
+ * Only the pure helpers live here, so this entry point is safe to import in
+ * Astro frontmatter (no DOM at module load). The component is imported by path:
+ * `import RangeSlider from "@kuhyx/web-ui/RangeSlider.astro";` in frontmatter,
+ * `import "@kuhyx/web-ui/range-slider";` in a client script, and the CSS as
+ * `import "@kuhyx/web-ui/tokens.css";`.
*/
export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts";
-export { RangeSlider, type RangeSliderProps } from "./range-slider.tsx";
export { keyTarget, steppedValue } from "./stepped-value.ts";
export { fuzzyMatch } from "./text.ts";
diff --git a/web_ui/src/range-slider-element-input.test.ts b/web_ui/src/range-slider-element-input.test.ts
new file mode 100644
index 0000000..c9e0a91
--- /dev/null
+++ b/web_ui/src/range-slider-element-input.test.ts
@@ -0,0 +1,125 @@
+import { fireEvent } from "@testing-library/dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { mount, thumb, track, TRACK_WIDTH } from "./test/slider-helpers.ts";
+
+afterEach(() => {
+ document.body.replaceChildren();
+});
+
+describe("keyboard", () => {
+ it.each([
+ ["ArrowRight", 10],
+ ["ArrowUp", 10],
+ ])("%s steps the low thumb up", (key, expected) => {
+ const { changes, element } = mount();
+ fireEvent.keyDown(thumb(element, "lo"), { key });
+ expect(changes).toEqual([{ hi: 90, lo: expected }]);
+ expect(element.lo).toBe(expected);
+ });
+
+ it.each([
+ ["ArrowLeft", 80],
+ ["ArrowDown", 80],
+ ])("%s steps the high thumb down", (key, expected) => {
+ const { changes, element } = mount();
+ fireEvent.keyDown(thumb(element, "hi"), { key });
+ expect(changes).toEqual([{ hi: expected, lo: 0 }]);
+ });
+
+ it("PageUp jumps ten samples", () => {
+ const { changes, element } = mount();
+ fireEvent.keyDown(thumb(element, "lo"), { key: "PageUp" });
+ expect(changes).toEqual([{ hi: 90, lo: 90 }]);
+ });
+
+ it("PageDown jumps ten samples down", () => {
+ const { changes, element } = mount();
+ fireEvent.keyDown(thumb(element, "hi"), { key: "PageDown" });
+ expect(changes).toEqual([{ hi: 0, lo: 0 }]);
+ });
+
+ it("Home sends a thumb to the floor", () => {
+ const { changes, element } = mount({ lo: "50" });
+ fireEvent.keyDown(thumb(element, "lo"), { key: "Home" });
+ expect(changes).toEqual([{ hi: 90, lo: 0 }]);
+ });
+
+ it("End sends a thumb to the ceiling", () => {
+ const { changes, element } = mount({ hi: "50" });
+ fireEvent.keyDown(thumb(element, "hi"), { key: "End" });
+ expect(changes).toEqual([{ hi: 90, lo: 0 }]);
+ });
+
+ it("prevents the default of a handled key and ignores the rest", () => {
+ const { changes, element } = mount();
+ expect(fireEvent.keyDown(thumb(element, "lo"), { cancelable: true, key: "ArrowRight" })).toBe(false);
+ expect(fireEvent.keyDown(thumb(element, "lo"), { cancelable: true, key: "a" })).toBe(true);
+ expect(changes).toHaveLength(1);
+ });
+
+ it("does not let the low thumb cross the high one", () => {
+ const { changes, element } = mount({ hi: "10", lo: "10" });
+ fireEvent.keyDown(thumb(element, "lo"), { key: "ArrowRight" });
+ expect(changes).toEqual([{ hi: 10, lo: 10 }]);
+ });
+
+ it("does not let the high thumb cross the low one", () => {
+ const { changes, element } = mount({ hi: "10", lo: "10" });
+ fireEvent.keyDown(thumb(element, "hi"), { key: "ArrowLeft" });
+ expect(changes).toEqual([{ hi: 10, lo: 10 }]);
+ });
+
+ it("bubbles the change event to ancestors", () => {
+ const { element } = mount();
+ const heard = vi.fn();
+ document.body.addEventListener("change", heard);
+ fireEvent.keyDown(thumb(element, "lo"), { key: "ArrowRight" });
+ expect(heard).toHaveBeenCalledOnce();
+ document.body.removeEventListener("change", heard);
+ });
+});
+
+describe("pointer", () => {
+ it("grabs the nearer thumb when pressed anywhere on the track", () => {
+ const { changes, element } = mount();
+ // The track is quantile-mapped over n-1 intervals: 0.1 lands on 9.
+ fireEvent.pointerDown(track(element), { clientX: TRACK_WIDTH * 0.1, pointerId: 1 });
+ expect(changes).toEqual([{ hi: 90, lo: 9 }]);
+ });
+
+ it("grabs the high thumb when the press is nearer to it", () => {
+ const { changes, element } = mount();
+ fireEvent.pointerDown(track(element), { clientX: TRACK_WIDTH * 0.9, pointerId: 1 });
+ expect(changes).toEqual([{ hi: 81, lo: 0 }]);
+ });
+
+ it("follows the pointer while dragging", () => {
+ const { changes, element } = mount();
+ fireEvent.pointerDown(track(element), { clientX: 0, pointerId: 1 });
+ fireEvent.pointerMove(track(element), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
+ expect(changes.at(-1)).toEqual({ hi: 90, lo: 27 });
+ });
+
+ it("ignores movement when no thumb is held", () => {
+ const { changes, element } = mount();
+ fireEvent.pointerMove(track(element), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
+ expect(changes).toEqual([]);
+ });
+
+ it("stops dragging on pointer up", () => {
+ const { changes, element } = mount();
+ fireEvent.pointerDown(track(element), { clientX: 0, pointerId: 1 });
+ fireEvent.pointerUp(track(element), { pointerId: 1 });
+ changes.length = 0;
+ fireEvent.pointerMove(track(element), { clientX: TRACK_WIDTH * 0.5, pointerId: 1 });
+ expect(changes).toEqual([]);
+ });
+
+ it("ignores a pointer up that was never preceded by a press", () => {
+ const { changes, element } = mount();
+ fireEvent.pointerUp(track(element), { pointerId: 1 });
+ expect(changes).toEqual([]);
+ });
+});
+
diff --git a/web_ui/src/range-slider-element-markup.test.ts b/web_ui/src/range-slider-element-markup.test.ts
new file mode 100644
index 0000000..41e01e0
--- /dev/null
+++ b/web_ui/src/range-slider-element-markup.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { RangeSliderElement } from "./range-slider-element.ts";
+import { thumb, VALUES } from "./test/slider-helpers.ts";
+
+afterEach(() => {
+ document.body.replaceChildren();
+});
+
+describe("server-rendered markup", () => {
+ const HEAD = '