mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:47:20 +01:00
web_ui: shared web token + component layer (Phase 3)
The web counterpart to design_system (Dart): one :root block of frozen tokens plus the components that were duplicated across repos. Extracted from dufs-cloud and awesome-mcp-explorer, which both now consume it. - tokens.css: the frozen palette, spacing/radius/type scales, shadow policy and focus ring, under the canonical variable names from unified-design-system. Also ships the global :focus-visible rule, rather than leaving each consumer to remember it. - RangeSlider: reconciled from the two drifted copies. Takes the accessible DOM from awesome-mcp-explorer (real <button> thumbs with role="slider" and the aria-value* trio) and grab-the-nearer-thumb from dufs-cloud. Keyboard support (arrows/PageUp/PageDown/Home/End) is new: both donors were pointer-only, which the design system rules out, and an arrow-key-deaf role="slider" is an ARIA role that lies. - quantile.ts + fuzzyMatch: the genuinely duplicated helpers. FilterBar is deliberately absent. The two same-named components share no structure -- a horizontal <select> strip over files vs a vertical tri-state chip sidebar over servers, with disjoint FilterState, props and sort keys. It is the name-collision-is-not-a-duplicate trap, so extracting it would ship a widget nobody imports. dist/ is committed (and un-ignored against the root rule) because Vite does not transpile dependencies; consumers stay at zero config. Consumed via pnpm's &path:/web_ui suffix -- npm cannot install from a subdirectory of a git repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
parent
d25f2885da
commit
03bf1ed08b
10
web_ui/.gitignore
vendored
Normal file
10
web_ui/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# dist/ is deliberately NOT ignored here: consumers pull this package as a git
|
||||
# dep and Vite does not transpile dependencies, so the built ESM + .d.ts must be
|
||||
# committed in the tag. See README.md. The repo root ignores `dist/` globally,
|
||||
# so this un-ignores it for this subtree only — without it, `git add web_ui`
|
||||
# silently skips the build and the tag ships a package with no code in it.
|
||||
!dist/
|
||||
!dist/**
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
94
web_ui/README.md
Normal file
94
web_ui/README.md
Normal file
@ -0,0 +1,94 @@
|
||||
# @kuhyx/web-ui
|
||||
|
||||
The shared token + component layer for kuhy's web apps — the web counterpart to
|
||||
`~/utils/design_system` (Dart). Owns the frozen palette, the spacing/radius/type
|
||||
scales, and the components that were each reimplemented in two or more repos.
|
||||
|
||||
Consumers import the tokens rather than transcribing the token table by hand.
|
||||
Source of truth for the *values* is `~/utils/unified-design-system/tokens.md`;
|
||||
this package is its executable form for the web.
|
||||
|
||||
## Install
|
||||
|
||||
```jsonc
|
||||
// package.json — pnpm only, see "Why the &path: suffix" below.
|
||||
"dependencies": {
|
||||
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.1.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
|
||||
|
||||
import { RangeSlider, fuzzyMatch, quantileValue } from "@kuhyx/web-ui";
|
||||
```
|
||||
|
||||
### Why the `&path:/web_ui` suffix
|
||||
|
||||
`utils` is a monorepo with no root `package.json`, and **npm cannot install from
|
||||
a subdirectory of a git repo** — `github:user/repo#tag` clones the root, looks
|
||||
for `package.json` there, and fails with `ENOENT` (verified against npm 11.16).
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## 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. |
|
||||
| `range-slider.css` | Its styling, entirely in tokens. |
|
||||
| `quantile.ts` helpers | `nth`, `clamp01`, `quantileValue`, `valueQuantile`, `fractionFromPointer`. |
|
||||
| `fuzzyMatch` | Case-insensitive subsequence match. |
|
||||
|
||||
`fractionFromPointer` is deliberately pure geometry: it makes the slider's
|
||||
behaviour testable without layout, which jsdom does not implement.
|
||||
|
||||
### RangeSlider 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
|
||||
arrow keys is an ARIA role that lies — so the shared version adds:
|
||||
|
||||
| Key | Effect |
|
||||
| --- | --- |
|
||||
| `←` `↓` / `→` `↑` | Step one sample through the distribution |
|
||||
| `PageDown` / `PageUp` | Step ten samples |
|
||||
| `Home` / `End` | Jump to the distribution's bounds |
|
||||
|
||||
Pass `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.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm test # vitest
|
||||
pnpm coverage # 100% branches/functions/lines/statements, enforced
|
||||
pnpm lint # tsc --noEmit && eslint
|
||||
pnpm build # tsc -b + copy CSS into dist/
|
||||
```
|
||||
|
||||
The 100% bar matches Phase 1's `design_system`: a shared layer two repos depend
|
||||
on does not get to be the least-tested code in the tree.
|
||||
|
||||
## Releasing
|
||||
|
||||
```bash
|
||||
pnpm test && pnpm build
|
||||
git add web_ui && git commit -m "web_ui: <what changed>"
|
||||
git tag web_ui-v<major>.<minor>.<patch> && git push origin main --tags
|
||||
```
|
||||
|
||||
Then bump the tag in each consumer's `package.json` and re-run `pnpm install`.
|
||||
10
web_ui/dist/index.d.ts
vendored
Normal file
10
web_ui/dist/index.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The shared web token + component layer.
|
||||
*
|
||||
* `tokens.css` is not re-exported here — CSS is imported by path:
|
||||
* `import "@kuhyx/web-ui/tokens.css";`
|
||||
*/
|
||||
export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts";
|
||||
export { RangeSlider, steppedValue, type RangeSliderProps } from "./range-slider.tsx";
|
||||
export { fuzzyMatch } from "./text.ts";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
web_ui/dist/index.d.ts.map
vendored
Normal file
1
web_ui/dist/index.d.ts.map
vendored
Normal file
@ -0,0 +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,YAAY,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
|
||||
10
web_ui/dist/index.js
vendored
Normal file
10
web_ui/dist/index.js
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The shared web token + component layer.
|
||||
*
|
||||
* `tokens.css` is not re-exported here — CSS is imported by path:
|
||||
* `import "@kuhyx/web-ui/tokens.css";`
|
||||
*/
|
||||
export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.js";
|
||||
export { RangeSlider, steppedValue } from "./range-slider.js";
|
||||
export { fuzzyMatch } from "./text.js";
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
web_ui/dist/index.js.map
vendored
Normal file
1
web_ui/dist/index.js.map
vendored
Normal file
@ -0,0 +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,EAAE,YAAY,EAAyB,MAAM,mBAAoB,CAAC;AACtF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
|
||||
43
web_ui/dist/quantile.d.ts
vendored
Normal file
43
web_ui/dist/quantile.d.ts
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Distribution-aware slider maths.
|
||||
*
|
||||
* A linear min..max track is useless on skewed data, which is the normal case
|
||||
* in both consumers: dufs-cloud's file sizes span bytes to gigabytes, and
|
||||
* awesome-mcp-explorer's star counts run 0..166,785 with a median of 8, so a
|
||||
* linear midpoint sits above 99.9% of all rows. Mapping the track through the
|
||||
* actual distribution puts the middle of the track at the median, which makes
|
||||
* equal track distance mean roughly equal numbers of items.
|
||||
*
|
||||
* This was duplicated in dufs-cloud and awesome-mcp-explorer before it moved
|
||||
* here; both now import it.
|
||||
*/
|
||||
/**
|
||||
* Bounds-checked array access (callers guarantee the index is valid). Keeps the
|
||||
* quantile helpers free of scattered non-null assertions or dead undefined
|
||||
* guards while still satisfying `noUncheckedIndexedAccess`.
|
||||
*/
|
||||
export declare function nth(values: readonly number[], index: number): number;
|
||||
/** Clamp to the unit interval. */
|
||||
export declare function clamp01(x: number): number;
|
||||
/**
|
||||
* The value at quantile `f` (0..1) of an ascending, non-empty array, with linear
|
||||
* interpolation between the two straddling samples. This is what makes the
|
||||
* sliders distribution-aware: f = 0.5 returns the *median*, so the middle of the
|
||||
* track sits where half the items are — not the arithmetic midpoint of the
|
||||
* range. Empty input yields 0 (callers hide the slider in that case).
|
||||
*/
|
||||
export declare function quantileValue(sorted: readonly number[], f: number): number;
|
||||
/**
|
||||
* The quantile (0..1) of `value` within `sorted` — the inverse of
|
||||
* {@link quantileValue}, used to place a thumb at the right spot on the track.
|
||||
* Values at or beyond the ends map to 0/1.
|
||||
*/
|
||||
export declare function valueQuantile(sorted: readonly number[], value: number): number;
|
||||
/**
|
||||
* Pointer x to a 0..1 fraction of a track.
|
||||
*
|
||||
* Extracted as pure geometry so slider behaviour is testable without layout,
|
||||
* which jsdom does not implement.
|
||||
*/
|
||||
export declare function fractionFromPointer(rect: DOMRect, clientX: number): number;
|
||||
//# sourceMappingURL=quantile.d.ts.map
|
||||
1
web_ui/dist/quantile.d.ts.map
vendored
Normal file
1
web_ui/dist/quantile.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"quantile.d.ts","sourceRoot":"","sources":["../src/quantile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMpE;AAED,kCAAkC;AAClC,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAS1E;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAa9E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1E"}
|
||||
79
web_ui/dist/quantile.js
vendored
Normal file
79
web_ui/dist/quantile.js
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Distribution-aware slider maths.
|
||||
*
|
||||
* A linear min..max track is useless on skewed data, which is the normal case
|
||||
* in both consumers: dufs-cloud's file sizes span bytes to gigabytes, and
|
||||
* awesome-mcp-explorer's star counts run 0..166,785 with a median of 8, so a
|
||||
* linear midpoint sits above 99.9% of all rows. Mapping the track through the
|
||||
* actual distribution puts the middle of the track at the median, which makes
|
||||
* equal track distance mean roughly equal numbers of items.
|
||||
*
|
||||
* This was duplicated in dufs-cloud and awesome-mcp-explorer before it moved
|
||||
* here; both now import it.
|
||||
*/
|
||||
/**
|
||||
* Bounds-checked array access (callers guarantee the index is valid). Keeps the
|
||||
* quantile helpers free of scattered non-null assertions or dead undefined
|
||||
* guards while still satisfying `noUncheckedIndexedAccess`.
|
||||
*/
|
||||
export function nth(values, index) {
|
||||
const value = values[index];
|
||||
if (value === undefined) {
|
||||
throw new RangeError(`index ${index} out of range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
/** Clamp to the unit interval. */
|
||||
export function clamp01(x) {
|
||||
return Math.min(1, Math.max(0, x));
|
||||
}
|
||||
/**
|
||||
* The value at quantile `f` (0..1) of an ascending, non-empty array, with linear
|
||||
* interpolation between the two straddling samples. This is what makes the
|
||||
* sliders distribution-aware: f = 0.5 returns the *median*, so the middle of the
|
||||
* track sits where half the items are — not the arithmetic midpoint of the
|
||||
* range. Empty input yields 0 (callers hide the slider in that case).
|
||||
*/
|
||||
export function quantileValue(sorted, f) {
|
||||
const n = sorted.length;
|
||||
if (n === 0)
|
||||
return 0;
|
||||
const pos = clamp01(f) * (n - 1);
|
||||
const lo = Math.floor(pos);
|
||||
const hi = Math.ceil(pos);
|
||||
const a = nth(sorted, lo);
|
||||
const b = nth(sorted, hi);
|
||||
return Math.round(a + (b - a) * (pos - lo));
|
||||
}
|
||||
/**
|
||||
* The quantile (0..1) of `value` within `sorted` — the inverse of
|
||||
* {@link quantileValue}, used to place a thumb at the right spot on the track.
|
||||
* Values at or beyond the ends map to 0/1.
|
||||
*/
|
||||
export function valueQuantile(sorted, value) {
|
||||
const n = sorted.length;
|
||||
if (n <= 1)
|
||||
return 0;
|
||||
if (value <= nth(sorted, 0))
|
||||
return 0;
|
||||
if (value >= nth(sorted, n - 1))
|
||||
return 1;
|
||||
// value strictly inside the range, so there is an index hi >= 1 with
|
||||
// sorted[hi] >= value and sorted[hi - 1] < value.
|
||||
const hi = sorted.findIndex((v) => v >= value);
|
||||
const lo = hi - 1;
|
||||
const a = nth(sorted, lo);
|
||||
const b = nth(sorted, hi);
|
||||
// a < value <= b here, so b - a > 0 (no divide-by-zero).
|
||||
return (lo + (value - a) / (b - a)) / (n - 1);
|
||||
}
|
||||
/**
|
||||
* Pointer x to a 0..1 fraction of a track.
|
||||
*
|
||||
* Extracted as pure geometry so slider behaviour is testable without layout,
|
||||
* which jsdom does not implement.
|
||||
*/
|
||||
export function fractionFromPointer(rect, clientX) {
|
||||
return rect.width === 0 ? 0 : clamp01((clientX - rect.left) / rect.width);
|
||||
}
|
||||
//# sourceMappingURL=quantile.js.map
|
||||
1
web_ui/dist/quantile.js.map
vendored
Normal file
1
web_ui/dist/quantile.js.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"quantile.js","sourceRoot":"","sources":["../src/quantile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;GAIG;AACH,MAAM,UAAU,GAAG,CAAC,MAAyB,EAAE,KAAa;IAC1D,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC5B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,UAAU,CAAC,SAAS,KAAK,eAAe,CAAC,CAAC;IACtD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,kCAAkC;AAClC,MAAM,UAAU,OAAO,CAAC,CAAS;IAC/B,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,MAAyB,EAAE,CAAS;IAChE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;IACxB,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACtB,MAAM,GAAG,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACjC,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC;AAC9C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,MAAyB,EAAE,KAAa;IACpE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;IACxB,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC;IACrB,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACtC,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1C,qEAAqE;IACrE,kDAAkD;IAClD,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC;IAC/C,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;AAChD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAa,EAAE,OAAe;IAChE,OAAO,IAAI,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;AAC5E,CAAC"}
|
||||
68
web_ui/dist/range-slider.css
vendored
Normal file
68
web_ui/dist/range-slider.css
vendored
Normal file
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Styling for the shared RangeSlider. Imported separately from tokens.css:
|
||||
* import "@kuhyx/web-ui/tokens.css";
|
||||
* import "@kuhyx/web-ui/range-slider.css";
|
||||
*
|
||||
* Every value routes through a token — no literals.
|
||||
*/
|
||||
|
||||
.slider-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-caption);
|
||||
letter-spacing: var(--tracking-caption);
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
/* Tabular figures so a dragging readout does not reflow the row. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
height: 22px;
|
||||
margin-top: var(--space-1);
|
||||
/* Let the element own the gesture instead of the page scrolling under it. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.slider-track::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: -7px;
|
||||
padding: 0;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The thumbs are the keyboard target, so the ring must be unmistakable. The
|
||||
* global :focus-visible rule in tokens.css already colours it; the wider offset
|
||||
* here keeps it clear of the 14px circle. */
|
||||
.slider-thumb:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
23
web_ui/dist/range-slider.d.ts
vendored
Normal file
23
web_ui/dist/range-slider.d.ts
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
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[];
|
||||
}
|
||||
/** Steps one thumb by `delta` positions through the distribution. Pure, so the
|
||||
* keyboard contract is testable without layout. */
|
||||
export declare function steppedValue(values: readonly number[], current: number, delta: number): 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
Normal file
1
web_ui/dist/range-slider.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"range-slider.d.ts","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"AAyBA,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;mDACmD;AACnD,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,MAAM,CAMR;AAED,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,CAyG7C"}
|
||||
109
web_ui/dist/range-slider.js
vendored
Normal file
109
web_ui/dist/range-slider.js
vendored
Normal file
@ -0,0 +1,109 @@
|
||||
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";
|
||||
/** Steps one thumb by `delta` positions through the distribution. Pure, so the
|
||||
* keyboard contract is testable without layout. */
|
||||
export function steppedValue(values, current, delta) {
|
||||
const last = values.length - 1;
|
||||
// Round to a real sample index so a thumb sitting between two samples still
|
||||
// advances by exactly one item rather than stalling on a fractional step.
|
||||
const index = Math.round(valueQuantile(values, current) * last);
|
||||
return nth(values, Math.min(last, Math.max(0, index + delta)));
|
||||
}
|
||||
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) => quantileValue(values, fractionFromPointer(track.getBoundingClientRect(), clientX)), [values]);
|
||||
const onKeyDown = useCallback((thumb, event) => {
|
||||
const current = thumb === "lo" ? lo : hi;
|
||||
const step = (delta) => {
|
||||
event.preventDefault();
|
||||
apply(thumb, steppedValue(values, current, delta));
|
||||
};
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowDown")
|
||||
step(-1);
|
||||
else if (event.key === "ArrowRight" || event.key === "ArrowUp")
|
||||
step(1);
|
||||
else if (event.key === "PageDown")
|
||||
step(-10);
|
||||
else if (event.key === "PageUp")
|
||||
step(10);
|
||||
else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
apply(thumb, nth(values, 0));
|
||||
}
|
||||
else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
apply(thumb, nth(values, values.length - 1));
|
||||
}
|
||||
}, [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) => ({
|
||||
"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
Normal file
1
web_ui/dist/range-slider.js.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"range-slider.js","sourceRoot":"","sources":["../src/range-slider.tsx"],"names":[],"mappings":"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"}
|
||||
16
web_ui/dist/text.d.ts
vendored
Normal file
16
web_ui/dist/text.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Small text helpers shared by the consumers' filter layers.
|
||||
*
|
||||
* Hand-rolled rather than pulling in Fuse.js, matching the house posture of no
|
||||
* runtime dependencies for things this small.
|
||||
*/
|
||||
/**
|
||||
* Case-insensitive subsequence match: every character of `query` appears in
|
||||
* `target`, in order, though not necessarily adjacently. An empty query matches
|
||||
* everything, so callers can pass the raw search box value straight through.
|
||||
*
|
||||
* dufs-cloud called this `fuzzyMatch` and awesome-mcp-explorer `matchesFuzzy`;
|
||||
* the implementations were identical apart from identifier names.
|
||||
*/
|
||||
export declare function fuzzyMatch(query: string, target: string): boolean;
|
||||
//# sourceMappingURL=text.d.ts.map
|
||||
1
web_ui/dist/text.d.ts.map
vendored
Normal file
1
web_ui/dist/text.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CASjE"}
|
||||
27
web_ui/dist/text.js
vendored
Normal file
27
web_ui/dist/text.js
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Small text helpers shared by the consumers' filter layers.
|
||||
*
|
||||
* Hand-rolled rather than pulling in Fuse.js, matching the house posture of no
|
||||
* runtime dependencies for things this small.
|
||||
*/
|
||||
/**
|
||||
* Case-insensitive subsequence match: every character of `query` appears in
|
||||
* `target`, in order, though not necessarily adjacently. An empty query matches
|
||||
* everything, so callers can pass the raw search box value straight through.
|
||||
*
|
||||
* dufs-cloud called this `fuzzyMatch` and awesome-mcp-explorer `matchesFuzzy`;
|
||||
* the implementations were identical apart from identifier names.
|
||||
*/
|
||||
export function fuzzyMatch(query, target) {
|
||||
if (query === "")
|
||||
return true;
|
||||
const q = query.toLowerCase();
|
||||
const t = target.toLowerCase();
|
||||
let index = 0;
|
||||
for (let scan = 0; scan < t.length && index < q.length; scan++) {
|
||||
if (t[scan] === q[index])
|
||||
index++;
|
||||
}
|
||||
return index === q.length;
|
||||
}
|
||||
//# sourceMappingURL=text.js.map
|
||||
1
web_ui/dist/text.js.map
vendored
Normal file
1
web_ui/dist/text.js.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"text.js","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,MAAc;IACtD,IAAI,KAAK,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,CAAC,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;IAC9B,MAAM,CAAC,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC;IAC/B,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,MAAM,IAAI,KAAK,GAAG,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;QAC/D,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC;YAAE,KAAK,EAAE,CAAC;IACpC,CAAC;IACD,OAAO,KAAK,KAAK,CAAC,CAAC,MAAM,CAAC;AAC5B,CAAC"}
|
||||
112
web_ui/dist/tokens.css
vendored
Normal file
112
web_ui/dist/tokens.css
vendored
Normal file
@ -0,0 +1,112 @@
|
||||
/*
|
||||
* The frozen design tokens, shared by every one of kuhy's web repos.
|
||||
*
|
||||
* Source of truth: ~/utils/unified-design-system/tokens.md (values) and
|
||||
* README.md (the CSS custom-property names). The same numbers ship for Dart in
|
||||
* ~/utils/design_system, so a change here is a change there — do not edit one
|
||||
* side alone, and do not introduce a value that is not on a scale below.
|
||||
*
|
||||
* Dark is the default; light is an override, matching the README.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Neutrals ------------------------------------------------------------- */
|
||||
--bg: #211d1b;
|
||||
--surface-1: #2b2624;
|
||||
--surface-2: #38312e;
|
||||
--border: #463e3a;
|
||||
--text: #eceae9;
|
||||
--text-muted: #aaa09a;
|
||||
|
||||
/* Accent + semantic roles ---------------------------------------------- */
|
||||
--accent: #b8862e;
|
||||
--info: #b8862e; /* = accent */
|
||||
--success: #8a9a3c;
|
||||
--warning: #e0a63c;
|
||||
--danger: #e2585f;
|
||||
|
||||
/* Text/icons ON a filled accent/success/warning/danger surface. Never
|
||||
* --text: every semantic fill sits in the same mid-light band, where
|
||||
* near-white measures 2.2:1-3.6:1 against a 4.5:1 floor. */
|
||||
--on-fill: #211d1b;
|
||||
/* Text/icons on a dark translucent scrim/overlay — fixed, not theme-tied,
|
||||
* because the scrim itself is always dark. */
|
||||
--on-scrim: #eceae9;
|
||||
|
||||
/* Corner radius --------------------------------------------------------- */
|
||||
--radius-sm: 8px; /* buttons, inputs, chips */
|
||||
--radius-md: 12px; /* cards */
|
||||
--radius-lg: 16px; /* sheets, dialogs */
|
||||
|
||||
/* Spacing (4px base) ---------------------------------------------------- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 16px;
|
||||
--space-4: 24px;
|
||||
--space-5: 32px;
|
||||
--space-6: 48px;
|
||||
|
||||
/* Typography ------------------------------------------------------------ */
|
||||
--font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", monospace;
|
||||
|
||||
--text-display: 32px;
|
||||
--text-title: 24px;
|
||||
--text-subtitle: 20px;
|
||||
--text-body: 16px; /* the floor for anything a user actually reads */
|
||||
--text-label: 14px; /* UI chrome only */
|
||||
--text-caption: 12px; /* UI chrome only */
|
||||
|
||||
--tracking-display: -0.2px;
|
||||
--tracking-title: -0.1px;
|
||||
--tracking-label: 0.05em;
|
||||
--tracking-caption: 0.1em;
|
||||
|
||||
/* Prose line-length cap (rule 21): ~65-70 characters. */
|
||||
--measure: 40rem;
|
||||
|
||||
/* Shadows: light surfaces only, and only on floating/overlay elements —
|
||||
* never inline cards or list rows. Blur = 2x offset, tinted from ink. */
|
||||
--shadow-1: 0 2px 4px rgba(33, 29, 27, 0.12); /* dropdown */
|
||||
--shadow-2: 0 4px 8px rgba(33, 29, 27, 0.16); /* modal */
|
||||
|
||||
/* Focus ring: the platform default is black, invisible on --bg. */
|
||||
--focus-ring: #b8862e; /* = accent */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f4f3;
|
||||
--surface-1: #fcfbfb;
|
||||
--surface-2: #fcfbfb;
|
||||
--border: #e0dad7;
|
||||
--text: #211d1b;
|
||||
--text-muted: #70625b;
|
||||
/* --on-fill / --on-scrim stay fixed — the fills and the scrim do not
|
||||
* change with page theme. */
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark surfaces take no shadow at all; elevation is a fill step. Light
|
||||
* surfaces opt in explicitly via --shadow-1 / --shadow-2. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--shadow-1: none;
|
||||
--shadow-2: none;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* Ships here rather than being left to each consumer: an unstyled focus ring
|
||||
* is the accessibility regression this layer exists to prevent, and several of
|
||||
* these apps are keyboard-only by necessity. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
51
web_ui/package.json
Normal file
51
web_ui/package.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@kuhyx/web-ui",
|
||||
"version": "0.1.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.",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./tokens.css": "./dist/tokens.css",
|
||||
"./range-slider.css": "./dist/range-slider.css"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b && cp src/tokens.css src/range-slider.css dist/",
|
||||
"lint": "tsc --noEmit && eslint src",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
2164
web_ui/pnpm-lock.yaml
Normal file
2164
web_ui/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
9
web_ui/src/index.ts
Normal file
9
web_ui/src/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The shared web token + component layer.
|
||||
*
|
||||
* `tokens.css` is not re-exported here — CSS is imported by path:
|
||||
* `import "@kuhyx/web-ui/tokens.css";`
|
||||
*/
|
||||
export { clamp01, fractionFromPointer, nth, quantileValue, valueQuantile } from "./quantile.ts";
|
||||
export { RangeSlider, steppedValue, type RangeSliderProps } from "./range-slider.tsx";
|
||||
export { fuzzyMatch } from "./text.ts";
|
||||
108
web_ui/src/quantile.test.ts
Normal file
108
web_ui/src/quantile.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clamp01,
|
||||
fractionFromPointer,
|
||||
nth,
|
||||
quantileValue,
|
||||
valueQuantile,
|
||||
} from "./quantile.ts";
|
||||
|
||||
describe("nth", () => {
|
||||
it("reads a value", () => {
|
||||
expect(nth([5, 6, 7], 1)).toBe(6);
|
||||
});
|
||||
|
||||
it("throws for an index out of range", () => {
|
||||
expect(() => nth([1], 5)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp01", () => {
|
||||
it("passes a value already in range", () => {
|
||||
expect(clamp01(0.5)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("clamps both ends", () => {
|
||||
expect(clamp01(-1)).toBe(0);
|
||||
expect(clamp01(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quantileValue", () => {
|
||||
it("returns 0 for an empty distribution", () => {
|
||||
expect(quantileValue([], 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the ends at f=0 and f=1", () => {
|
||||
expect(quantileValue([1, 2, 100], 0)).toBe(1);
|
||||
expect(quantileValue([1, 2, 100], 1)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns the MEDIAN at f=0.5, not the linear midpoint", () => {
|
||||
// The whole point of the quantile mapping, on a distribution shaped like
|
||||
// the real star counts (1 .. 59k, long tail). The linear midpoint of that
|
||||
// range is ~29,500 — above every server but one, which is exactly why a
|
||||
// plain min..max slider is useless here. The median is 3.
|
||||
expect(quantileValue([1, 2, 3, 4, 59_000], 0.5)).toBe(3);
|
||||
});
|
||||
|
||||
it("interpolates between samples", () => {
|
||||
expect(quantileValue([0, 10], 0.5)).toBe(5);
|
||||
});
|
||||
|
||||
it("clamps an out-of-range fraction", () => {
|
||||
expect(quantileValue([1, 5], -1)).toBe(1);
|
||||
expect(quantileValue([1, 5], 9)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueQuantile", () => {
|
||||
it("returns 0 for a distribution with fewer than two samples", () => {
|
||||
expect(valueQuantile([], 5)).toBe(0);
|
||||
expect(valueQuantile([5], 5)).toBe(0);
|
||||
});
|
||||
|
||||
it("maps the ends to 0 and 1", () => {
|
||||
expect(valueQuantile([1, 2, 100], 1)).toBe(0);
|
||||
expect(valueQuantile([1, 2, 100], 100)).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps values beyond the ends", () => {
|
||||
expect(valueQuantile([1, 2, 100], -5)).toBe(0);
|
||||
expect(valueQuantile([1, 2, 100], 1000)).toBe(1);
|
||||
});
|
||||
|
||||
it("inverts quantileValue", () => {
|
||||
const sorted = [1, 4, 9, 16, 25];
|
||||
for (const f of [0.25, 0.5, 0.75]) {
|
||||
const value = quantileValue(sorted, f);
|
||||
expect(valueQuantile(sorted, value)).toBeCloseTo(f, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("interpolates strictly inside the range", () => {
|
||||
expect(valueQuantile([0, 10], 5)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
/** jsdom has no layout, so the rect is supplied rather than measured. */
|
||||
const rect = (over: Partial<DOMRect> = {}): DOMRect =>
|
||||
({ left: 100, width: 200, ...over }) as DOMRect;
|
||||
|
||||
describe("fractionFromPointer", () => {
|
||||
it("maps the pointer to a fraction of the track", () => {
|
||||
expect(fractionFromPointer(rect(), 200)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("clamps outside the track", () => {
|
||||
expect(fractionFromPointer(rect(), 0)).toBe(0);
|
||||
expect(fractionFromPointer(rect(), 9999)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for a zero-width track rather than dividing by zero", () => {
|
||||
// jsdom reports zero-sized rects for everything, so this is the common
|
||||
// case in tests rather than an exotic one.
|
||||
expect(fractionFromPointer(rect({ width: 0 }), 150)).toBe(0);
|
||||
});
|
||||
});
|
||||
79
web_ui/src/quantile.ts
Normal file
79
web_ui/src/quantile.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Distribution-aware slider maths.
|
||||
*
|
||||
* A linear min..max track is useless on skewed data, which is the normal case
|
||||
* in both consumers: dufs-cloud's file sizes span bytes to gigabytes, and
|
||||
* awesome-mcp-explorer's star counts run 0..166,785 with a median of 8, so a
|
||||
* linear midpoint sits above 99.9% of all rows. Mapping the track through the
|
||||
* actual distribution puts the middle of the track at the median, which makes
|
||||
* equal track distance mean roughly equal numbers of items.
|
||||
*
|
||||
* This was duplicated in dufs-cloud and awesome-mcp-explorer before it moved
|
||||
* here; both now import it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bounds-checked array access (callers guarantee the index is valid). Keeps the
|
||||
* quantile helpers free of scattered non-null assertions or dead undefined
|
||||
* guards while still satisfying `noUncheckedIndexedAccess`.
|
||||
*/
|
||||
export function nth(values: readonly number[], index: number): number {
|
||||
const value = values[index];
|
||||
if (value === undefined) {
|
||||
throw new RangeError(`index ${index} out of range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Clamp to the unit interval. */
|
||||
export function clamp01(x: number): number {
|
||||
return Math.min(1, Math.max(0, x));
|
||||
}
|
||||
|
||||
/**
|
||||
* The value at quantile `f` (0..1) of an ascending, non-empty array, with linear
|
||||
* interpolation between the two straddling samples. This is what makes the
|
||||
* sliders distribution-aware: f = 0.5 returns the *median*, so the middle of the
|
||||
* track sits where half the items are — not the arithmetic midpoint of the
|
||||
* range. Empty input yields 0 (callers hide the slider in that case).
|
||||
*/
|
||||
export function quantileValue(sorted: readonly number[], f: number): number {
|
||||
const n = sorted.length;
|
||||
if (n === 0) return 0;
|
||||
const pos = clamp01(f) * (n - 1);
|
||||
const lo = Math.floor(pos);
|
||||
const hi = Math.ceil(pos);
|
||||
const a = nth(sorted, lo);
|
||||
const b = nth(sorted, hi);
|
||||
return Math.round(a + (b - a) * (pos - lo));
|
||||
}
|
||||
|
||||
/**
|
||||
* The quantile (0..1) of `value` within `sorted` — the inverse of
|
||||
* {@link quantileValue}, used to place a thumb at the right spot on the track.
|
||||
* Values at or beyond the ends map to 0/1.
|
||||
*/
|
||||
export function valueQuantile(sorted: readonly number[], value: number): number {
|
||||
const n = sorted.length;
|
||||
if (n <= 1) return 0;
|
||||
if (value <= nth(sorted, 0)) return 0;
|
||||
if (value >= nth(sorted, n - 1)) return 1;
|
||||
// value strictly inside the range, so there is an index hi >= 1 with
|
||||
// sorted[hi] >= value and sorted[hi - 1] < value.
|
||||
const hi = sorted.findIndex((v) => v >= value);
|
||||
const lo = hi - 1;
|
||||
const a = nth(sorted, lo);
|
||||
const b = nth(sorted, hi);
|
||||
// a < value <= b here, so b - a > 0 (no divide-by-zero).
|
||||
return (lo + (value - a) / (b - a)) / (n - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer x to a 0..1 fraction of a track.
|
||||
*
|
||||
* Extracted as pure geometry so slider behaviour is testable without layout,
|
||||
* which jsdom does not implement.
|
||||
*/
|
||||
export function fractionFromPointer(rect: DOMRect, clientX: number): number {
|
||||
return rect.width === 0 ? 0 : clamp01((clientX - rect.left) / rect.width);
|
||||
}
|
||||
68
web_ui/src/range-slider.css
Normal file
68
web_ui/src/range-slider.css
Normal file
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Styling for the shared RangeSlider. Imported separately from tokens.css:
|
||||
* import "@kuhyx/web-ui/tokens.css";
|
||||
* import "@kuhyx/web-ui/range-slider.css";
|
||||
*
|
||||
* Every value routes through a token — no literals.
|
||||
*/
|
||||
|
||||
.slider-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-caption);
|
||||
letter-spacing: var(--tracking-caption);
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
/* Tabular figures so a dragging readout does not reflow the row. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
height: 22px;
|
||||
margin-top: var(--space-1);
|
||||
/* Let the element own the gesture instead of the page scrolling under it. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.slider-track::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: -7px;
|
||||
padding: 0;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The thumbs are the keyboard target, so the ring must be unmistakable. The
|
||||
* global :focus-visible rule in tokens.css already colours it; the wider offset
|
||||
* here keeps it clear of the 14px circle. */
|
||||
.slider-thumb:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
209
web_ui/src/range-slider.test.tsx
Normal file
209
web_ui/src/range-slider.test.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RangeSlider, steppedValue } 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("steppedValue", () => {
|
||||
it("advances one sample at a time", () => {
|
||||
expect(steppedValue(VALUES, 30, 1)).toBe(40);
|
||||
});
|
||||
|
||||
it("moves backwards for a negative delta", () => {
|
||||
expect(steppedValue(VALUES, 30, -1)).toBe(20);
|
||||
});
|
||||
|
||||
it("clamps at the top", () => {
|
||||
expect(steppedValue(VALUES, 90, 5)).toBe(90);
|
||||
});
|
||||
|
||||
it("clamps at the bottom", () => {
|
||||
expect(steppedValue(VALUES, 0, -5)).toBe(0);
|
||||
});
|
||||
|
||||
it("rounds a between-samples value to a real sample", () => {
|
||||
expect(steppedValue(VALUES, 34, 1)).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
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] as HTMLElement, { 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] as HTMLElement, { key });
|
||||
expect(onChange).toHaveBeenCalledWith(0, expected);
|
||||
});
|
||||
|
||||
it("PageUp jumps ten samples", () => {
|
||||
const { onChange } = renderSlider({ lo: 0 });
|
||||
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key: "PageUp" });
|
||||
expect(onChange).toHaveBeenCalledWith(90, 90);
|
||||
});
|
||||
|
||||
it("PageDown jumps ten samples down", () => {
|
||||
const { onChange } = renderSlider({ hi: 90 });
|
||||
fireEvent.keyDown(thumbs()[1] as HTMLElement, { key: "PageDown" });
|
||||
expect(onChange).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it("Home sends a thumb to the floor", () => {
|
||||
const { onChange } = renderSlider({ lo: 50 });
|
||||
fireEvent.keyDown(thumbs()[0] as HTMLElement, { key: "Home" });
|
||||
expect(onChange).toHaveBeenCalledWith(0, 90);
|
||||
});
|
||||
|
||||
it("End sends a thumb to the ceiling", () => {
|
||||
const { onChange } = renderSlider({ hi: 50 });
|
||||
fireEvent.keyDown(thumbs()[1] as HTMLElement, { key: "End" });
|
||||
expect(onChange).toHaveBeenCalledWith(0, 90);
|
||||
});
|
||||
|
||||
it("ignores keys it does not handle", () => {
|
||||
const { onChange } = renderSlider();
|
||||
fireEvent.keyDown(thumbs()[0] as HTMLElement, { 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] as HTMLElement, { 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] as HTMLElement, { key: "ArrowLeft" });
|
||||
expect(onChange).toHaveBeenCalledWith(10, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pointer", () => {
|
||||
function track(): HTMLElement {
|
||||
// eslint-disable-next-line testing-library/no-node-access -- the track is
|
||||
// presentational; there is no role or text to query it by.
|
||||
return document.querySelector(".slider-track") as HTMLElement;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
173
web_ui/src/range-slider.tsx
Normal file
173
web_ui/src/range-slider.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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";
|
||||
|
||||
/** Steps one thumb by `delta` positions through the distribution. Pure, so the
|
||||
* keyboard contract is testable without layout. */
|
||||
export function steppedValue(
|
||||
values: readonly number[],
|
||||
current: number,
|
||||
delta: number,
|
||||
): number {
|
||||
const last = values.length - 1;
|
||||
// Round to a real sample index so a thumb sitting between two samples still
|
||||
// advances by exactly one item rather than stalling on a fractional step.
|
||||
const index = Math.round(valueQuantile(values, current) * last);
|
||||
return nth(values, Math.min(last, Math.max(0, index + delta)));
|
||||
}
|
||||
|
||||
export function RangeSlider({
|
||||
format,
|
||||
hi,
|
||||
label,
|
||||
lo,
|
||||
onChange,
|
||||
values,
|
||||
}: RangeSliderProps): null | React.JSX.Element {
|
||||
const [drag, setDrag] = useState<Thumb | null>(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 =>
|
||||
quantileValue(values, fractionFromPointer(track.getBoundingClientRect(), clientX)),
|
||||
[values],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(thumb: Thumb, event: React.KeyboardEvent): void => {
|
||||
const current = thumb === "lo" ? lo : hi;
|
||||
const step = (delta: number): void => {
|
||||
event.preventDefault();
|
||||
apply(thumb, steppedValue(values, current, delta));
|
||||
};
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowDown") step(-1);
|
||||
else if (event.key === "ArrowRight" || event.key === "ArrowUp") step(1);
|
||||
else if (event.key === "PageDown") step(-10);
|
||||
else if (event.key === "PageUp") step(10);
|
||||
else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
apply(thumb, nth(values, 0));
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
apply(thumb, nth(values, values.length - 1));
|
||||
}
|
||||
},
|
||||
[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"> => ({
|
||||
"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>
|
||||
);
|
||||
}
|
||||
38
web_ui/src/test/setup.ts
Normal file
38
web_ui/src/test/setup.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// jsdom implements no layout engine (every rect is zero-sized) and no pointer
|
||||
// capture. The slider needs a non-zero track width for fractionFromPointer to
|
||||
// mean anything, and needs the capture calls to not throw.
|
||||
const TRACK_WIDTH = 1000;
|
||||
|
||||
Object.defineProperty(Element.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value(): DOMRect {
|
||||
return {
|
||||
bottom: 20,
|
||||
height: 20,
|
||||
left: 0,
|
||||
right: TRACK_WIDTH,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: TRACK_WIDTH,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperties(Element.prototype, {
|
||||
releasePointerCapture: {
|
||||
configurable: true,
|
||||
value: (): void => {
|
||||
// No pointer capture in jsdom; the slider only needs the call to not throw.
|
||||
},
|
||||
},
|
||||
setPointerCapture: {
|
||||
configurable: true,
|
||||
value: (): void => {
|
||||
// As above.
|
||||
},
|
||||
},
|
||||
});
|
||||
33
web_ui/src/text.test.ts
Normal file
33
web_ui/src/text.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fuzzyMatch } from "./text.ts";
|
||||
|
||||
describe("fuzzyMatch", () => {
|
||||
it("matches everything on an empty query", () => {
|
||||
expect(fuzzyMatch("", "anything")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an exact substring", () => {
|
||||
expect(fuzzyMatch("cat", "concatenate")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches a non-adjacent subsequence", () => {
|
||||
expect(fuzzyMatch("cne", "concatenate")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores case on both sides", () => {
|
||||
expect(fuzzyMatch("CaT", "concatenate")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects characters that appear out of order", () => {
|
||||
expect(fuzzyMatch("tac", "cat")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a query with a character the target lacks", () => {
|
||||
expect(fuzzyMatch("dog", "concatenate")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a query longer than the target", () => {
|
||||
expect(fuzzyMatch("catalogue", "cat")).toBe(false);
|
||||
});
|
||||
});
|
||||
25
web_ui/src/text.ts
Normal file
25
web_ui/src/text.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Small text helpers shared by the consumers' filter layers.
|
||||
*
|
||||
* Hand-rolled rather than pulling in Fuse.js, matching the house posture of no
|
||||
* runtime dependencies for things this small.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Case-insensitive subsequence match: every character of `query` appears in
|
||||
* `target`, in order, though not necessarily adjacently. An empty query matches
|
||||
* everything, so callers can pass the raw search box value straight through.
|
||||
*
|
||||
* dufs-cloud called this `fuzzyMatch` and awesome-mcp-explorer `matchesFuzzy`;
|
||||
* the implementations were identical apart from identifier names.
|
||||
*/
|
||||
export function fuzzyMatch(query: string, target: string): boolean {
|
||||
if (query === "") return true;
|
||||
const q = query.toLowerCase();
|
||||
const t = target.toLowerCase();
|
||||
let index = 0;
|
||||
for (let scan = 0; scan < t.length && index < q.length; scan++) {
|
||||
if (t[scan] === q[index]) index++;
|
||||
}
|
||||
return index === q.length;
|
||||
}
|
||||
112
web_ui/src/tokens.css
Normal file
112
web_ui/src/tokens.css
Normal file
@ -0,0 +1,112 @@
|
||||
/*
|
||||
* The frozen design tokens, shared by every one of kuhy's web repos.
|
||||
*
|
||||
* Source of truth: ~/utils/unified-design-system/tokens.md (values) and
|
||||
* README.md (the CSS custom-property names). The same numbers ship for Dart in
|
||||
* ~/utils/design_system, so a change here is a change there — do not edit one
|
||||
* side alone, and do not introduce a value that is not on a scale below.
|
||||
*
|
||||
* Dark is the default; light is an override, matching the README.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Neutrals ------------------------------------------------------------- */
|
||||
--bg: #211d1b;
|
||||
--surface-1: #2b2624;
|
||||
--surface-2: #38312e;
|
||||
--border: #463e3a;
|
||||
--text: #eceae9;
|
||||
--text-muted: #aaa09a;
|
||||
|
||||
/* Accent + semantic roles ---------------------------------------------- */
|
||||
--accent: #b8862e;
|
||||
--info: #b8862e; /* = accent */
|
||||
--success: #8a9a3c;
|
||||
--warning: #e0a63c;
|
||||
--danger: #e2585f;
|
||||
|
||||
/* Text/icons ON a filled accent/success/warning/danger surface. Never
|
||||
* --text: every semantic fill sits in the same mid-light band, where
|
||||
* near-white measures 2.2:1-3.6:1 against a 4.5:1 floor. */
|
||||
--on-fill: #211d1b;
|
||||
/* Text/icons on a dark translucent scrim/overlay — fixed, not theme-tied,
|
||||
* because the scrim itself is always dark. */
|
||||
--on-scrim: #eceae9;
|
||||
|
||||
/* Corner radius --------------------------------------------------------- */
|
||||
--radius-sm: 8px; /* buttons, inputs, chips */
|
||||
--radius-md: 12px; /* cards */
|
||||
--radius-lg: 16px; /* sheets, dialogs */
|
||||
|
||||
/* Spacing (4px base) ---------------------------------------------------- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 16px;
|
||||
--space-4: 24px;
|
||||
--space-5: 32px;
|
||||
--space-6: 48px;
|
||||
|
||||
/* Typography ------------------------------------------------------------ */
|
||||
--font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", monospace;
|
||||
|
||||
--text-display: 32px;
|
||||
--text-title: 24px;
|
||||
--text-subtitle: 20px;
|
||||
--text-body: 16px; /* the floor for anything a user actually reads */
|
||||
--text-label: 14px; /* UI chrome only */
|
||||
--text-caption: 12px; /* UI chrome only */
|
||||
|
||||
--tracking-display: -0.2px;
|
||||
--tracking-title: -0.1px;
|
||||
--tracking-label: 0.05em;
|
||||
--tracking-caption: 0.1em;
|
||||
|
||||
/* Prose line-length cap (rule 21): ~65-70 characters. */
|
||||
--measure: 40rem;
|
||||
|
||||
/* Shadows: light surfaces only, and only on floating/overlay elements —
|
||||
* never inline cards or list rows. Blur = 2x offset, tinted from ink. */
|
||||
--shadow-1: 0 2px 4px rgba(33, 29, 27, 0.12); /* dropdown */
|
||||
--shadow-2: 0 4px 8px rgba(33, 29, 27, 0.16); /* modal */
|
||||
|
||||
/* Focus ring: the platform default is black, invisible on --bg. */
|
||||
--focus-ring: #b8862e; /* = accent */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f4f3;
|
||||
--surface-1: #fcfbfb;
|
||||
--surface-2: #fcfbfb;
|
||||
--border: #e0dad7;
|
||||
--text: #211d1b;
|
||||
--text-muted: #70625b;
|
||||
/* --on-fill / --on-scrim stay fixed — the fills and the scrim do not
|
||||
* change with page theme. */
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark surfaces take no shadow at all; elevation is a fill step. Light
|
||||
* surfaces opt in explicitly via --shadow-1 / --shadow-2. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--shadow-1: none;
|
||||
--shadow-2: none;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* Ships here rather than being left to each consumer: an unstyled focus ring
|
||||
* is the accessibility regression this layer exists to prevent, and several of
|
||||
* these apps are keyboard-only by necessity. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
41
web_ui/tsconfig.json
Normal file
41
web_ui/tsconfig.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals"],
|
||||
|
||||
/* Strictness: everything on, matching both consumers so a file that
|
||||
compiles here compiles there. */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
|
||||
/* This package ships a prebuilt dist/ (ESM + .d.ts) because consumers use
|
||||
Vite, which does not transpile dependencies. So unlike the consumers,
|
||||
emit is on and .ts import specifiers must be rewritten. */
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||
}
|
||||
18
web_ui/vitest.config.ts
Normal file
18
web_ui/vitest.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: ["src/index.ts", "src/**/*.d.ts"],
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
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.
|
||||
thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 },
|
||||
},
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user