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 = '
'; + + function server(beforeTrack = ""): string { + return `
${beforeTrack} +
+ +
`; + } + + /** Parses markup into a detached, upgraded element (no connectedCallback yet). */ + function parse(markup: string): RangeSliderElement { + const parser = new DOMParser(); + const parsed = parser.parseFromString(markup, "text/html").body.firstElementChild; + if (parsed === null) throw new TypeError("no markup"); + const element = document.importNode(parsed, true); + customElements.upgrade(element); + if (!(element instanceof RangeSliderElement)) throw new TypeError("not upgraded"); + return element; + } + + it("adopts the markup instead of adding a second slider", () => { + const element = parse(server()); + const original = element.querySelector(".slider-lo"); + element.values = VALUES; + expect(element.querySelectorAll(".slider")).toHaveLength(1); + expect(element.querySelector(".slider-lo")).toBe(original); + expect(thumb(element, "lo").getAttribute("aria-valuenow")).toBe("20"); + }); + + it("keeps a server-rendered head when no format is set", () => { + const element = parse(server('
kept
')); + element.values = VALUES; + expect(element.querySelector(".slider-head")?.textContent).toBe("kept"); + }); + + it("fills an existing head when a format arrives", () => { + const element = parse(server(HEAD)); + element.values = VALUES; + element.format = String; + expect(element.querySelector(".slider-value")?.textContent).toBe("20 – 70"); + }); + + it("tolerates a head with no spans", () => { + const element = parse(server('
')); + element.values = VALUES; + element.format = String; + expect(element.querySelector(".slider-head")?.textContent).toBe(""); + }); + + it.each([ + ['
', /missing \.slider-fill/u], + [ + '
', + /missing \.slider-hi/u, + ], + [ + '
', + /missing \.slider-lo/u, + ], + [ + '
', + /missing \.slider-track/u, + ], + ])("fails loudly when the server markup lacks a part: %#", (markup, message) => { + const element = parse(markup); + expect(() => { + element.values = VALUES; + }).toThrow(message); + }); +}); + +describe("upgrade", () => { + it("takes properties assigned before the element was upgraded", () => { + const element = document.createElement("range-slider"); + const prototype = Object.getPrototypeOf(element) as object; + Object.setPrototypeOf(element, HTMLElement.prototype); + Object.assign(element, { format: String, values: VALUES }); + Object.setPrototypeOf(element, prototype); + document.body.append(element); + expect(thumb(element, "hi").getAttribute("aria-valuenow")).toBe("90"); + }); +}); diff --git a/web_ui/src/range-slider-element.test.ts b/web_ui/src/range-slider-element.test.ts new file mode 100644 index 0000000..67d1cc5 --- /dev/null +++ b/web_ui/src/range-slider-element.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { RangeSliderElement } from "./range-slider-element.ts"; +import { mount, thumb, VALUES } from "./test/slider-helpers.ts"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("registration", () => { + it("registers range-slider", () => { + expect(customElements.get("range-slider")).toBe(RangeSliderElement); + }); + + it("tolerates a second copy of the module loading", async () => { + vi.resetModules(); + const second = await import("./range-slider-element.ts"); + expect(second.RangeSliderElement).not.toBe(RangeSliderElement); + expect(customElements.get("range-slider")).toBe(RangeSliderElement); + }); +}); + +describe("rendering", () => { + it("renders a hidden slider when there is nothing to range over", () => { + const { element } = mount({}, [1]); + expect(element.querySelector(".slider")?.hidden).toBe(true); + }); + + it("builds nothing before it has a value to show", () => { + const element = new RangeSliderElement(); + element.setAttribute("lo", "1"); + expect(element.querySelector(".slider")).toBeNull(); + }); + + it("exposes both thumbs as sliders with the value trio", () => { + const { element } = mount(); + const low = thumb(element, "lo"); + expect(low.getAttribute("role")).toBe("slider"); + expect(low.getAttribute("aria-valuemin")).toBe("0"); + expect(low.getAttribute("aria-valuemax")).toBe("90"); + expect(low.getAttribute("aria-valuenow")).toBe("0"); + expect(thumb(element, "hi").getAttribute("aria-valuenow")).toBe("90"); + expect(element.querySelector(".slider")?.hidden).toBe(false); + }); + + it("positions the thumbs and the fill from lo and hi", () => { + const { element } = mount({ hi: "70", lo: "20" }); + expect(thumb(element, "lo").style.left).toBe(`${String((2 / 9) * 100)}%`); + expect(thumb(element, "hi").style.left).toBe(`${String((7 / 9) * 100)}%`); + expect(element.querySelector(".slider-fill")?.style.left).toBe( + `${String((2 / 9) * 100)}%`, + ); + }); + + it("reports lo and hi as the distribution bounds when unconstrained", () => { + const { element } = mount(); + expect([element.lo, element.hi]).toEqual([0, 90]); + }); + + it("reports zero bounds with no values", () => { + const element = new RangeSliderElement(); + expect([element.lo, element.hi]).toEqual([0, 0]); + }); + + it("falls back to the bounds for a non-numeric attribute", () => { + const { element } = mount({ hi: "x", lo: "y" }); + expect([element.lo, element.hi]).toEqual([0, 90]); + }); + + it("names the thumbs from the label", () => { + const { element } = mount({ label: "Stars" }); + expect(thumb(element, "lo").getAttribute("aria-label")).toBe("Stars minimum"); + expect(thumb(element, "hi").getAttribute("aria-label")).toBe("Stars maximum"); + }); + + it("falls back to a generic name without a label", () => { + const { element } = mount(); + expect(thumb(element, "lo").getAttribute("aria-label")).toBe("Range minimum"); + }); + + it("re-renders when an attribute changes", () => { + const { element } = mount(); + element.setAttribute("lo", "30"); + expect(thumb(element, "lo").getAttribute("aria-valuenow")).toBe("30"); + }); + + it("is focusable, so the control is a keyboard stop", () => { + const { element } = mount(); + thumb(element, "lo").focus(); + expect(document.activeElement).toBe(thumb(element, "lo")); + }); + + it("keeps a single slider across re-renders", () => { + const { element } = mount(); + element.values = VALUES; + expect(element.querySelectorAll(".slider")).toHaveLength(1); + }); + + it("returns the values it was given", () => { + const { element } = mount(); + expect(element.values).toBe(VALUES); + }); +}); + +describe("head", () => { + const format = (value: number): string => `${String(value)}*`; + + it("draws the head only when both label and format are given", () => { + const { element } = mount({ label: "Stars" }); + expect(element.querySelector(".slider-head")).toBeNull(); + element.format = format; + expect(element.format).toBe(format); + expect(element.querySelector(":scope .slider-head > span")?.textContent).toBe("Stars"); + expect(element.querySelector(".slider-value")?.textContent).toBe("0* – 90*"); + }); + + it("omits the head without a label even with a format", () => { + const { element } = mount(); + element.format = format; + expect(element.querySelector(".slider-head")).toBeNull(); + }); + + it("updates the readout when the bounds move", () => { + const { element } = mount({ label: "Stars" }); + element.format = format; + element.setAttribute("hi", "40"); + expect(element.querySelector(".slider-value")?.textContent).toBe("0* – 40*"); + }); +}); + diff --git a/web_ui/src/range-slider-element.ts b/web_ui/src/range-slider-element.ts new file mode 100644 index 0000000..e95149d --- /dev/null +++ b/web_ui/src/range-slider-element.ts @@ -0,0 +1,231 @@ +/** + * ``: a dual-thumb slider whose track maps through a value + * distribution rather than a linear ramp (see `./quantile.ts`). + * + * Inputs: `values` (property, ascending), `lo` / `hi` / `label` (attributes), + * `format` (property, enables the head readout). Output: a bubbling `change` + * CustomEvent with `{ lo, hi }`. The element writes `lo` / `hi` itself, so it + * works uncontrolled. It adopts the markup `RangeSlider.astro` renders, or + * builds it when used from plain HTML. + */ +import { fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts"; +import { keyTarget } from "./stepped-value.ts"; + +export interface RangeChange { + readonly hi: number; + readonly lo: number; +} + +type Thumb = "hi" | "lo"; + +interface Parts { + readonly fill: HTMLElement; + readonly hi: HTMLButtonElement; + readonly lo: HTMLButtonElement; + readonly root: HTMLElement; + readonly track: HTMLElement; +} + +function make( + tag: K, + classes: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + element.setAttribute("class", classes); + return element; +} + +function thumbButton(thumb: Thumb): HTMLButtonElement { + const button = make("button", `slider-thumb slider-${thumb}`); + button.type = "button"; + button.setAttribute("role", "slider"); + return button; +} + +function buildParts(): Parts { + const root = make("div", "slider"); + const track = make("div", "slider-track"); + const fill = make("div", "slider-fill"); + const lo = thumbButton("lo"); + const hi = thumbButton("hi"); + track.append(fill, lo, hi); + root.append(track); + return { fill, hi, lo, root, track }; +} + +function required(found: null | T, selector: string): T { + if (found === null) throw new Error(` markup is missing ${selector}`); + return found; +} + +function adoptParts(host: Element): Parts { + const root = required(host.querySelector(".slider"), ".slider"); + return { + fill: required(root.querySelector(".slider-fill"), ".slider-fill"), + hi: required(root.querySelector(".slider-hi"), ".slider-hi"), + lo: required(root.querySelector(".slider-lo"), ".slider-lo"), + root, + track: required(root.querySelector(".slider-track"), ".slider-track"), + }; +} + +export class RangeSliderElement extends HTMLElement { + static readonly observedAttributes = ["hi", "label", "lo"]; + + #drag: null | Thumb = null; + #format: ((value: number) => string) | undefined; + #parts: Parts | undefined; + #values: readonly number[] = []; + + #apply(thumb: Thumb, value: number): void { + const lo = thumb === "lo" ? Math.min(value, this.hi) : this.lo; + const hi = thumb === "hi" ? Math.max(value, this.lo) : this.hi; + this.setAttribute("lo", String(lo)); + this.setAttribute("hi", String(hi)); + const detail: RangeChange = { hi, lo }; + this.dispatchEvent(new CustomEvent("change", { bubbles: true, detail })); + } + + #bound(name: Thumb, fallback: number): number { + const raw = this.getAttribute(name); + const parsed = raw === null ? NaN : Number(raw); + return Number.isNaN(parsed) ? fallback : parsed; + } + + #build(): Parts { + const parts = buildParts(); + this.append(parts.root); + return parts; + } + + #mount(): Parts { + if (this.#parts !== undefined) return this.#parts; + const parts = this.querySelector(".slider") === null ? this.#build() : adoptParts(this); + + for (const thumb of ["lo", "hi"] as const) { + parts[thumb].addEventListener("keydown", (event) => { + const target = keyTarget(event.key, this.#values, thumb === "lo" ? this.lo : this.hi); + if (target === undefined) return; + event.preventDefault(); + this.#apply(thumb, target); + }); + } + + const valueAt = (event: PointerEvent): number => { + return quantileValue( + this.#values, + fractionFromPointer(parts.track.getBoundingClientRect(), event.clientX), + ); + }; + + parts.track.addEventListener("pointerdown", (event) => { + const value = valueAt(event); + // Pressing anywhere on the track grabs the nearer thumb. + const thumb: Thumb = Math.abs(value - this.lo) <= Math.abs(value - this.hi) ? "lo" : "hi"; + this.#drag = thumb; + parts.track.setPointerCapture(event.pointerId); + this.#apply(thumb, value); + }); + parts.track.addEventListener("pointermove", (event) => { + if (this.#drag === null) return; + this.#apply(this.#drag, valueAt(event)); + }); + parts.track.addEventListener("pointerup", (event) => { + if (this.#drag === null) return; + this.#drag = null; + parts.track.releasePointerCapture(event.pointerId); + }); + + this.#parts = parts; + return parts; + } + + #renderHead(root: HTMLElement, label: null | string): void { + const format = this.#format; + if (label === null || format === undefined) return; + let head = root.querySelector(".slider-head"); + if (head === null) { + head = make("div", "slider-head"); + head.append(document.createElement("span"), make("span", "slider-value")); + root.prepend(head); + } + const [name, value] = head.children; + if (name !== undefined) name.textContent = label; + if (value !== undefined) value.textContent = `${format(this.lo)} – ${format(this.hi)}`; + } + + #render(): void { + const { fill, hi, lo, root } = this.#mount(); + const values = this.#values; + root.hidden = values.length < 2; + if (root.hidden) return; + + const label = this.getAttribute("label"); + const name = label ?? "Range"; + const low = this.lo; + const high = this.hi; + const loFraction = valueQuantile(values, low) * 100; + const hiFraction = valueQuantile(values, high) * 100; + + fill.style.left = `${String(loFraction)}%`; + fill.style.width = `${String(hiFraction - loFraction)}%`; + for (const [thumb, button, now, fraction] of [ + ["minimum", lo, low, loFraction], + ["maximum", hi, high, hiFraction], + ] as const) { + button.setAttribute("aria-label", `${name} ${thumb}`); + button.setAttribute("aria-valuemin", String(nth(values, 0))); + button.setAttribute("aria-valuemax", String(nth(values, values.length - 1))); + button.setAttribute("aria-valuenow", String(now)); + button.style.left = `${String(fraction)}%`; + } + this.#renderHead(root, label); + } + + get format(): ((value: number) => string) | undefined { + return this.#format; + } + + set format(format: ((value: number) => string) | undefined) { + this.#format = format; + this.#render(); + } + + /** The upper bound; the distribution max when the attribute is absent. */ + get hi(): number { + return this.#bound("hi", this.#values.length === 0 ? 0 : nth(this.#values, this.#values.length - 1)); + } + + /** The lower bound; the distribution min when the attribute is absent. */ + get lo(): number { + return this.#bound("lo", this.#values.length === 0 ? 0 : nth(this.#values, 0)); + } + + get values(): readonly number[] { + return this.#values; + } + + set values(values: readonly number[]) { + this.#values = values; + this.#render(); + } + + attributeChangedCallback(): void { + if (this.#parts !== undefined) this.#render(); + } + + connectedCallback(): void { + // A property set before the element upgraded shadows the accessor. + for (const name of ["format", "values"] as const) { + if (!Object.hasOwn(this, name)) continue; + const early: unknown = Reflect.get(this, name); + Reflect.deleteProperty(this, name); + Reflect.set(this, name, early); + } + this.#render(); + } +} + +if (customElements.get("range-slider") === undefined) { + customElements.define("range-slider", RangeSliderElement); +} diff --git a/web_ui/src/range-slider.test.tsx b/web_ui/src/range-slider.test.tsx deleted file mode 100644 index 3b9112a..0000000 --- a/web_ui/src/range-slider.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { RangeSlider } from "./range-slider.tsx"; - -/** Ten evenly spaced values, so a quantile fraction maps to a predictable value. */ -const VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]; - -/** The track width the jsdom rect stub reports (see src/test/setup.ts). */ -const TRACK_WIDTH = 1000; - -function renderSlider( - properties: Partial> = {}, -): { onChange: ReturnType } { - const onChange = vi.fn(); - render( - , - ); - return { onChange }; -} - -function thumbs(): HTMLElement[] { - return screen.getAllByRole("slider"); -} - -describe("RangeSlider", () => { - it("renders nothing when there is nothing to range over", () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it("exposes both thumbs as sliders with the value trio", () => { - renderSlider(); - const [low, high] = thumbs(); - expect(low).toHaveAttribute("aria-valuemin", "0"); - expect(low).toHaveAttribute("aria-valuemax", "90"); - expect(low).toHaveAttribute("aria-valuenow", "0"); - expect(high).toHaveAttribute("aria-valuenow", "90"); - }); - - it("names the thumbs from the label", () => { - renderSlider({ label: "Stars" }); - expect(screen.getByLabelText("Stars minimum")).toBeInTheDocument(); - expect(screen.getByLabelText("Stars maximum")).toBeInTheDocument(); - }); - - it("falls back to a generic name without a label", () => { - renderSlider(); - expect(screen.getByLabelText("Range minimum")).toBeInTheDocument(); - }); - - it("draws the head only when both label and format are given", () => { - renderSlider({ format: (v): string => `${String(v)}*`, label: "Stars" }); - expect(screen.getByText("Stars")).toBeInTheDocument(); - expect(screen.getByText("0* – 90*")).toBeInTheDocument(); - }); - - it("omits the head when the caller draws its own chrome", () => { - renderSlider({ label: "Stars" }); - expect(screen.queryByText("Stars")).not.toBeInTheDocument(); - }); - - it("is focusable, so the control is a keyboard stop", () => { - renderSlider(); - const [low] = thumbs(); - low?.focus(); - expect(low).toHaveFocus(); - }); - - describe("keyboard", () => { - it.each([ - ["ArrowRight", 10], - ["ArrowUp", 10], - ])("%s steps the low thumb up", (key, expected) => { - const { onChange } = renderSlider(); - fireEvent.keyDown(thumbs()[0]!, { key }); - expect(onChange).toHaveBeenCalledWith(expected, 90); - }); - - it.each([ - ["ArrowLeft", 80], - ["ArrowDown", 80], - ])("%s steps the high thumb down", (key, expected) => { - const { onChange } = renderSlider(); - fireEvent.keyDown(thumbs()[1]!, { key }); - expect(onChange).toHaveBeenCalledWith(0, expected); - }); - - it("PageUp jumps ten samples", () => { - const { onChange } = renderSlider({ lo: 0 }); - fireEvent.keyDown(thumbs()[0]!, { key: "PageUp" }); - expect(onChange).toHaveBeenCalledWith(90, 90); - }); - - it("PageDown jumps ten samples down", () => { - const { onChange } = renderSlider({ hi: 90 }); - fireEvent.keyDown(thumbs()[1]!, { key: "PageDown" }); - expect(onChange).toHaveBeenCalledWith(0, 0); - }); - - it("Home sends a thumb to the floor", () => { - const { onChange } = renderSlider({ lo: 50 }); - fireEvent.keyDown(thumbs()[0]!, { key: "Home" }); - expect(onChange).toHaveBeenCalledWith(0, 90); - }); - - it("End sends a thumb to the ceiling", () => { - const { onChange } = renderSlider({ hi: 50 }); - fireEvent.keyDown(thumbs()[1]!, { key: "End" }); - expect(onChange).toHaveBeenCalledWith(0, 90); - }); - - it("ignores keys it does not handle", () => { - const { onChange } = renderSlider(); - fireEvent.keyDown(thumbs()[0]!, { key: "a" }); - expect(onChange).not.toHaveBeenCalled(); - }); - - it("does not let the low thumb cross the high one", () => { - const { onChange } = renderSlider({ hi: 10, lo: 10 }); - fireEvent.keyDown(thumbs()[0]!, { key: "ArrowRight" }); - expect(onChange).toHaveBeenCalledWith(10, 10); - }); - - it("does not let the high thumb cross the low one", () => { - const { onChange } = renderSlider({ hi: 10, lo: 10 }); - fireEvent.keyDown(thumbs()[1]!, { key: "ArrowLeft" }); - expect(onChange).toHaveBeenCalledWith(10, 10); - }); - }); - - describe("pointer", () => { - function track(): HTMLElement { - // Queried by class rather than by role: the track is presentational, so - // it has neither a role nor text to find it by. (The consumers run - // eslint-plugin-testing-library, which flags this; this package does not - // install that plugin, so a disable comment here would itself be an - // unknown-rule error.) - return document.querySelector(".slider-track")!; - } - - it("grabs the nearer thumb when pressed anywhere on the track", () => { - const { onChange } = renderSlider(); - // 10% along: nearer the low thumb, which sits at 0. The track is - // quantile-mapped over n-1 intervals, so 0.1 lands on 9, not 10. - fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.1, pointerId: 1 }); - expect(onChange).toHaveBeenCalledWith(9, 90); - }); - - it("grabs the high thumb when the press is nearer to it", () => { - const { onChange } = renderSlider(); - fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.9, pointerId: 1 }); - expect(onChange).toHaveBeenCalledWith(0, 81); - }); - - it("follows the pointer while dragging", () => { - const { onChange } = renderSlider(); - fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 }); - fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 }); - expect(onChange).toHaveBeenLastCalledWith(27, 90); - }); - - it("ignores movement when no thumb is held", () => { - const { onChange } = renderSlider(); - fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 }); - expect(onChange).not.toHaveBeenCalled(); - }); - - it("stops dragging on pointer up", () => { - const { onChange } = renderSlider(); - fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 }); - fireEvent.pointerUp(track(), { pointerId: 1 }); - onChange.mockClear(); - fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.5, pointerId: 1 }); - expect(onChange).not.toHaveBeenCalled(); - }); - - it("ignores a pointer up that was never preceded by a press", () => { - const { onChange } = renderSlider(); - fireEvent.pointerUp(track(), { pointerId: 1 }); - expect(onChange).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/web_ui/src/range-slider.tsx b/web_ui/src/range-slider.tsx deleted file mode 100644 index 28477c0..0000000 --- a/web_ui/src/range-slider.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/** - * A dual-thumb slider whose track maps through a value *distribution* rather - * than a linear min..max ramp (see `./quantile.ts` for why). - * - * Reconciled from the two drifted copies in dufs-cloud and - * awesome-mcp-explorer. It takes the accessible DOM from awesome-mcp-explorer - * (real `