mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +01:00
web_ui 0.5.0: port to Astro + custom element
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
web_ui tests / test (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
web_ui tests / test (push) Has been cancelled
RangeSlider.astro + <range-slider> replace the React component. Pure helpers, tokens.css and range-slider.css are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
parent
648bde44b1
commit
decf5824e6
1
web_ui/.gitignore
vendored
1
web_ui/.gitignore
vendored
@ -8,3 +8,4 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.astro/
|
||||
|
||||
@ -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
|
||||
---
|
||||
<RangeSlider label="Stars" />
|
||||
<script>
|
||||
import "@kuhyx/web-ui/range-slider"; // registers <range-slider>
|
||||
const slider = document.querySelector("range-slider");
|
||||
slider.values = [0, 0, 3, 40, 900]; // ascending distribution
|
||||
slider.format = (value) => `${value} stars`;
|
||||
slider.addEventListener("change", (event) => {
|
||||
const { lo, hi } = event.detail; // bubbles; raw values, lo <= hi
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
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:/<dir>`, 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 `<range-slider>`. Props: `values`, `lo`, `hi`, `label`, `format` (server-side, first paint of the head only). Its `<script>` registers the element. |
|
||||
| `range-slider` | The custom element. `values` and `format` properties, `lo` / `hi` / `label` attributes, bubbling `change` event with `{ lo, hi }`. Adopts the server markup, or builds it in plain HTML. |
|
||||
| `range-slider.css` | Its styling, entirely in tokens. |
|
||||
| `quantile.ts` helpers | `nth`, `clamp01`, `quantileValue`, `valueQuantile`, `fractionFromPointer`. |
|
||||
| `stepped-value.ts` helpers | `keyTarget`, `steppedValue`. |
|
||||
| `fuzzyMatch` | Case-insensitive subsequence match. |
|
||||
|
||||
The element writes `lo` / `hi` itself on every change, so it works without any
|
||||
wiring; set the attributes from outside to override it (e.g. a URL-hash restore).
|
||||
With fewer than two `values` the slider stays hidden. `values` set before the
|
||||
element upgrades is picked up on connect.
|
||||
|
||||
### The categorical ramp
|
||||
|
||||
`--cat-1` … `--cat-6` are for charts, tags and category dots — N mutually
|
||||
@ -69,7 +95,7 @@ candidate reaches only ΔE 17.9. Group the tail into "other" instead.
|
||||
`fractionFromPointer` is deliberately pure geometry: it makes the slider's
|
||||
behaviour testable without layout, which jsdom does not implement.
|
||||
|
||||
### RangeSlider is keyboard-operable
|
||||
### `<range-slider>` is keyboard-operable
|
||||
|
||||
Both donor copies were pointer-only. The design system requires that every
|
||||
action be reachable with the keyboard alone, and a `role="slider"` that ignores
|
||||
@ -81,7 +107,7 @@ arrow keys is an ARIA role that lies — so the shared version adds:
|
||||
| `PageDown` / `PageUp` | Step ten distinct values |
|
||||
| `Home` / `End` | Jump to the distribution's bounds |
|
||||
|
||||
Pass `label` **and** `format` to get the built-in head (name + value readout);
|
||||
Set `label` **and** `format` to get the built-in head (name + value readout);
|
||||
omit them for a bare track when the caller draws its own chrome, as
|
||||
dufs-cloud's `SizeRange`/`DurationRange` wrappers do.
|
||||
|
||||
@ -95,9 +121,9 @@ arrow key, and jsdom's evenly-spaced fixtures do not catch it.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm test # vitest
|
||||
pnpm test # vitest (jsdom for the element, AstroContainer for the .astro markup)
|
||||
pnpm coverage # 100% branches/functions/lines/statements, enforced
|
||||
pnpm lint # tsc --noEmit && eslint
|
||||
pnpm lint # tsc --noEmit && eslint && astro check
|
||||
pnpm build # tsc -b + copy CSS into dist/
|
||||
```
|
||||
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
# Port @kuhyx/web-ui from React to Astro + vanilla TS
|
||||
|
||||
## what
|
||||
kuhy chose one framework for every web UI: Astro, with no React islands. This
|
||||
package is the first blocker. `RangeSlider` is its only React export. It
|
||||
becomes `RangeSlider.astro` plus a `<range-slider>` custom element. The
|
||||
pure-TS helpers and the CSS stay as they are. Every repo's
|
||||
`TODO-astro-migration.md` waits for the tag this produces.
|
||||
|
||||
## steps
|
||||
1. Keep these as they are: `quantile.ts` (`nth`, `clamp01`, `quantileValue`,
|
||||
`valueQuantile`, `fractionFromPointer`), `stepped-value.ts` (`keyTarget`,
|
||||
`steppedValue`), `text.ts` (`fuzzyMatch`), `tokens.css`, `range-slider.css`.
|
||||
2. Write `src/range-slider-element.ts`, a `RangeSliderElement extends HTMLElement`
|
||||
registered as `range-slider`. Inputs: a `values` property (number[]), `lo` and
|
||||
`hi` attributes, a `label` attribute, and a `format` property (a function).
|
||||
Output: a bubbling `change` CustomEvent with `{ lo, hi }`. Keep the current
|
||||
DOM: two `<button role="slider">` thumbs with `aria-valuemin`, `aria-valuemax`
|
||||
and `aria-valuenow`, grab-the-nearer-thumb on pointer, and the keyboard map in
|
||||
README.md. All of the logic stays in the existing pure helpers.
|
||||
3. Write `src/RangeSlider.astro`. It server-renders the same markup (track,
|
||||
thumbs, optional head) inside `<range-slider>`. Its `<script>` imports the
|
||||
element module, so including the component registers the element.
|
||||
4. Order: element and its tests first, then the `.astro` wrapper, then exports
|
||||
and packaging, then README, then the tag.
|
||||
5. Packaging. tsc cannot emit `.astro`, so ship the `.astro` source as it is.
|
||||
Astro compiles `.astro` files from dependencies. Add to `exports`:
|
||||
`"./RangeSlider.astro": "./src/RangeSlider.astro"` and
|
||||
`"./range-slider": "./dist/range-slider-element.js"`. Add `src/*.astro`
|
||||
to `files`. Keep `dist/` committed for the `.ts` output. The `.astro`
|
||||
script imports `../dist/range-slider-element.js`, never the `.ts` source.
|
||||
6. Consumers import it like this:
|
||||
`import RangeSlider from "@kuhyx/web-ui/RangeSlider.astro";` in frontmatter.
|
||||
Scripts use `import "@kuhyx/web-ui/range-slider";` and listen for `change`.
|
||||
Tokens stay `import "@kuhyx/web-ui/tokens.css";` in the shared layout.
|
||||
7. Dependencies, all exact pins via pnpm: drop `peerDependencies.react`,
|
||||
`react`, `react-dom`, `@types/react`, `@types/react-dom` and
|
||||
`@testing-library/react`. Add `astro@7.4.4` as a devDependency (for the
|
||||
Container API and `astro check`), `@astrojs/check@0.9.12` and
|
||||
`@testing-library/dom` at its newest stable. Add
|
||||
`peerDependencies.astro: ">=7.4.0"`.
|
||||
8. Tests. `range-slider.test.tsx` becomes `range-slider-element.test.ts`, which
|
||||
covers the custom element in jsdom: pointer, keyboard, the `change` event and
|
||||
the ARIA attributes. Add `RangeSlider.astro.test.ts`, which uses
|
||||
`experimental_AstroContainer` to check the server markup. Keep coverage at
|
||||
100% for `src/**/*.ts`. Exclude `.astro` from coverage, since its script
|
||||
holds only the import.
|
||||
9. Rewrite README.md's Install and "What's here" sections for the Astro API.
|
||||
Then tag `web_ui-v0.5.0`. Before tagging, run `pnpm build`.
|
||||
|
||||
## done
|
||||
Run from `~/src/utils/web_ui`:
|
||||
`pnpm install --frozen-lockfile && pnpm lint && pnpm coverage && pnpm build && ! grep -q '"react"' package.json && ! find src -name '*.tsx' | grep -q . && git tag -l web_ui-v0.5.0 | grep -q .`
|
||||
This exits 0.
|
||||
|
||||
## verify
|
||||
Do it in the first consumer migrated, awesome-mcp-explorer, because it uses
|
||||
`RangeSlider`. Check that the slider drags and that arrow keys and Home/End
|
||||
step. Use a headless chromium at 4K and at a mobile viewport. The filter must
|
||||
update and there must be no console errors.
|
||||
|
||||
## read first
|
||||
- `~/src/utils/web_ui/README.md`: keyboard map, why `&path:/web_ui`, release steps.
|
||||
- `~/src/utils/web_ui/src/range-slider.tsx` and `range-slider.test.tsx`.
|
||||
- The consumers and their tags:
|
||||
- control-panel/web: v0.3.2
|
||||
- signal-bot/web: v0.3.1
|
||||
- dufs-cloud/web: v0.3.1
|
||||
- awesome-mcp-explorer: v0.3.1
|
||||
- foss-audit: v0.4.0
|
||||
- Astro docs: "Publish to NPM" (shipping `.astro`), and `reference/container-reference`.
|
||||
|
||||
## order
|
||||
Migration order across kuhy's repos:
|
||||
1. `web_ui` first (this file), because every other repo waits for its tag.
|
||||
2. Content sites next (public sites, e.g. teatr-pw's Astro version).
|
||||
3. Small apps after.
|
||||
4. `control-panel` and `steam-backlog-enforcer` last, after every other repo.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
8
web_ui/dist/index.d.ts
vendored
8
web_ui/dist/index.d.ts
vendored
@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 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";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
2
web_ui/dist/index.d.ts.map
vendored
2
web_ui/dist/index.d.ts.map
vendored
@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
|
||||
8
web_ui/dist/index.js
vendored
8
web_ui/dist/index.js
vendored
@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 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.js";
|
||||
export { RangeSlider } from "./range-slider.js";
|
||||
export { keyTarget, steppedValue } from "./stepped-value.js";
|
||||
export { fuzzyMatch } from "./text.js";
|
||||
//# sourceMappingURL=index.js.map
|
||||
2
web_ui/dist/index.js.map
vendored
2
web_ui/dist/index.js.map
vendored
@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,WAAW,EAAyB,MAAM,mBAAoB,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChG,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
|
||||
19
web_ui/dist/range-slider-element.d.ts
vendored
Normal file
19
web_ui/dist/range-slider-element.d.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
export interface RangeChange {
|
||||
readonly hi: number;
|
||||
readonly lo: number;
|
||||
}
|
||||
export declare class RangeSliderElement extends HTMLElement {
|
||||
#private;
|
||||
static readonly observedAttributes: string[];
|
||||
get format(): ((value: number) => string) | undefined;
|
||||
set format(format: ((value: number) => string) | undefined);
|
||||
/** The upper bound; the distribution max when the attribute is absent. */
|
||||
get hi(): number;
|
||||
/** The lower bound; the distribution min when the attribute is absent. */
|
||||
get lo(): number;
|
||||
get values(): readonly number[];
|
||||
set values(values: readonly number[]);
|
||||
attributeChangedCallback(): void;
|
||||
connectedCallback(): void;
|
||||
}
|
||||
//# sourceMappingURL=range-slider-element.d.ts.map
|
||||
1
web_ui/dist/range-slider-element.d.ts.map
vendored
Normal file
1
web_ui/dist/range-slider-element.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"range-slider-element.d.ts","sourceRoot":"","sources":["../src/range-slider-element.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAuDD,qBAAa,kBAAmB,SAAQ,WAAW;;IACjD,MAAM,CAAC,QAAQ,CAAC,kBAAkB,WAAyB;IAgH3D,IAAI,MAAM,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,CAEpD;IAED,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,EAGzD;IAED,0EAA0E;IAC1E,IAAI,EAAE,IAAI,MAAM,CAEf;IAED,0EAA0E;IAC1E,IAAI,EAAE,IAAI,MAAM,CAEf;IAED,IAAI,MAAM,IAAI,SAAS,MAAM,EAAE,CAE9B;IAED,IAAI,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAGnC;IAED,wBAAwB,IAAI,IAAI;IAIhC,iBAAiB,IAAI,IAAI;CAU1B"}
|
||||
194
web_ui/dist/range-slider-element.js
vendored
Normal file
194
web_ui/dist/range-slider-element.js
vendored
Normal file
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* `<range-slider>`: 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.js";
|
||||
import { keyTarget } from "./stepped-value.js";
|
||||
function make(tag, classes) {
|
||||
const element = document.createElement(tag);
|
||||
element.setAttribute("class", classes);
|
||||
return element;
|
||||
}
|
||||
function thumbButton(thumb) {
|
||||
const button = make("button", `slider-thumb slider-${thumb}`);
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "slider");
|
||||
return button;
|
||||
}
|
||||
function buildParts() {
|
||||
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, selector) {
|
||||
if (found === null)
|
||||
throw new Error(`<range-slider> markup is missing ${selector}`);
|
||||
return found;
|
||||
}
|
||||
function adoptParts(host) {
|
||||
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 observedAttributes = ["hi", "label", "lo"];
|
||||
#drag = null;
|
||||
#format;
|
||||
#parts;
|
||||
#values = [];
|
||||
#apply(thumb, value) {
|
||||
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 = { hi, lo };
|
||||
this.dispatchEvent(new CustomEvent("change", { bubbles: true, detail }));
|
||||
}
|
||||
#bound(name, fallback) {
|
||||
const raw = this.getAttribute(name);
|
||||
const parsed = raw === null ? NaN : Number(raw);
|
||||
return Number.isNaN(parsed) ? fallback : parsed;
|
||||
}
|
||||
#build() {
|
||||
const parts = buildParts();
|
||||
this.append(parts.root);
|
||||
return parts;
|
||||
}
|
||||
#mount() {
|
||||
if (this.#parts !== undefined)
|
||||
return this.#parts;
|
||||
const parts = this.querySelector(".slider") === null ? this.#build() : adoptParts(this);
|
||||
for (const thumb of ["lo", "hi"]) {
|
||||
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) => {
|
||||
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 = 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, label) {
|
||||
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() {
|
||||
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],
|
||||
]) {
|
||||
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() {
|
||||
return this.#format;
|
||||
}
|
||||
set format(format) {
|
||||
this.#format = format;
|
||||
this.#render();
|
||||
}
|
||||
/** The upper bound; the distribution max when the attribute is absent. */
|
||||
get hi() {
|
||||
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() {
|
||||
return this.#bound("lo", this.#values.length === 0 ? 0 : nth(this.#values, 0));
|
||||
}
|
||||
get values() {
|
||||
return this.#values;
|
||||
}
|
||||
set values(values) {
|
||||
this.#values = values;
|
||||
this.#render();
|
||||
}
|
||||
attributeChangedCallback() {
|
||||
if (this.#parts !== undefined)
|
||||
this.#render();
|
||||
}
|
||||
connectedCallback() {
|
||||
// A property set before the element upgraded shadows the accessor.
|
||||
for (const name of ["format", "values"]) {
|
||||
if (!Object.hasOwn(this, name))
|
||||
continue;
|
||||
const early = 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);
|
||||
}
|
||||
//# sourceMappingURL=range-slider-element.js.map
|
||||
1
web_ui/dist/range-slider-element.js.map
vendored
Normal file
1
web_ui/dist/range-slider-element.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
20
web_ui/dist/range-slider.d.ts
vendored
20
web_ui/dist/range-slider.d.ts
vendored
@ -1,20 +0,0 @@
|
||||
export interface RangeSliderProps {
|
||||
/**
|
||||
* Formats a value for the head readout. Omit it (with `label`) to render a
|
||||
* bare track — dufs-cloud's wrappers draw their own labels and value notes,
|
||||
* so a built-in head would duplicate them.
|
||||
*/
|
||||
readonly format?: (value: number) => string;
|
||||
/** Current upper bound; equal to the distribution max when unconstrained. */
|
||||
readonly hi: number;
|
||||
/** Accessible name. Falls back to a generic one when omitted. */
|
||||
readonly label?: string;
|
||||
/** Current lower bound; equal to the distribution min when unconstrained. */
|
||||
readonly lo: number;
|
||||
/** Reports both thumb values (raw, clamped so lo <= hi) on every change. */
|
||||
readonly onChange: (lo: number, hi: number) => void;
|
||||
/** The full ascending distribution — not just its endpoints. */
|
||||
readonly values: readonly number[];
|
||||
}
|
||||
export declare function RangeSlider({ format, hi, label, lo, onChange, values, }: RangeSliderProps): null | React.JSX.Element;
|
||||
//# sourceMappingURL=range-slider.d.ts.map
|
||||
1
web_ui/dist/range-slider.d.ts.map
vendored
1
web_ui/dist/range-slider.d.ts.map
vendored
@ -1 +0,0 @@
|
||||
{"version":3,"file":"range-slider.d.ts","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"AA0BA,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAID,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,EAAE,EACF,KAAK,EACL,EAAE,EACF,QAAQ,EACR,MAAM,GACP,EAAE,gBAAgB,GAAG,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAkG7C"}
|
||||
91
web_ui/dist/range-slider.js
vendored
91
web_ui/dist/range-slider.js
vendored
@ -1,91 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
/**
|
||||
* 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 `<button>` thumbs carrying `role="slider"` and the `aria-value*` trio,
|
||||
* so the control is a focus stop) and the grab-the-nearer-thumb pointer
|
||||
* behaviour from dufs-cloud (pressing anywhere on the track moves the closer
|
||||
* handle, instead of requiring a direct hit on an
|
||||
* 8px target).
|
||||
*
|
||||
* Keyboard support is new in the shared version. Both donors were pointer-only,
|
||||
* which the design system rules out outright: every action must be reachable
|
||||
* with the keyboard alone, and a `role="slider"` that does not respond to arrow
|
||||
* keys is an ARIA role that lies. Arrow keys step by one item through the
|
||||
* distribution, Home/End jump to the bounds.
|
||||
*
|
||||
* The component deals only in raw values; callers translate the extremes into
|
||||
* "no filter".
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.js";
|
||||
import { keyTarget } from "./stepped-value.js";
|
||||
export function RangeSlider({ format, hi, label, lo, onChange, values, }) {
|
||||
const [drag, setDrag] = useState(null);
|
||||
const apply = useCallback((thumb, value) => {
|
||||
if (thumb === "lo")
|
||||
onChange(Math.min(value, hi), hi);
|
||||
else
|
||||
onChange(lo, Math.max(value, lo));
|
||||
}, [hi, lo, onChange]);
|
||||
// Reads the rect off the event's own target rather than a ref: the pointer
|
||||
// handlers are bound to the track, so currentTarget *is* the track. A ref
|
||||
// would add a null branch that cannot happen once the element is mounted.
|
||||
const valueAt = useCallback((track, clientX) => {
|
||||
const fraction = fractionFromPointer(track.getBoundingClientRect(), clientX);
|
||||
return quantileValue(values, fraction);
|
||||
}, [values]);
|
||||
const onKeyDown = useCallback((thumb, event) => {
|
||||
const current = thumb === "lo" ? lo : hi;
|
||||
const target = keyTarget(event.key, values, current);
|
||||
if (target === undefined)
|
||||
return;
|
||||
event.preventDefault();
|
||||
apply(thumb, target);
|
||||
}, [apply, hi, lo, values]);
|
||||
// Fewer than two values means there is nothing to range over.
|
||||
if (values.length < 2)
|
||||
return null;
|
||||
const lowest = nth(values, 0);
|
||||
const highest = nth(values, values.length - 1);
|
||||
const loFraction = valueQuantile(values, lo) * 100;
|
||||
const hiFraction = valueQuantile(values, hi) * 100;
|
||||
const name = label ?? "Range";
|
||||
const thumbProps = (thumb) => {
|
||||
return {
|
||||
"aria-valuemax": highest,
|
||||
"aria-valuemin": lowest,
|
||||
"aria-valuenow": thumb === "lo" ? lo : hi,
|
||||
className: `slider-thumb slider-${thumb}`,
|
||||
onKeyDown: (event) => {
|
||||
onKeyDown(thumb, event);
|
||||
},
|
||||
role: "slider",
|
||||
style: { left: `${String(thumb === "lo" ? loFraction : hiFraction)}%` },
|
||||
tabIndex: 0,
|
||||
type: "button",
|
||||
};
|
||||
};
|
||||
return (_jsxs("div", { className: "slider", children: [label !== undefined && format !== undefined && (_jsxs("div", { className: "slider-head", children: [_jsx("span", { children: label }), _jsxs("span", { className: "slider-value", children: [format(lo), " \u2013 ", format(hi)] })] })), _jsxs("div", { className: "slider-track", onPointerDown: (event) => {
|
||||
const value = valueAt(event.currentTarget, event.clientX);
|
||||
// Grab whichever thumb is nearer, so a press anywhere on the track
|
||||
// does something sensible instead of requiring a direct hit.
|
||||
const thumb = Math.abs(value - lo) <= Math.abs(value - hi) ? "lo" : "hi";
|
||||
setDrag(thumb);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
apply(thumb, value);
|
||||
}, onPointerMove: (event) => {
|
||||
if (drag === null)
|
||||
return;
|
||||
apply(drag, valueAt(event.currentTarget, event.clientX));
|
||||
}, onPointerUp: (event) => {
|
||||
if (drag === null)
|
||||
return;
|
||||
setDrag(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}, children: [_jsx("div", { className: "slider-fill", style: { left: `${String(loFraction)}%`, width: `${String(hiFraction - loFraction)}%` } }), _jsx("button", { "aria-label": `${name} minimum`, ...thumbProps("lo") }), _jsx("button", { "aria-label": `${name} maximum`, ...thumbProps("hi") })] })] }));
|
||||
}
|
||||
//# sourceMappingURL=range-slider.js.map
|
||||
1
web_ui/dist/range-slider.js.map
vendored
1
web_ui/dist/range-slider.js.map
vendored
@ -1 +0,0 @@
|
||||
{"version":3,"file":"range-slider.js","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE9C,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACvF,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAuB/C,MAAM,UAAU,WAAW,CAAC,EAC1B,MAAM,EACN,EAAE,EACF,KAAK,EACL,EAAE,EACF,QAAQ,EACR,MAAM,GACW;IACjB,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAe,IAAI,CAAC,CAAC;IAErD,MAAM,KAAK,GAAG,WAAW,CACvB,CAAC,KAAY,EAAE,KAAa,EAAQ,EAAE;QACpC,IAAI,KAAK,KAAK,IAAI;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;;YACjD,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC;IACzC,CAAC,EACD,CAAC,EAAE,EAAE,EAAE,EAAE,QAAQ,CAAC,CACnB,CAAC;IAEF,2EAA2E;IAC3E,0EAA0E;IAC1E,0EAA0E;IAC1E,MAAM,OAAO,GAAG,WAAW,CACzB,CAAC,KAAkB,EAAE,OAAe,EAAU,EAAE;QAC9C,MAAM,QAAQ,GAAG,mBAAmB,CAAC,KAAK,CAAC,qBAAqB,EAAE,EAAE,OAAO,CAAC,CAAC;QAC7E,OAAO,aAAa,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACzC,CAAC,EACD,CAAC,MAAM,CAAC,CACT,CAAC;IAEF,MAAM,SAAS,GAAG,WAAW,CAC3B,CAAC,KAAY,EAAE,KAA0B,EAAQ,EAAE;QACjD,MAAM,OAAO,GAAG,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzC,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;QACrD,IAAI,MAAM,KAAK,SAAS;YAAE,OAAO;QACjC,KAAK,CAAC,cAAc,EAAE,CAAC;QACvB,KAAK,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACvB,CAAC,EACD,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,CACxB,CAAC;IAEF,8DAA8D;IAC9D,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAEnC,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAC9B,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC/C,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC;IACnD,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC;IACnD,MAAM,IAAI,GAAG,KAAK,IAAI,OAAO,CAAC;IAE9B,MAAM,UAAU,GAAG,CAAC,KAAY,EAAkC,EAAE;QAClE,OAAO;YACL,eAAe,EAAE,OAAO;YACxB,eAAe,EAAE,MAAM;YACvB,eAAe,EAAE,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE;YACzC,SAAS,EAAE,uBAAuB,KAAK,EAAE;YACzC,SAAS,EAAE,CAAC,KAAK,EAAQ,EAAE;gBACzB,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC1B,CAAC;YACD,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE;YACvE,QAAQ,EAAE,CAAC;YACX,IAAI,EAAE,QAAQ;SACf,CAAC;IACJ,CAAC,CAAC;IAEF,OAAO,CACL,eAAK,SAAS,EAAC,QAAQ,aACpB,KAAK,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,IAAI,CAC9C,eAAK,SAAS,EAAC,aAAa,aAC1B,yBAAO,KAAK,GAAQ,EACpB,gBAAM,SAAS,EAAC,cAAc,aAC3B,MAAM,CAAC,EAAE,CAAC,cAAK,MAAM,CAAC,EAAE,CAAC,IACrB,IACH,CACP,EACD,eACE,SAAS,EAAC,cAAc,EACxB,aAAa,EAAE,CAAC,KAAK,EAAQ,EAAE;oBAC7B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;oBAC1D,mEAAmE;oBACnE,6DAA6D;oBAC7D,MAAM,KAAK,GAAU,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,EAAE,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;oBAChF,OAAO,CAAC,KAAK,CAAC,CAAC;oBACf,KAAK,CAAC,aAAa,CAAC,iBAAiB,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;oBACvD,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;gBACtB,CAAC,EACD,aAAa,EAAE,CAAC,KAAK,EAAQ,EAAE;oBAC7B,IAAI,IAAI,KAAK,IAAI;wBAAE,OAAO;oBAC1B,KAAK,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;gBAC3D,CAAC,EACD,WAAW,EAAE,CAAC,KAAK,EAAQ,EAAE;oBAC3B,IAAI,IAAI,KAAK,IAAI;wBAAE,OAAO;oBAC1B,OAAO,CAAC,IAAI,CAAC,CAAC;oBACd,KAAK,CAAC,aAAa,CAAC,qBAAqB,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;gBAC7D,CAAC,aAED,cACE,SAAS,EAAC,aAAa,EACvB,KAAK,EAAE,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,UAAU,CAAC,GAAG,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC,UAAU,GAAG,UAAU,CAAC,GAAG,EAAE,GACvF,EACF,+BAAoB,GAAG,IAAI,UAAU,KAAM,UAAU,CAAC,IAAI,CAAC,GAAI,EAC/D,+BAAoB,GAAG,IAAI,UAAU,KAAM,UAAU,CAAC,IAAI,CAAC,GAAI,IAC3D,IACF,CACP,CAAC;AACJ,CAAC"}
|
||||
4
web_ui/dist/stepped-value.js
vendored
4
web_ui/dist/stepped-value.js
vendored
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The keyboard contract of the range slider, kept pure and in its own module
|
||||
* so range-slider.tsx exports only the component (react-refresh needs that
|
||||
* for HMR to keep working) and the contract is testable without layout.
|
||||
* so the element module holds only DOM wiring and the contract is testable
|
||||
* without layout.
|
||||
*/
|
||||
import { nth, valueQuantile } from "./quantile.js";
|
||||
/**
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
// The shared preset with the React layer, plus one frozen API name. This
|
||||
// The shared preset, plus one frozen API name. This
|
||||
// package is one of the repos the bar is defined for.
|
||||
import { defineConfig } from "@kuhyx/ts-config";
|
||||
|
||||
export default defineConfig({
|
||||
// Flat files under src/, no layers to fence and no alias configured.
|
||||
aliasOnly: false,
|
||||
// Generated by `astro check`, which lint runs after ESLint.
|
||||
ignores: [".astro/**"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["src/RangeSlider.astro.test.ts"],
|
||||
// Named after the component it renders, which is PascalCase on disk.
|
||||
rules: { "unicorn/filename-case": "off" },
|
||||
},
|
||||
{
|
||||
files: ["src/text.ts"],
|
||||
rules: {
|
||||
@ -20,6 +27,5 @@ export default defineConfig({
|
||||
// tsconfig.json excludes the tests so they never land in dist/; the lint
|
||||
// project widens the include for type-aware rules (see tsconfig.lint.json).
|
||||
project: ["./tsconfig.lint.json"],
|
||||
react: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kuhyx/web-ui",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "The shared token + component layer for kuhy's web apps: the frozen palette, spacing/radius/type scales as CSS custom properties, and the components that were each reimplemented in two or more repos.",
|
||||
@ -9,9 +9,10 @@
|
||||
"node": ">=24",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61",
|
||||
"packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"src/*.astro"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@ -21,33 +22,33 @@
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./tokens.css": "./dist/tokens.css",
|
||||
"./range-slider.css": "./dist/range-slider.css"
|
||||
"./range-slider.css": "./dist/range-slider.css",
|
||||
"./RangeSlider.astro": "./src/RangeSlider.astro",
|
||||
"./range-slider": "./dist/range-slider-element.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b && cp src/tokens.css src/range-slider.css dist/",
|
||||
"lint": "tsc --noEmit -p tsconfig.lint.json && eslint .",
|
||||
"lint": "tsc --noEmit -p tsconfig.lint.json && eslint . && astro check",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19"
|
||||
"astro": ">=7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "0.9.12",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@kuhyx/ts-config": "link:../ts_config",
|
||||
"@testing-library/dom": "10.4.2",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.8",
|
||||
"@types/node": "26.6.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/node": "26.6.6",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"eslint": "10.13.0",
|
||||
"astro": "7.4.4",
|
||||
"eslint": "10.14.0",
|
||||
"jsdom": "30.1.3",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
66
web_ui/src/RangeSlider.astro
Normal file
66
web_ui/src/RangeSlider.astro
Normal file
@ -0,0 +1,66 @@
|
||||
---
|
||||
/**
|
||||
* Server-rendered shell for `<range-slider>`. The `<script>` below registers
|
||||
* the element, which adopts this markup and takes over; set `.values` from a
|
||||
* client script. Pass `values` here only to render the thumbs in place.
|
||||
* `format` runs on the server and only draws the first paint of the head.
|
||||
*/
|
||||
import { nth, valueQuantile } from "../dist/quantile.js";
|
||||
|
||||
interface Props {
|
||||
format?: (value: number) => string;
|
||||
hi?: number;
|
||||
label?: string;
|
||||
lo?: number;
|
||||
values?: readonly number[];
|
||||
}
|
||||
|
||||
const { format, label, values = [] } = Astro.props;
|
||||
const ready = values.length >= 2;
|
||||
const lowest = ready ? nth(values, 0) : 0;
|
||||
const highest = ready ? nth(values, values.length - 1) : 0;
|
||||
const lo = Astro.props.lo ?? lowest;
|
||||
const hi = Astro.props.hi ?? highest;
|
||||
const loFraction = ready ? valueQuantile(values, lo) * 100 : 0;
|
||||
const hiFraction = ready ? valueQuantile(values, hi) * 100 : 100;
|
||||
const name = label ?? "Range";
|
||||
const thumbs = [
|
||||
{ aria: `${name} minimum`, fraction: loFraction, now: lo, which: "lo" },
|
||||
{ aria: `${name} maximum`, fraction: hiFraction, now: hi, which: "hi" },
|
||||
] as const;
|
||||
---
|
||||
|
||||
<range-slider lo={Astro.props.lo} hi={Astro.props.hi} label={label}>
|
||||
<div class="slider" hidden={!ready}>
|
||||
{
|
||||
label !== undefined && format !== undefined && ready && (
|
||||
<div class="slider-head">
|
||||
<span>{label}</span>
|
||||
<span class="slider-value">{format(lo)} – {format(hi)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="slider-track">
|
||||
<div class="slider-fill" style={`left: ${loFraction}%; width: ${hiFraction - loFraction}%`}></div>
|
||||
{
|
||||
thumbs.map((thumb) => (
|
||||
<button
|
||||
aria-label={thumb.aria}
|
||||
aria-valuemax={highest}
|
||||
aria-valuemin={lowest}
|
||||
aria-valuenow={thumb.now}
|
||||
class={`slider-thumb slider-${thumb.which}`}
|
||||
role="slider"
|
||||
style={`left: ${thumb.fraction}%`}
|
||||
tabindex="0"
|
||||
type="button"
|
||||
></button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</range-slider>
|
||||
|
||||
<script>
|
||||
import "../dist/range-slider-element.js";
|
||||
</script>
|
||||
73
web_ui/src/RangeSlider.astro.test.ts
Normal file
73
web_ui/src/RangeSlider.astro.test.ts
Normal file
@ -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<string, unknown>): Promise<string> {
|
||||
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("<range-slider");
|
||||
expect(html).toContain('class="slider-track"');
|
||||
expect(html).toContain('class="slider-fill"');
|
||||
});
|
||||
|
||||
it("renders two slider thumbs carrying the value trio", async () => {
|
||||
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(/<range-slider[^>]*lo="20"/u);
|
||||
expect(html).toMatch(/<range-slider[^>]*hi="70"/u);
|
||||
expect(html).toMatch(/<range-slider[^>]*label="Stars"/u);
|
||||
});
|
||||
});
|
||||
6
web_ui/src/astro-modules.d.ts
vendored
Normal file
6
web_ui/src/astro-modules.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module "*.astro" {
|
||||
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
||||
|
||||
const component: AstroComponentFactory;
|
||||
export default component;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
125
web_ui/src/range-slider-element-input.test.ts
Normal file
125
web_ui/src/range-slider-element-input.test.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
92
web_ui/src/range-slider-element-markup.test.ts
Normal file
92
web_ui/src/range-slider-element-markup.test.ts
Normal file
@ -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 = '<div class="slider-head"><span></span><span class="slider-value"></span></div>';
|
||||
|
||||
function server(beforeTrack = ""): string {
|
||||
return `<range-slider lo="20" hi="70" label="Stars"><div class="slider">${beforeTrack}
|
||||
<div class="slider-track"><div class="slider-fill"></div>
|
||||
<button class="slider-thumb slider-lo" type="button" role="slider"></button>
|
||||
<button class="slider-thumb slider-hi" type="button" role="slider"></button></div></div></range-slider>`;
|
||||
}
|
||||
|
||||
/** 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('<div class="slider-head">kept</div>'));
|
||||
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('<div class="slider-head"></div>'));
|
||||
element.values = VALUES;
|
||||
element.format = String;
|
||||
expect(element.querySelector(".slider-head")?.textContent).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
['<range-slider><div class="slider"></div></range-slider>', /missing \.slider-fill/u],
|
||||
[
|
||||
'<range-slider><div class="slider"><div class="slider-fill"></div></div></range-slider>',
|
||||
/missing \.slider-hi/u,
|
||||
],
|
||||
[
|
||||
'<range-slider><div class="slider"><div class="slider-fill"></div><button class="slider-hi"></button></div></range-slider>',
|
||||
/missing \.slider-lo/u,
|
||||
],
|
||||
[
|
||||
'<range-slider><div class="slider"><div class="slider-fill"></div><button class="slider-hi"></button><button class="slider-lo"></button></div></range-slider>',
|
||||
/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");
|
||||
});
|
||||
});
|
||||
130
web_ui/src/range-slider-element.test.ts
Normal file
130
web_ui/src/range-slider-element.test.ts
Normal file
@ -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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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*");
|
||||
});
|
||||
});
|
||||
|
||||
231
web_ui/src/range-slider-element.ts
Normal file
231
web_ui/src/range-slider-element.ts
Normal file
@ -0,0 +1,231 @@
|
||||
/**
|
||||
* `<range-slider>`: 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<K extends keyof HTMLElementTagNameMap>(
|
||||
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<T extends Element>(found: null | T, selector: string): T {
|
||||
if (found === null) throw new Error(`<range-slider> markup is missing ${selector}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function adoptParts(host: Element): Parts {
|
||||
const root = required(host.querySelector<HTMLElement>(".slider"), ".slider");
|
||||
return {
|
||||
fill: required(root.querySelector<HTMLElement>(".slider-fill"), ".slider-fill"),
|
||||
hi: required(root.querySelector<HTMLButtonElement>(".slider-hi"), ".slider-hi"),
|
||||
lo: required(root.querySelector<HTMLButtonElement>(".slider-lo"), ".slider-lo"),
|
||||
root,
|
||||
track: required(root.querySelector<HTMLElement>(".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<RangeChange>("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<HTMLElement>(".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);
|
||||
}
|
||||
@ -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<React.ComponentProps<typeof RangeSlider>> = {},
|
||||
): { onChange: ReturnType<typeof vi.fn> } {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<RangeSlider
|
||||
hi={90}
|
||||
lo={0}
|
||||
onChange={onChange}
|
||||
values={VALUES}
|
||||
{...properties}
|
||||
/>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
function thumbs(): HTMLElement[] {
|
||||
return screen.getAllByRole("slider");
|
||||
}
|
||||
|
||||
describe("RangeSlider", () => {
|
||||
it("renders nothing when there is nothing to range over", () => {
|
||||
const { container } = render(<RangeSlider hi={1} lo={1} onChange={vi.fn()} values={[1]} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 `<button>` thumbs carrying `role="slider"` and the `aria-value*` trio,
|
||||
* so the control is a focus stop) and the grab-the-nearer-thumb pointer
|
||||
* behaviour from dufs-cloud (pressing anywhere on the track moves the closer
|
||||
* handle, instead of requiring a direct hit on an
|
||||
* 8px target).
|
||||
*
|
||||
* Keyboard support is new in the shared version. Both donors were pointer-only,
|
||||
* which the design system rules out outright: every action must be reachable
|
||||
* with the keyboard alone, and a `role="slider"` that does not respond to arrow
|
||||
* keys is an ARIA role that lies. Arrow keys step by one item through the
|
||||
* distribution, Home/End jump to the bounds.
|
||||
*
|
||||
* The component deals only in raw values; callers translate the extremes into
|
||||
* "no filter".
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts";
|
||||
import { keyTarget } from "./stepped-value.ts";
|
||||
|
||||
export interface RangeSliderProps {
|
||||
/**
|
||||
* Formats a value for the head readout. Omit it (with `label`) to render a
|
||||
* bare track — dufs-cloud's wrappers draw their own labels and value notes,
|
||||
* so a built-in head would duplicate them.
|
||||
*/
|
||||
readonly format?: (value: number) => string;
|
||||
/** Current upper bound; equal to the distribution max when unconstrained. */
|
||||
readonly hi: number;
|
||||
/** Accessible name. Falls back to a generic one when omitted. */
|
||||
readonly label?: string;
|
||||
/** Current lower bound; equal to the distribution min when unconstrained. */
|
||||
readonly lo: number;
|
||||
/** Reports both thumb values (raw, clamped so lo <= hi) on every change. */
|
||||
readonly onChange: (lo: number, hi: number) => void;
|
||||
/** The full ascending distribution — not just its endpoints. */
|
||||
readonly values: readonly number[];
|
||||
}
|
||||
|
||||
type Thumb = "hi" | "lo";
|
||||
|
||||
export function RangeSlider({
|
||||
format,
|
||||
hi,
|
||||
label,
|
||||
lo,
|
||||
onChange,
|
||||
values,
|
||||
}: RangeSliderProps): null | React.JSX.Element {
|
||||
const [drag, setDrag] = useState<null | Thumb>(null);
|
||||
|
||||
const apply = useCallback(
|
||||
(thumb: Thumb, value: number): void => {
|
||||
if (thumb === "lo") onChange(Math.min(value, hi), hi);
|
||||
else onChange(lo, Math.max(value, lo));
|
||||
},
|
||||
[hi, lo, onChange],
|
||||
);
|
||||
|
||||
// Reads the rect off the event's own target rather than a ref: the pointer
|
||||
// handlers are bound to the track, so currentTarget *is* the track. A ref
|
||||
// would add a null branch that cannot happen once the element is mounted.
|
||||
const valueAt = useCallback(
|
||||
(track: HTMLElement, clientX: number): number => {
|
||||
const fraction = fractionFromPointer(track.getBoundingClientRect(), clientX);
|
||||
return quantileValue(values, fraction);
|
||||
},
|
||||
[values],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(thumb: Thumb, event: React.KeyboardEvent): void => {
|
||||
const current = thumb === "lo" ? lo : hi;
|
||||
const target = keyTarget(event.key, values, current);
|
||||
if (target === undefined) return;
|
||||
event.preventDefault();
|
||||
apply(thumb, target);
|
||||
},
|
||||
[apply, hi, lo, values],
|
||||
);
|
||||
|
||||
// Fewer than two values means there is nothing to range over.
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const lowest = nth(values, 0);
|
||||
const highest = nth(values, values.length - 1);
|
||||
const loFraction = valueQuantile(values, lo) * 100;
|
||||
const hiFraction = valueQuantile(values, hi) * 100;
|
||||
const name = label ?? "Range";
|
||||
|
||||
const thumbProps = (thumb: Thumb): React.ComponentProps<"button"> => {
|
||||
return {
|
||||
"aria-valuemax": highest,
|
||||
"aria-valuemin": lowest,
|
||||
"aria-valuenow": thumb === "lo" ? lo : hi,
|
||||
className: `slider-thumb slider-${thumb}`,
|
||||
onKeyDown: (event): void => {
|
||||
onKeyDown(thumb, event);
|
||||
},
|
||||
role: "slider",
|
||||
style: { left: `${String(thumb === "lo" ? loFraction : hiFraction)}%` },
|
||||
tabIndex: 0,
|
||||
type: "button",
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="slider">
|
||||
{label !== undefined && format !== undefined && (
|
||||
<div className="slider-head">
|
||||
<span>{label}</span>
|
||||
<span className="slider-value">
|
||||
{format(lo)} – {format(hi)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="slider-track"
|
||||
onPointerDown={(event): void => {
|
||||
const value = valueAt(event.currentTarget, event.clientX);
|
||||
// Grab whichever thumb is nearer, so a press anywhere on the track
|
||||
// does something sensible instead of requiring a direct hit.
|
||||
const thumb: Thumb = Math.abs(value - lo) <= Math.abs(value - hi) ? "lo" : "hi";
|
||||
setDrag(thumb);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
apply(thumb, value);
|
||||
}}
|
||||
onPointerMove={(event): void => {
|
||||
if (drag === null) return;
|
||||
apply(drag, valueAt(event.currentTarget, event.clientX));
|
||||
}}
|
||||
onPointerUp={(event): void => {
|
||||
if (drag === null) return;
|
||||
setDrag(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="slider-fill"
|
||||
style={{ left: `${String(loFraction)}%`, width: `${String(hiFraction - loFraction)}%` }}
|
||||
/>
|
||||
<button aria-label={`${name} minimum`} {...thumbProps("lo")} />
|
||||
<button aria-label={`${name} maximum`} {...thumbProps("hi")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The keyboard contract of the range slider, kept pure and in its own module
|
||||
* so range-slider.tsx exports only the component (react-refresh needs that
|
||||
* for HMR to keep working) and the contract is testable without layout.
|
||||
* so the element module holds only DOM wiring and the contract is testable
|
||||
* without layout.
|
||||
*/
|
||||
import { nth, valueQuantile } from "./quantile.ts";
|
||||
|
||||
|
||||
36
web_ui/src/test/slider-helpers.ts
Normal file
36
web_ui/src/test/slider-helpers.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { RangeSliderElement } from "../range-slider-element.ts";
|
||||
|
||||
export const VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
|
||||
export const TRACK_WIDTH = 1000;
|
||||
|
||||
export interface Mounted {
|
||||
readonly changes: { hi: number; lo: number }[];
|
||||
readonly element: RangeSliderElement;
|
||||
}
|
||||
|
||||
export function mount(
|
||||
attributes: Record<string, string> = {},
|
||||
values: readonly number[] = VALUES,
|
||||
): Mounted {
|
||||
const element = new RangeSliderElement();
|
||||
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
|
||||
const changes: { hi: number; lo: number }[] = [];
|
||||
element.addEventListener("change", (event) => {
|
||||
if (event instanceof CustomEvent) changes.push(event.detail as { hi: number; lo: number });
|
||||
});
|
||||
document.body.append(element);
|
||||
element.values = values;
|
||||
return { changes, element };
|
||||
}
|
||||
|
||||
export function thumb(element: Element, which: "hi" | "lo"): HTMLElement {
|
||||
const found = element.querySelector<HTMLElement>(`.slider-${which}`);
|
||||
if (found === null) throw new Error(`no ${which} thumb`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function track(element: Element): HTMLElement {
|
||||
const found = element.querySelector<HTMLElement>(".slider-track");
|
||||
if (found === null) throw new Error("no track");
|
||||
return found;
|
||||
}
|
||||
@ -6,7 +6,6 @@
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals"],
|
||||
|
||||
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
/// <reference types="vitest/config" />
|
||||
import { getViteConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
export default getViteConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: ["src/index.ts", "src/**/*.d.ts"],
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/index.ts", "src/test/**", "src/**/*.d.ts"],
|
||||
include: ["src/**/*.ts"],
|
||||
provider: "v8",
|
||||
// The same bar Phase 1's design_system holds: a shared layer that two
|
||||
// repos depend on does not get to be the least-tested code in the tree.
|
||||
// A shared layer that several repos depend on does not get to be the
|
||||
// least-tested code in the tree. `.astro` files are outside `include`:
|
||||
// the component is markup, covered by RangeSlider.astro.test.ts.
|
||||
thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 },
|
||||
},
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user