Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: dropdowns open a themed list of their own instead of the browser's

SelectField and the refresh-interval picker are built on a new Listbox, a
select-only combobox: a button that keeps focus and a listbox in the top
layer, so it works the same in every current browser, is not clipped by a
scrolling ancestor and shows above a modal dialog. Keys, type-ahead and
placement are pure functions with tests. required becomes aria-required, as a
button has no native form validation.
This commit is contained in:
iris 2026-10-25 17:15:04 +01:00
commit a2e8a2bda0
9 changed files with 653 additions and 66 deletions

View file

@ -0,0 +1,113 @@
// What the styled dropdown's keys and placement do. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
indexOfValue,
moveIndex,
nextQuery,
placePopup,
TYPE_AHEAD_MS,
typeAhead,
} from "../src/ui/select-field/select-nav.ts";
const opts = ["alpha", "beta", "bravo", "charlie", "Delta"].map((label) => ({
value: label.toLowerCase(),
label,
}));
test("the option holding a value is found, or -1", () => {
assert.equal(indexOfValue(opts, "bravo"), 2);
assert.equal(indexOfValue(opts, "nope"), -1);
});
test("arrow keys step and stop at the ends; Home and End jump", () => {
assert.equal(moveIndex(1, 5, "ArrowDown"), 2);
assert.equal(moveIndex(4, 5, "ArrowDown"), 4, "stops at the last");
assert.equal(moveIndex(1, 5, "ArrowUp"), 0);
assert.equal(moveIndex(0, 5, "ArrowUp"), 0, "stops at the first");
assert.equal(moveIndex(2, 5, "Home"), 0);
assert.equal(moveIndex(2, 5, "End"), 4);
assert.equal(moveIndex(0, 30, "PageDown"), 10);
assert.equal(moveIndex(25, 30, "PageDown"), 29);
assert.equal(moveIndex(3, 30, "PageUp"), 0);
});
test("a key that moves nothing, or no options, leave the active one alone", () => {
assert.equal(moveIndex(2, 5, "a"), 2);
assert.equal(moveIndex(0, 0, "ArrowDown"), -1);
assert.equal(
moveIndex(-1, 3, "ArrowDown"),
0,
"from nothing active, the first",
);
});
test("typing a letter steps through the options starting with it, wrapping", () => {
assert.equal(typeAhead(opts, 0, "b"), 1);
assert.equal(typeAhead(opts, 1, "b"), 2);
assert.equal(typeAhead(opts, 2, "b"), 1, "wraps round to the first b");
assert.equal(typeAhead(opts, 0, "d"), 4, "case does not matter");
assert.equal(typeAhead(opts, 0, "z"), -1);
});
test("the same letter typed again steps on; a longer word narrows in place", () => {
assert.equal(typeAhead(opts, 1, "bb"), 2, "bb is b again");
assert.equal(typeAhead(opts, 1, "br"), 2);
assert.equal(typeAhead(opts, 2, "br"), 2, "stays while it still matches");
assert.equal(typeAhead(opts, 0, ""), -1);
assert.equal(typeAhead([], 0, "a"), -1);
});
test("a short pause continues the word, a long one starts a new one", () => {
assert.equal(nextQuery("b", "r", 100), "br");
assert.equal(nextQuery("br", "c", TYPE_AHEAD_MS + 1), "c");
});
const viewport = { width: 1000, height: 800 };
test("the list opens under the control when it fits", () => {
const p = placePopup(
{ top: 100, bottom: 140, left: 50, width: 200 },
viewport,
120,
);
assert.deepEqual(p, { top: 144, left: 50, width: 200, maxHeight: 120 });
});
test("it opens above when under is too short and above has more room", () => {
const p = placePopup(
{ top: 700, bottom: 740, left: 50, width: 200 },
viewport,
300,
);
assert.equal(p.maxHeight, 300);
assert.equal(p.top, 700 - 4 - 300);
});
test("it scrolls under the control when neither side fits it", () => {
const p = placePopup(
{ top: 100, bottom: 140, left: 50, width: 200 },
{ width: 1000, height: 300 },
900,
);
// 300 - 140 - 8 - 4 under; 100 - 12 above: under has more here.
assert.equal(p.top, 144);
assert.equal(p.maxHeight, 148);
});
test("it is at least the minimum width and stays inside the viewport", () => {
const p = placePopup(
{ top: 100, bottom: 140, left: 960, width: 30 },
viewport,
100,
120,
);
assert.equal(p.width, 120);
assert.equal(p.left, 1000 - 8 - 120);
const q = placePopup(
{ top: 0, bottom: 40, left: -20, width: 100 },
viewport,
50,
);
assert.equal(q.left, 8);
});