mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
web_ui: step the slider to the next DISTINCT value (v0.3.0)
Caught by driving the real browser, not jsdom: on awesome-mcp-explorer's star distribution the arrow keys did nothing at all. ~1500 of the ~2981 values are 0, so an index step landed inside the run of duplicates -- the index moved, the value did not, and the thumb, the readout and the filter all stayed put. steppedValue now walks to the next distinct value, so one arrow press is one visible change on any distribution, however lumpy. The regression test uses the shape that actually broke rather than the evenly-spaced fixture that hid it. 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
2574b21acc
commit
133c210b2e
@ -13,7 +13,7 @@ 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.2.0&path:/web_ui"
|
||||
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.0&path:/web_ui"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
12
web_ui/dist/range-slider.d.ts
vendored
12
web_ui/dist/range-slider.d.ts
vendored
@ -16,8 +16,16 @@ export interface RangeSliderProps {
|
||||
/** 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. */
|
||||
/**
|
||||
* Steps one thumb by `delta` positions through the distribution. Pure, so the
|
||||
* keyboard contract is testable without layout.
|
||||
*
|
||||
* Steps to the next *distinct* value rather than the next index. Real
|
||||
* distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500
|
||||
* duplicate zeros -- and an index step inside a run of equal values changes the
|
||||
* index while leaving the value (and therefore the thumb, and the filter)
|
||||
* exactly where it was. That reads as a dead arrow key.
|
||||
*/
|
||||
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
|
||||
2
web_ui/dist/range-slider.d.ts.map
vendored
2
web_ui/dist/range-slider.d.ts.map
vendored
@ -1 +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"}
|
||||
{"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;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,MAAM,CAkBR;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"}
|
||||
29
web_ui/dist/range-slider.js
vendored
29
web_ui/dist/range-slider.js
vendored
@ -22,14 +22,35 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* Steps one thumb by `delta` positions through the distribution. Pure, so the
|
||||
* keyboard contract is testable without layout.
|
||||
*
|
||||
* Steps to the next *distinct* value rather than the next index. Real
|
||||
* distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500
|
||||
* duplicate zeros -- and an index step inside a run of equal values changes the
|
||||
* index while leaving the value (and therefore the thumb, and the filter)
|
||||
* exactly where it was. That reads as a dead arrow key.
|
||||
*/
|
||||
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.
|
||||
// advances 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)));
|
||||
const direction = Math.sign(delta);
|
||||
if (direction === 0)
|
||||
return nth(values, index);
|
||||
let cursor = index;
|
||||
for (let taken = 0; taken < Math.abs(delta); taken++) {
|
||||
let next = cursor + direction;
|
||||
while (next >= 0 && next <= last && nth(values, next) === nth(values, cursor)) {
|
||||
next += direction;
|
||||
}
|
||||
if (next < 0 || next > last)
|
||||
break;
|
||||
cursor = next;
|
||||
}
|
||||
return nth(values, cursor);
|
||||
}
|
||||
export function RangeSlider({ format, hi, label, lo, onChange, values, }) {
|
||||
const [drag, setDrag] = useState(null);
|
||||
|
||||
2
web_ui/dist/range-slider.js.map
vendored
2
web_ui/dist/range-slider.js.map
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kuhyx/web-ui",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.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.",
|
||||
|
||||
@ -49,6 +49,41 @@ describe("steppedValue", () => {
|
||||
it("rounds a between-samples value to a real sample", () => {
|
||||
expect(steppedValue(VALUES, 34, 1)).toBe(40);
|
||||
});
|
||||
|
||||
it("returns the current sample for a zero delta", () => {
|
||||
expect(steppedValue(VALUES, 30, 0)).toBe(30);
|
||||
});
|
||||
|
||||
describe("on a lumpy distribution", () => {
|
||||
// The shape that actually broke in the browser: awesome-mcp-explorer's
|
||||
// star counts hold ~1500 duplicate zeros, so an index step inside the run
|
||||
// moved the index but not the value, and the arrow key looked dead.
|
||||
const LUMPY = [...Array.from({ length: 50 }, () => 0), 5, 5, 5, 100, 5000];
|
||||
|
||||
it("escapes a run of duplicates instead of stalling", () => {
|
||||
expect(steppedValue(LUMPY, 0, 1)).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps stepping past the second run", () => {
|
||||
expect(steppedValue(LUMPY, 5, 1)).toBe(100);
|
||||
});
|
||||
|
||||
it("steps back out of a run", () => {
|
||||
expect(steppedValue(LUMPY, 5, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts distinct values for a multi-step jump", () => {
|
||||
expect(steppedValue(LUMPY, 0, 2)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps at the top rather than running off the end", () => {
|
||||
expect(steppedValue(LUMPY, 5000, 1)).toBe(5000);
|
||||
});
|
||||
|
||||
it("clamps at the bottom", () => {
|
||||
expect(steppedValue(LUMPY, 0, -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("RangeSlider", () => {
|
||||
|
||||
@ -44,8 +44,16 @@ export interface RangeSliderProps {
|
||||
|
||||
type Thumb = "hi" | "lo";
|
||||
|
||||
/** Steps one thumb by `delta` positions through the distribution. Pure, so the
|
||||
* keyboard contract is testable without layout. */
|
||||
/**
|
||||
* Steps one thumb by `delta` positions through the distribution. Pure, so the
|
||||
* keyboard contract is testable without layout.
|
||||
*
|
||||
* Steps to the next *distinct* value rather than the next index. Real
|
||||
* distributions are lumpy -- awesome-mcp-explorer's star counts hold ~1500
|
||||
* duplicate zeros -- and an index step inside a run of equal values changes the
|
||||
* index while leaving the value (and therefore the thumb, and the filter)
|
||||
* exactly where it was. That reads as a dead arrow key.
|
||||
*/
|
||||
export function steppedValue(
|
||||
values: readonly number[],
|
||||
current: number,
|
||||
@ -53,9 +61,21 @@ export function steppedValue(
|
||||
): 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.
|
||||
// advances 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)));
|
||||
const direction = Math.sign(delta);
|
||||
if (direction === 0) return nth(values, index);
|
||||
|
||||
let cursor = index;
|
||||
for (let taken = 0; taken < Math.abs(delta); taken++) {
|
||||
let next = cursor + direction;
|
||||
while (next >= 0 && next <= last && nth(values, next) === nth(values, cursor)) {
|
||||
next += direction;
|
||||
}
|
||||
if (next < 0 || next > last) break;
|
||||
cursor = next;
|
||||
}
|
||||
return nth(values, cursor);
|
||||
}
|
||||
|
||||
export function RangeSlider({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user