@celestia-island/hikari 0.55.338 → 0.55.340
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -19,16 +19,21 @@ import jaBundle from "../i18n/locales/ja/components.json";
|
|
|
19
19
|
*
|
|
20
20
|
* The component's whole reason to exist is the window contract, so the
|
|
21
21
|
* cases below pin it from both sides:
|
|
22
|
-
* ·
|
|
23
|
-
*
|
|
24
|
-
* the
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* · clipping has exactly TWO sites: the window (windowed lanes: outside →
|
|
23
|
+
* absent, straddling → re-normalized) and the DOMAIN (`clipToDomain`:
|
|
24
|
+
* content outside the caller's axis paints nowhere on either layer),
|
|
25
|
+
* while the overview keeps painting the whole domain;
|
|
26
|
+
* · the emitted window is ALWAYS in absolute domain coordinates — never
|
|
27
|
+
* relative to the currently zoomed sub-domain (the chest row filter
|
|
28
|
+
* depends on exactly that);
|
|
28
29
|
* · every reset path (button / dblclick / Escape / Home) lands null and
|
|
29
30
|
* restores the full view;
|
|
30
31
|
* · sub-minimum brushes, edge drags and keyboard nudges clamp instead of
|
|
31
|
-
* crossing
|
|
32
|
+
* crossing;
|
|
33
|
+
* · the two layers stay explicit (minimap / detail) over ONE caller
|
|
34
|
+
* `domain` axis (default [0, 1]): the minimap draws the domain, the
|
|
35
|
+
* detail draws the selected window, labels read domain percentages, and
|
|
36
|
+
* the deprecated `headroom` prop is the `[0, 1 + headroom]` alias.
|
|
32
37
|
* A stylesheet contract at the bottom compiles the sheet and pins the
|
|
33
38
|
* classes, geometry and the one animation (repo convention: vitest stubs
|
|
34
39
|
* CSS, so the runtime cases can never catch a dead rule).
|
|
@@ -284,6 +289,7 @@ describe("HkRangeRibbon full domain", () => {
|
|
|
284
289
|
{ id: "far", lane: "cpu", start: -0.5, end: 0.25, label: "far" },
|
|
285
290
|
{ id: "nan", lane: "cpu", start: Number.NaN, end: 0.4, label: "nan" },
|
|
286
291
|
{ id: "orphan", lane: "gpu", start: 0.1, end: 0.2, label: "orphan" },
|
|
292
|
+
{ id: "outside", lane: "cpu", start: 1.2, end: 1.4, label: "outside" },
|
|
287
293
|
];
|
|
288
294
|
const root = mountRibbon({ window: null, segments });
|
|
289
295
|
// Inverted → swapped, out-of-domain → clamped, zero-length kept as a tick.
|
|
@@ -298,6 +304,11 @@ describe("HkRangeRibbon full domain", () => {
|
|
|
298
304
|
// …and a segment can never invent an undeclared lane.
|
|
299
305
|
expect(segmentById(root, "orphan")).toBeNull();
|
|
300
306
|
expect(root.querySelector('[data-segment-id="orphan"]')).toBeNull();
|
|
307
|
+
// Content wholly outside the axis paints NOTHING: the domain is a
|
|
308
|
+
// clipping rule, not a clamp to an edge tick (pre-domain content would
|
|
309
|
+
// have collapsed [1.2, 1.4] onto 100% and drawn a tick there).
|
|
310
|
+
expect(segmentById(root, "outside")).toBeNull();
|
|
311
|
+
expect(root.querySelector('[data-segment-id="outside"]')).toBeNull();
|
|
301
312
|
});
|
|
302
313
|
|
|
303
314
|
it("shares the label column with the lanes so both axes agree", () => {
|
|
@@ -323,7 +334,7 @@ describe("HkRangeRibbon window normalizer parity", () => {
|
|
|
323
334
|
// component must keep CALLING the exported normalizer rather than
|
|
324
335
|
// growing a private copy again.
|
|
325
336
|
const tsx = readFileSync(resolve(componentDir, "HkRangeRibbon.tsx"), "utf8");
|
|
326
|
-
expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value\)/);
|
|
337
|
+
expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value, domain\.value\)/);
|
|
327
338
|
});
|
|
328
339
|
|
|
329
340
|
it("renders exactly the window the exported helper returns", () => {
|
|
@@ -1357,6 +1368,732 @@ describe("HkRangeRibbon controlled contract", () => {
|
|
|
1357
1368
|
});
|
|
1358
1369
|
});
|
|
1359
1370
|
|
|
1371
|
+
// ── Domain-aware normalizer ─────────────────────────────────────────────
|
|
1372
|
+
describe("HkRangeRibbon domain normalizer", () => {
|
|
1373
|
+
it("clamps, expands and caps inside the caller's domain", () => {
|
|
1374
|
+
// Inverted and out-of-domain windows clamp to the axis.
|
|
1375
|
+
const inverted = normalizeHkRangeWindow([1.5, 0.5], 0.02, [0, 2])!;
|
|
1376
|
+
expect(inverted[0]).toBeCloseTo(0.5, 10);
|
|
1377
|
+
expect(inverted[1]).toBeCloseTo(1.5, 10);
|
|
1378
|
+
const clamped = normalizeHkRangeWindow([-1, 3], 0.02, [0, 2])!;
|
|
1379
|
+
expect(clamped[0]).toBeCloseTo(0, 10);
|
|
1380
|
+
expect(clamped[1]).toBeCloseTo(2, 10);
|
|
1381
|
+
|
|
1382
|
+
// The min width lives in domain units: 0.02 around a point on [0, 2].
|
|
1383
|
+
const point = normalizeHkRangeWindow([0.5, 0.5], 0.02, [0, 2])!;
|
|
1384
|
+
expect(point[0]).toBeCloseTo(0.49, 10);
|
|
1385
|
+
expect(point[1]).toBeCloseTo(0.51, 10);
|
|
1386
|
+
|
|
1387
|
+
// A min width wider than the axis caps at the span instead of escaping
|
|
1388
|
+
// the domain.
|
|
1389
|
+
const capped = normalizeHkRangeWindow([1, 1], 5, [0, 2])!;
|
|
1390
|
+
expect(capped[0]).toBeCloseTo(0, 10);
|
|
1391
|
+
expect(capped[1]).toBeCloseTo(2, 10);
|
|
1392
|
+
|
|
1393
|
+
// At the domain edges the expansion shifts inward, never outside.
|
|
1394
|
+
const leftEdge = normalizeHkRangeWindow([0, 0], 0.02, [0, 2])!;
|
|
1395
|
+
expect(leftEdge[0]).toBeCloseTo(0, 10);
|
|
1396
|
+
expect(leftEdge[1]).toBeCloseTo(0.02, 10);
|
|
1397
|
+
const rightEdge = normalizeHkRangeWindow([2, 2], 0.02, [0, 2])!;
|
|
1398
|
+
expect(rightEdge[0]).toBeCloseTo(1.98, 10);
|
|
1399
|
+
expect(rightEdge[1]).toBeCloseTo(2, 10);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
it("treats a malformed domain as the unit axis (two-argument parity)", () => {
|
|
1403
|
+
// Hosts calling with two arguments are byte-identical to the old contract.
|
|
1404
|
+
expect(normalizeHkRangeWindow([0.2, 0.4])).toEqual([0.2, 0.4]);
|
|
1405
|
+
expect(normalizeHkRangeWindow([0.2, 0.4], 0.02)).toEqual([0.2, 0.4]);
|
|
1406
|
+
expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, null)).toEqual([0.2, 0.4]);
|
|
1407
|
+
expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, "nope" as unknown as number[])).toEqual([
|
|
1408
|
+
0.2, 0.4,
|
|
1409
|
+
]);
|
|
1410
|
+
// A zero-width domain has no interior: the unit axis takes over.
|
|
1411
|
+
expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, [0.5, 0.5])).toEqual([0.2, 0.4]);
|
|
1412
|
+
// An inverted domain is sorted, so it behaves like the same ordered pair.
|
|
1413
|
+
expect(normalizeHkRangeWindow([0.2, 0.4], 0.02, [1, 0])).toEqual([0.2, 0.4]);
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
// ── Dual-layer waterfall + caller domain axis ───────────────────────────
|
|
1418
|
+
describe("HkRangeRibbon dual-layer waterfall", () => {
|
|
1419
|
+
/** The minimap's x for a fraction on the deprecated headroom ALIAS axis
|
|
1420
|
+
* `[0, 1 + headroom]`, in percent. */
|
|
1421
|
+
function axisPercent(fraction: number, headroom: number): number {
|
|
1422
|
+
return (fraction / (1 + headroom)) * 100;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** The minimap's x of a DOMAIN coordinate / of a domain LENGTH, in percent. */
|
|
1426
|
+
function domainPercent(value: number, domain: [number, number]): number {
|
|
1427
|
+
return ((value - domain[0]) / (domain[1] - domain[0])) * 100;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function domainWidthPercent(length: number, domain: [number, number]): number {
|
|
1431
|
+
return (length / (domain[1] - domain[0])) * 100;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** Inline box assertions at the geometry tolerance. `percent()` rounds to
|
|
1435
|
+
* four decimals OF A PERCENT (~1e-6 fraction), so a four-decimal
|
|
1436
|
+
* `toBeCloseTo` sits exactly on that rounding boundary; 3 keeps a margin
|
|
1437
|
+
* while still pinning well below a percent. */
|
|
1438
|
+
function expectBox(
|
|
1439
|
+
element: HTMLElement | null,
|
|
1440
|
+
expected: { left: number; width: number },
|
|
1441
|
+
label: string,
|
|
1442
|
+
): void {
|
|
1443
|
+
expect(element, label).toBeTruthy();
|
|
1444
|
+
expect(boxOf(element!).left, `${label} left`).toBeCloseTo(expected.left, 3);
|
|
1445
|
+
expect(boxOf(element!).width, `${label} width`).toBeCloseTo(expected.width, 3);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
it("marks both layers explicitly and keeps the lane markup intact", () => {
|
|
1449
|
+
const root = mountRibbon({ window: [0.35, 0.67] });
|
|
1450
|
+
const minimap = root.querySelector<HTMLElement>(".hk-range-ribbon-overview");
|
|
1451
|
+
expect(minimap, "the minimap layer renders").toBeTruthy();
|
|
1452
|
+
expect(minimap!.getAttribute("data-layer")).toBe("minimap");
|
|
1453
|
+
// The strip (brush surface + window chrome host) is the minimap's own.
|
|
1454
|
+
expect(minimap!.querySelector(".hk-range-ribbon-strip")).toBeTruthy();
|
|
1455
|
+
expect(minimap!.querySelector(".hk-range-ribbon-overview-gutter")).toBeTruthy();
|
|
1456
|
+
expect(minimap!.querySelectorAll(".hk-range-ribbon-overview-row")).toHaveLength(2);
|
|
1457
|
+
|
|
1458
|
+
const detail = root.querySelector<HTMLElement>(".hk-range-ribbon-detail");
|
|
1459
|
+
expect(detail, "the detail layer renders").toBeTruthy();
|
|
1460
|
+
expect(detail!.getAttribute("data-layer")).toBe("detail");
|
|
1461
|
+
// The lane list keeps its markup verbatim and lives INSIDE the wrapper.
|
|
1462
|
+
const lanes = detail!.querySelector<HTMLElement>(".hk-range-ribbon-lanes");
|
|
1463
|
+
expect(lanes, "the lane list is nested in the detail layer").toBeTruthy();
|
|
1464
|
+
expect(lanes!.parentElement).toBe(detail);
|
|
1465
|
+
expect(detail!.querySelectorAll(".hk-range-ribbon-lane")).toHaveLength(2);
|
|
1466
|
+
expect(detail!.querySelectorAll(".hk-range-ribbon-lane-track")).toHaveLength(2);
|
|
1467
|
+
// Exactly two layers, in reading order (minimap above detail).
|
|
1468
|
+
expect(Array.from(root.children).map((child) => child.getAttribute("data-layer"))).toEqual([
|
|
1469
|
+
"minimap",
|
|
1470
|
+
"detail",
|
|
1471
|
+
]);
|
|
1472
|
+
// Minimal mode stays a bare bar: no detail layer at all.
|
|
1473
|
+
expect(mountRibbon({ minimal: true }).querySelector(".hk-range-ribbon-detail")).toBeNull();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it("keeps the unit axis when no domain is supplied (backward compatible)", () => {
|
|
1477
|
+
// Omitting `domain` (and the alias) is exactly [0, 1]: same boxes, same
|
|
1478
|
+
// aria bounds as an explicit unit axis.
|
|
1479
|
+
const implicit = mountRibbon({ window: [0.2, 0.6] });
|
|
1480
|
+
const explicit = mountRibbon({ domain: [0, 1], window: [0.2, 0.6] });
|
|
1481
|
+
for (const selector of [
|
|
1482
|
+
".hk-range-ribbon-window",
|
|
1483
|
+
'.hk-range-ribbon-scrim[data-side="after"]',
|
|
1484
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="write"]',
|
|
1485
|
+
'.hk-range-ribbon-lane-track .hk-range-ribbon-segment[data-segment-id="write"]',
|
|
1486
|
+
]) {
|
|
1487
|
+
const a = implicit.querySelector<HTMLElement>(selector)!;
|
|
1488
|
+
const b = explicit.querySelector<HTMLElement>(selector)!;
|
|
1489
|
+
expect(a.style.left, `${selector} left`).toBe(b.style.left);
|
|
1490
|
+
expect(a.style.width, `${selector} width`).toBe(b.style.width);
|
|
1491
|
+
}
|
|
1492
|
+
const strip = implicit.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1493
|
+
expect(strip.getAttribute("aria-valuemin")).toBe("0");
|
|
1494
|
+
expect(strip.getAttribute("aria-valuemax")).toBe("100");
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it("draws the minimap x as (f - domain[0]) / span across caller axes", () => {
|
|
1498
|
+
// Hard anchor first: on [0, 2] half the strip is coordinate 1.0.
|
|
1499
|
+
const wide = mountRibbon({ domain: [0, 2], window: [0.5, 1.5] });
|
|
1500
|
+
expectBox(
|
|
1501
|
+
wide.querySelector<HTMLElement>(".hk-range-ribbon-window"),
|
|
1502
|
+
{ left: 25, width: 50 },
|
|
1503
|
+
"frame [0.5, 1.5] of [0, 2]",
|
|
1504
|
+
);
|
|
1505
|
+
expectBox(
|
|
1506
|
+
wide.querySelector<HTMLElement>('.hk-range-ribbon-overview-segment[data-segment-id="parse"]'),
|
|
1507
|
+
{ left: 15, width: 7.5 },
|
|
1508
|
+
"parse 0.3..0.45 of [0, 2]",
|
|
1509
|
+
);
|
|
1510
|
+
expectBox(
|
|
1511
|
+
wide.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
|
|
1512
|
+
{ left: 0, width: 25 },
|
|
1513
|
+
"before scrim covers the domain before the window",
|
|
1514
|
+
);
|
|
1515
|
+
expectBox(
|
|
1516
|
+
wide.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]'),
|
|
1517
|
+
{ left: 75, width: 25 },
|
|
1518
|
+
"after scrim runs to the domain end",
|
|
1519
|
+
);
|
|
1520
|
+
expect(
|
|
1521
|
+
parseFloat(wide.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!.style.left),
|
|
1522
|
+
).toBeCloseTo(25, 3);
|
|
1523
|
+
expect(
|
|
1524
|
+
parseFloat(wide.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!.style.left),
|
|
1525
|
+
).toBeCloseTo(75, 3);
|
|
1526
|
+
|
|
1527
|
+
// Formula sweep over the shapes a caller may declare: negative start,
|
|
1528
|
+
// trailing budget and a narrower window of interest. A window at 10%..70%
|
|
1529
|
+
// of each axis must land at the same strip percentages.
|
|
1530
|
+
const cases: Array<[number, number]> = [
|
|
1531
|
+
[-0.1, 1],
|
|
1532
|
+
[0.2, 0.8],
|
|
1533
|
+
[-0.5, 1.5],
|
|
1534
|
+
];
|
|
1535
|
+
for (const domain of cases) {
|
|
1536
|
+
const span = domain[1] - domain[0];
|
|
1537
|
+
const window: [number, number] = [domain[0] + 0.1 * span, domain[0] + 0.7 * span];
|
|
1538
|
+
const root = mountRibbon({ domain, window });
|
|
1539
|
+
expectBox(
|
|
1540
|
+
root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
|
|
1541
|
+
{ left: 10, width: 60 },
|
|
1542
|
+
`${domain} frame is 10%..70% of the axis`,
|
|
1543
|
+
);
|
|
1544
|
+
expectBox(
|
|
1545
|
+
root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
|
|
1546
|
+
{ left: 0, width: 10 },
|
|
1547
|
+
`${domain} before scrim`,
|
|
1548
|
+
);
|
|
1549
|
+
expectBox(
|
|
1550
|
+
root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]'),
|
|
1551
|
+
{ left: 70, width: 30 },
|
|
1552
|
+
`${domain} after scrim reaches the axis end`,
|
|
1553
|
+
);
|
|
1554
|
+
expect(
|
|
1555
|
+
parseFloat(
|
|
1556
|
+
root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!.style.left,
|
|
1557
|
+
),
|
|
1558
|
+
`${domain} start grip`,
|
|
1559
|
+
).toBeCloseTo(10, 3);
|
|
1560
|
+
expect(
|
|
1561
|
+
parseFloat(
|
|
1562
|
+
root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!.style.left,
|
|
1563
|
+
),
|
|
1564
|
+
`${domain} end grip`,
|
|
1565
|
+
).toBeCloseTo(70, 3);
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
it("maps the detail track to the selected WINDOW in domain coordinates", () => {
|
|
1570
|
+
// Domain [0, 2], window [0.5, 1.5]: the detail is 0..100% of that window,
|
|
1571
|
+
// so a bar starting at coordinate 1.0 lands halfway.
|
|
1572
|
+
const segments: RibbonSegment[] = [
|
|
1573
|
+
{ id: "lead", lane: "cpu", start: 0.5, end: 0.9, label: "lead" },
|
|
1574
|
+
{ id: "mid", lane: "cpu", start: 1, end: 1.2, label: "mid" },
|
|
1575
|
+
{ id: "tail", lane: "cpu", start: 1.4, end: 1.8, label: "tail" },
|
|
1576
|
+
];
|
|
1577
|
+
const root = mountRibbon({ domain: [0, 2], window: [0.5, 1.5], segments });
|
|
1578
|
+
expectBox(segmentById(root, "lead"), { left: 0, width: 40 }, "lead 0.5..0.9");
|
|
1579
|
+
expectBox(segmentById(root, "mid"), { left: 50, width: 20 }, "a bar at 1.0 starts halfway");
|
|
1580
|
+
expectBox(segmentById(root, "tail"), { left: 90, width: 10 }, "tail clipped at the window end");
|
|
1581
|
+
|
|
1582
|
+
// The same window on a negative-start axis keeps its relative mapping:
|
|
1583
|
+
// [-0.1, 1] with window [0.2, 0.6] puts parse (0.3..0.45) at 25%..62.5%.
|
|
1584
|
+
const negative = mountRibbon({ domain: [-0.1, 1], window: [0.2, 0.6] });
|
|
1585
|
+
expectBox(segmentById(negative, "parse"), { left: 25, width: 37.5 }, "parse inside a negative axis");
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it("paints the no-window detail on the domain axis (the full view)", () => {
|
|
1589
|
+
// With no window the detail IS the full view, so its axis is the domain:
|
|
1590
|
+
// on [0, 2] a segment at 0.3..0.45 lands at 15%..22.5%, and the minimap
|
|
1591
|
+
// twin agrees — the full-domain view is where the two layers line up.
|
|
1592
|
+
const root = mountRibbon({ window: null, domain: [0, 2] });
|
|
1593
|
+
const parse = segmentById(root, "parse")!;
|
|
1594
|
+
expect(boxOf(parse).left, "0.3 on a [0, 2] axis").toBeCloseTo(15, 3);
|
|
1595
|
+
expect(boxOf(parse).width).toBeCloseTo(7.5, 3);
|
|
1596
|
+
const overview = root.querySelector<HTMLElement>(
|
|
1597
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
|
|
1598
|
+
)!;
|
|
1599
|
+
expect(boxOf(overview).left, "both layers share the domain axis").toBeCloseTo(
|
|
1600
|
+
boxOf(parse).left,
|
|
1601
|
+
3,
|
|
1602
|
+
);
|
|
1603
|
+
expect(boxOf(overview).width).toBeCloseTo(boxOf(parse).width, 3);
|
|
1604
|
+
|
|
1605
|
+
// The deprecated alias drives the no-window detail the same way: it IS a
|
|
1606
|
+
// domain, not a minimap-only tail.
|
|
1607
|
+
const alias = mountRibbon({ window: null, headroom: 0.1 });
|
|
1608
|
+
expect(boxOf(segmentById(alias, "parse")!).left).toBeCloseTo(axisPercent(0.3, 0.1), 3);
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
it("maps the same absolute fraction onto the alias minimap and the window-relative detail", () => {
|
|
1612
|
+
// parse covers 30%..45% of the alias axis [0, 1.1]; the window is
|
|
1613
|
+
// 35%..67% of that same axis.
|
|
1614
|
+
const root = mountRibbon({ window: [0.35, 0.67], headroom: 0.1 });
|
|
1615
|
+
expectBox(
|
|
1616
|
+
root.querySelector<HTMLElement>('.hk-range-ribbon-overview-segment[data-segment-id="parse"]'),
|
|
1617
|
+
{ left: axisPercent(0.3, 0.1), width: axisPercent(0.15, 0.1) },
|
|
1618
|
+
"alias minimap parse",
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
// The detail ignores the axis entirely and re-normalizes the window to
|
|
1622
|
+
// its own 0..100% track.
|
|
1623
|
+
const parseDetail = segmentById(root, "parse")!;
|
|
1624
|
+
expect(boxOf(parseDetail).left, "detail starts at the window start").toBeCloseTo(0, 3);
|
|
1625
|
+
expect(boxOf(parseDetail).width, "detail width is window-relative").toBeCloseTo(
|
|
1626
|
+
((0.45 - 0.35) / (0.67 - 0.35)) * 100,
|
|
1627
|
+
3,
|
|
1628
|
+
);
|
|
1629
|
+
const writeDetail = segmentById(root, "write")!;
|
|
1630
|
+
expect(
|
|
1631
|
+
boxOf(writeDetail).left + boxOf(writeDetail).width,
|
|
1632
|
+
"detail ends at the window end",
|
|
1633
|
+
).toBeCloseTo(100, 3);
|
|
1634
|
+
|
|
1635
|
+
// A detail box under the alias is byte-identical to its box with no
|
|
1636
|
+
// alias at all — the window, not the axis, is the detail's unit.
|
|
1637
|
+
const noAlias = mountRibbon({ window: [0.35, 0.67] });
|
|
1638
|
+
expect(boxOf(segmentById(root, "write")!)).toEqual(boxOf(segmentById(noAlias, "write")!));
|
|
1639
|
+
// …while the window frame is axis-projected on the minimap.
|
|
1640
|
+
expectBox(
|
|
1641
|
+
root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
|
|
1642
|
+
{ left: axisPercent(0.35, 0.1), width: axisPercent(0.32, 0.1) },
|
|
1643
|
+
"alias frame",
|
|
1644
|
+
);
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
it("dims the unselected alias axis all the way to its end", () => {
|
|
1648
|
+
// window [0.2, 1]: content 100% is selected, so the whole 1.0..1.1 end of
|
|
1649
|
+
// the alias axis is unselected and must stay dimmed.
|
|
1650
|
+
const root = mountRibbon({ window: [0.2, 1], headroom: 0.1 });
|
|
1651
|
+
expectBox(
|
|
1652
|
+
root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
|
|
1653
|
+
{ left: 0, width: axisPercent(0.2, 0.1) },
|
|
1654
|
+
"before scrim",
|
|
1655
|
+
);
|
|
1656
|
+
const after = root.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]')!;
|
|
1657
|
+
expect(boxOf(after).left, "after scrim starts at content 100%").toBeCloseTo(90.9091, 3);
|
|
1658
|
+
expect(boxOf(after).width, "after scrim covers to the alias end").toBeCloseTo(9.0909, 3);
|
|
1659
|
+
expect(
|
|
1660
|
+
boxOf(after).left + boxOf(after).width,
|
|
1661
|
+
"the scrim reaches the axis end",
|
|
1662
|
+
).toBeCloseTo(100, 3);
|
|
1663
|
+
|
|
1664
|
+
expectBox(
|
|
1665
|
+
root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
|
|
1666
|
+
{ left: axisPercent(0.2, 0.1), width: axisPercent(0.8, 0.1) },
|
|
1667
|
+
"alias frame",
|
|
1668
|
+
);
|
|
1669
|
+
const start = root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
|
|
1670
|
+
const end = root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
|
|
1671
|
+
expect(parseFloat(start.style.left)).toBeCloseTo(axisPercent(0.2, 0.1), 3);
|
|
1672
|
+
expect(parseFloat(end.style.left)).toBeCloseTo(axisPercent(1, 0.1), 3);
|
|
1673
|
+
// The a11y readings are coordinates, not axis percentages.
|
|
1674
|
+
expect(end.getAttribute("aria-valuenow")).toBe("100");
|
|
1675
|
+
expect(end.getAttribute("aria-valuetext")).toBe("100%");
|
|
1676
|
+
expect(
|
|
1677
|
+
root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuetext"),
|
|
1678
|
+
).toBe("20% – 100%");
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
it("sanitizes the deprecated headroom alias into a real axis", () => {
|
|
1682
|
+
const cases: Array<[number, number]> = [
|
|
1683
|
+
[0, 1],
|
|
1684
|
+
[-0.5, 1],
|
|
1685
|
+
[Number.NaN, 1],
|
|
1686
|
+
[Number.NEGATIVE_INFINITY, 1],
|
|
1687
|
+
[Number.POSITIVE_INFINITY, 1],
|
|
1688
|
+
[2, 2],
|
|
1689
|
+
];
|
|
1690
|
+
for (const [headroom, span] of cases) {
|
|
1691
|
+
const root = mountRibbon({ window: null, headroom });
|
|
1692
|
+
const parse = root.querySelector<HTMLElement>(
|
|
1693
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
|
|
1694
|
+
)!;
|
|
1695
|
+
expect(parse, `headroom ${headroom} still paints`).toBeTruthy();
|
|
1696
|
+
expect(boxOf(parse).left, `headroom ${headroom} maps through span ${span}`).toBeCloseTo(
|
|
1697
|
+
(0.3 / span) * 100,
|
|
1698
|
+
3,
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
it("treats headroom as the [0, 1 + headroom] alias, and a domain wins over it", () => {
|
|
1704
|
+
// Equivalence: the alias and its explicit domain draw the same geometry.
|
|
1705
|
+
const alias = mountRibbon({ headroom: 0.1, window: [0.2, 0.6] });
|
|
1706
|
+
const explicit = mountRibbon({ domain: [0, 1.1], window: [0.2, 0.6] });
|
|
1707
|
+
const compared: Array<{ selector: string; width: boolean }> = [
|
|
1708
|
+
{ selector: ".hk-range-ribbon-window", width: true },
|
|
1709
|
+
{ selector: '.hk-range-ribbon-scrim[data-side="before"]', width: true },
|
|
1710
|
+
{ selector: '.hk-range-ribbon-scrim[data-side="after"]', width: true },
|
|
1711
|
+
{ selector: '.hk-range-ribbon-handle[data-edge="start"]', width: false },
|
|
1712
|
+
{ selector: '.hk-range-ribbon-handle[data-edge="end"]', width: false },
|
|
1713
|
+
{ selector: '.hk-range-ribbon-overview-segment[data-segment-id="parse"]', width: true },
|
|
1714
|
+
];
|
|
1715
|
+
for (const { selector, width } of compared) {
|
|
1716
|
+
const fromAlias = alias.querySelector<HTMLElement>(selector)!;
|
|
1717
|
+
const fromDomain = explicit.querySelector<HTMLElement>(selector)!;
|
|
1718
|
+
expect(parseFloat(fromAlias.style.left), `${selector} left`).toBeCloseTo(
|
|
1719
|
+
parseFloat(fromDomain.style.left),
|
|
1720
|
+
10,
|
|
1721
|
+
);
|
|
1722
|
+
if (width) {
|
|
1723
|
+
expect(parseFloat(fromAlias.style.width), `${selector} width`).toBeCloseTo(
|
|
1724
|
+
parseFloat(fromDomain.style.width),
|
|
1725
|
+
10,
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
// The alias announces its own end (110%), never the unit axis.
|
|
1730
|
+
expect(
|
|
1731
|
+
alias.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuemax"),
|
|
1732
|
+
).toBe("110");
|
|
1733
|
+
|
|
1734
|
+
// A valid `domain` wins over a supplied headroom.
|
|
1735
|
+
const both = mountRibbon({ domain: [0, 2], headroom: 0.1 });
|
|
1736
|
+
expect(
|
|
1737
|
+
boxOf(
|
|
1738
|
+
both.querySelector<HTMLElement>(
|
|
1739
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
|
|
1740
|
+
)!,
|
|
1741
|
+
).left,
|
|
1742
|
+
"domain [0, 2] wins: parse at 15%",
|
|
1743
|
+
).toBeCloseTo(15, 3);
|
|
1744
|
+
expect(
|
|
1745
|
+
both.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuemax"),
|
|
1746
|
+
).toBe("200");
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
it("treats the headroom alias as a real axis: selectable tail, axis-end rest state", async () => {
|
|
1750
|
+
// The deprecated `headroom` alias is SOURCE-compatible with the old prop,
|
|
1751
|
+
// not behaviour-compatible: [0, 1.1] is the coordinate system, so the old
|
|
1752
|
+
// "padding, not selectable progress" tail is gone. This case pins the
|
|
1753
|
+
// deltas a headroom host would observe.
|
|
1754
|
+
const host = mountHost([0, 1], { headroom: 0.1 });
|
|
1755
|
+
const root = host.root;
|
|
1756
|
+
// [0, 1] is a SUB-view of a [0, 1.1] axis, so there IS something to reset
|
|
1757
|
+
// (the old component treated content 100% as the rest state).
|
|
1758
|
+
expect(
|
|
1759
|
+
root.querySelector(".hk-range-ribbon-reset"),
|
|
1760
|
+
"a sub-view of the alias axis has a reset chip",
|
|
1761
|
+
).toBeTruthy();
|
|
1762
|
+
keydown(root, "Escape");
|
|
1763
|
+
await nextTick();
|
|
1764
|
+
expect(lastWindow(host.windowChange), "Escape clears to the full axis").toBeNull();
|
|
1765
|
+
|
|
1766
|
+
// The tail is selectable axis: a press at strip 95% reads 1.045 (the old
|
|
1767
|
+
// component clamped it to content 1.0).
|
|
1768
|
+
const { strip, x } = stubStrip(root);
|
|
1769
|
+
await drag(strip, 0.95, 0.5, x);
|
|
1770
|
+
expectWindow(lastWindow(host.windowChange), 0.55, 1.045);
|
|
1771
|
+
|
|
1772
|
+
// Shift+ArrowLeft from the full axis contracts it by 5% of the SPAN
|
|
1773
|
+
// (0.055), not the unit axis's 0.05.
|
|
1774
|
+
keydown(root, "Escape");
|
|
1775
|
+
await nextTick();
|
|
1776
|
+
expect(lastWindow(host.windowChange), "back on the full axis").toBeNull();
|
|
1777
|
+
keydown(root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!, "ArrowLeft", {
|
|
1778
|
+
shiftKey: true,
|
|
1779
|
+
});
|
|
1780
|
+
await nextTick();
|
|
1781
|
+
expectWindow(lastWindow(host.windowChange), 0, 1.045);
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
it("caps minWindow at the axis span when the domain is narrower", () => {
|
|
1785
|
+
// A 0.01 span cannot honour the default 0.02 floor, so the component caps
|
|
1786
|
+
// the floor at the span. The cap is visible in the handle bounds: an
|
|
1787
|
+
// uncapped 0.02 floor would announce 49% / 52% instead of 50% / 51%.
|
|
1788
|
+
const root = mountRibbon({ domain: [0.5, 0.51], window: [0.505, 0.505] });
|
|
1789
|
+
expectBox(
|
|
1790
|
+
root.querySelector<HTMLElement>(".hk-range-ribbon-window"),
|
|
1791
|
+
{ left: 0, width: 100 },
|
|
1792
|
+
"a point expands to the whole narrow axis",
|
|
1793
|
+
);
|
|
1794
|
+
expect(
|
|
1795
|
+
root.querySelector<HTMLElement>('[data-edge="start"]')!.getAttribute("aria-valuemax"),
|
|
1796
|
+
"the floor is the span, not the 0.02 default",
|
|
1797
|
+
).toBe("50");
|
|
1798
|
+
expect(
|
|
1799
|
+
root.querySelector<HTMLElement>('[data-edge="end"]')!.getAttribute("aria-valuemin"),
|
|
1800
|
+
"end floor = start + the capped min",
|
|
1801
|
+
).toBe("51");
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
it("maps pointer input onto the domain and clamps to its edges", async () => {
|
|
1805
|
+
// Domain [0, 2]: half the strip is coordinate 1.0, so a brush from axis
|
|
1806
|
+
// 50% to the far right is [1, 2]. A read that ignored the domain would
|
|
1807
|
+
// answer [0.5, 1].
|
|
1808
|
+
const wide = mountHost(null, { domain: [0, 2] });
|
|
1809
|
+
const wideStrip = stubStrip(wide.root);
|
|
1810
|
+
await drag(wideStrip.strip, 0.5, 1, wideStrip.x);
|
|
1811
|
+
expectWindow(lastWindow(wide.windowChange), 1, 2);
|
|
1812
|
+
|
|
1813
|
+
// Negative start, [-0.1, 1]: axis 50% is coordinate 0.45, and a press
|
|
1814
|
+
// past the right edge still clamps to the domain end 1.
|
|
1815
|
+
const negative = mountHost(null, { domain: [-0.1, 1] });
|
|
1816
|
+
const neg = stubStrip(negative.root);
|
|
1817
|
+
await drag(neg.strip, 0.5, 1.25, neg.x);
|
|
1818
|
+
expectWindow(lastWindow(negative.windowChange), 0.45, 1);
|
|
1819
|
+
|
|
1820
|
+
// The handle path reads the same axis: with domain [0, 2] the end grip
|
|
1821
|
+
// dragged to axis 95% is coordinate 1.9 (a read that ignored the domain
|
|
1822
|
+
// would leave the window at 0.95).
|
|
1823
|
+
await wide.setWindow([0.5, 1.5]);
|
|
1824
|
+
const end = wide.root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
|
|
1825
|
+
expect(end, "the end grip renders for the sub-window").toBeTruthy();
|
|
1826
|
+
await drag(end, 0.75, 0.95, wideStrip.x);
|
|
1827
|
+
expectWindow(lastWindow(wide.windowChange), 0.5, 1.9);
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
it("reads labels and ARIA in domain units (negative and above 100%)", () => {
|
|
1831
|
+
// Negative start: the full view announces its own axis, not 0%..100%.
|
|
1832
|
+
const negative = mountRibbon({ domain: [-0.1, 0.5] });
|
|
1833
|
+
const strip = negative.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1834
|
+
expect(strip.getAttribute("aria-valuemin")).toBe("-10");
|
|
1835
|
+
expect(strip.getAttribute("aria-valuemax")).toBe("50");
|
|
1836
|
+
expect(strip.getAttribute("aria-valuenow"), "full view = domain start").toBe("-10");
|
|
1837
|
+
expect(strip.getAttribute("aria-valuetext")).toBe("-10% – 50%");
|
|
1838
|
+
|
|
1839
|
+
// A window that itself starts below zero keeps its sign on both the strip
|
|
1840
|
+
// and the start grip.
|
|
1841
|
+
const windowed = mountRibbon({ domain: [-0.1, 1], window: [-0.05, 0.4] });
|
|
1842
|
+
const windowedStrip = windowed.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1843
|
+
expect(windowedStrip.getAttribute("aria-valuenow")).toBe("-5");
|
|
1844
|
+
expect(windowedStrip.getAttribute("aria-valuetext")).toBe("-5% – 40%");
|
|
1845
|
+
expect(
|
|
1846
|
+
windowed
|
|
1847
|
+
.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!
|
|
1848
|
+
.getAttribute("aria-valuetext"),
|
|
1849
|
+
).toBe("-5%");
|
|
1850
|
+
|
|
1851
|
+
// Above 100%: a 200% budget reads as 0..200, and a window ending at 150%
|
|
1852
|
+
// announces 150 rather than a rescaled 75.
|
|
1853
|
+
const wide = mountRibbon({ domain: [0, 2], window: [0.5, 1.5] });
|
|
1854
|
+
const wideStrip = wide.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1855
|
+
expect(wideStrip.getAttribute("aria-valuemin")).toBe("0");
|
|
1856
|
+
expect(wideStrip.getAttribute("aria-valuemax")).toBe("200");
|
|
1857
|
+
expect(wideStrip.getAttribute("aria-valuetext")).toBe("50% – 150%");
|
|
1858
|
+
const wideEnd = wide.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
|
|
1859
|
+
expect(wideEnd.getAttribute("aria-valuenow")).toBe("150");
|
|
1860
|
+
expect(wideEnd.getAttribute("aria-valuetext")).toBe("150%");
|
|
1861
|
+
expect(wideEnd.getAttribute("aria-valuemax")).toBe("200");
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it("sanitizes the domain: inverted sorts, unusable values fall back to [0, 1]", () => {
|
|
1865
|
+
// An inverted pair is a caller typo, not a different axis: it is sorted.
|
|
1866
|
+
const inverted = mountRibbon({ domain: [0.6, 0.2] });
|
|
1867
|
+
expect(
|
|
1868
|
+
boxOf(
|
|
1869
|
+
inverted.querySelector<HTMLElement>(
|
|
1870
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
|
|
1871
|
+
)!,
|
|
1872
|
+
).left,
|
|
1873
|
+
"parse 0.3 on the sorted [0.2, 0.6] axis",
|
|
1874
|
+
).toBeCloseTo(25, 3);
|
|
1875
|
+
|
|
1876
|
+
const unusable: unknown[] = [
|
|
1877
|
+
[0.5, 0.5],
|
|
1878
|
+
[Number.NaN, 1],
|
|
1879
|
+
[0.2],
|
|
1880
|
+
[],
|
|
1881
|
+
null,
|
|
1882
|
+
[0.2, Number.POSITIVE_INFINITY],
|
|
1883
|
+
];
|
|
1884
|
+
for (const domain of unusable) {
|
|
1885
|
+
const root = mountRibbon({ domain });
|
|
1886
|
+
const strip = root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1887
|
+
expect(strip.getAttribute("aria-valuemax"), `${JSON.stringify(domain)} falls back`).toBe(
|
|
1888
|
+
"100",
|
|
1889
|
+
);
|
|
1890
|
+
expect(
|
|
1891
|
+
boxOf(
|
|
1892
|
+
root.querySelector<HTMLElement>(
|
|
1893
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
|
|
1894
|
+
)!,
|
|
1895
|
+
).left,
|
|
1896
|
+
).toBeCloseTo(30, 3);
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
it("clips overview segments to the domain and drops points outside it", () => {
|
|
1901
|
+
// Domain [0.2, 0.8]: boot ends exactly at the domain start and flush
|
|
1902
|
+
// starts past its end, so neither paints. An explicit segment list is
|
|
1903
|
+
// needed here: the shared fixture straddles the axis differently.
|
|
1904
|
+
const segments: RibbonSegment[] = [
|
|
1905
|
+
{ id: "boot", lane: "cpu", start: 0.05, end: 0.2, label: "boot" },
|
|
1906
|
+
{ id: "write", lane: "io", start: 0.5, end: 0.9, label: "write" },
|
|
1907
|
+
{ id: "flush", lane: "io", start: 0.95, end: 1, label: "flush" },
|
|
1908
|
+
{ id: "edge", lane: "cpu", start: 0.2, end: 0.2, label: "edge" },
|
|
1909
|
+
{ id: "gone", lane: "cpu", start: 0.9, end: 0.9, label: "gone" },
|
|
1910
|
+
];
|
|
1911
|
+
const root = mountRibbon({ window: null, domain: [0.2, 0.8], segments });
|
|
1912
|
+
expect(root.querySelector('[data-segment-id="boot"]'), "ends at the domain start").toBeNull();
|
|
1913
|
+
expect(root.querySelector('[data-segment-id="flush"]'), "starts past the domain end").toBeNull();
|
|
1914
|
+
// A zero-length point ON the domain start is kept as a tick; one past the
|
|
1915
|
+
// domain end is gone (the domain is the clipping rule, not a clamp).
|
|
1916
|
+
expect(
|
|
1917
|
+
root.querySelector('.hk-range-ribbon-overview-segment[data-segment-id="edge"]'),
|
|
1918
|
+
"a point on the domain edge paints",
|
|
1919
|
+
).toBeTruthy();
|
|
1920
|
+
expect(root.querySelector('[data-segment-id="gone"]'), "a point outside is gone").toBeNull();
|
|
1921
|
+
// A bar straddling the domain end is clipped, not rescaled away.
|
|
1922
|
+
expectBox(
|
|
1923
|
+
root.querySelector<HTMLElement>(
|
|
1924
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="write"]',
|
|
1925
|
+
),
|
|
1926
|
+
{ left: 50, width: 50 },
|
|
1927
|
+
"write clipped at the domain end",
|
|
1928
|
+
);
|
|
1929
|
+
|
|
1930
|
+
// The START clip anchors at the caller's d0, not at absolute 0: on
|
|
1931
|
+
// [-0.5, 1.5] a segment [-1, 0] straddles the domain start and paints
|
|
1932
|
+
// 0%..25% (clipping at 0 would paint 25%..0%: an empty box at the wrong
|
|
1933
|
+
// place). The no-window detail reuses the same domain box.
|
|
1934
|
+
const straddling = mountRibbon({
|
|
1935
|
+
window: null,
|
|
1936
|
+
domain: [-0.5, 1.5],
|
|
1937
|
+
segments: [{ id: "straddle", lane: "cpu", start: -1, end: 0, label: "straddle" }],
|
|
1938
|
+
});
|
|
1939
|
+
expectBox(
|
|
1940
|
+
straddling.querySelector<HTMLElement>(
|
|
1941
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="straddle"]',
|
|
1942
|
+
),
|
|
1943
|
+
{ left: 0, width: 25 },
|
|
1944
|
+
"the overview start clip anchors at d0",
|
|
1945
|
+
);
|
|
1946
|
+
expectBox(
|
|
1947
|
+
segmentById(straddling, "straddle"),
|
|
1948
|
+
{ left: 0, width: 25 },
|
|
1949
|
+
"the no-window detail shares that domain box",
|
|
1950
|
+
);
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
it("treats a window covering the whole domain as the unfiltered state", () => {
|
|
1954
|
+
// The reset chip keys off the DOMAIN: [0, 2] on a [0, 2] axis is the rest
|
|
1955
|
+
// state (nothing to reset), while [0, 1] is a real sub-selection.
|
|
1956
|
+
const full = mountRibbon({ domain: [0, 2], window: [0, 2] });
|
|
1957
|
+
expect(
|
|
1958
|
+
full.querySelector(".hk-range-ribbon-reset"),
|
|
1959
|
+
"the whole axis selected = nothing to reset",
|
|
1960
|
+
).toBeNull();
|
|
1961
|
+
const sub = mountRibbon({ domain: [0, 2], window: [0, 1] });
|
|
1962
|
+
expect(sub.querySelector(".hk-range-ribbon-reset"), "a sub-window shows reset").toBeTruthy();
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
it("nudges the window by 5% of the visible span from the keyboard", async () => {
|
|
1966
|
+
// Domain [0, 2]: one arrow moves the window 0.1 (5% of the span), not the
|
|
1967
|
+
// unit axis's 0.05, so the visual step stays 5% of the strip.
|
|
1968
|
+
const host = mountHost([0.5, 1], { domain: [0, 2] });
|
|
1969
|
+
const strip = host.root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1970
|
+
keydown(strip, "ArrowRight");
|
|
1971
|
+
await nextTick();
|
|
1972
|
+
expectWindow(lastWindow(host.windowChange), 0.6, 1.1);
|
|
1973
|
+
// Shift+Arrow resizes from the trailing edge by the same step.
|
|
1974
|
+
keydown(strip, "ArrowRight", { shiftKey: true });
|
|
1975
|
+
await nextTick();
|
|
1976
|
+
expectWindow(lastWindow(host.windowChange), 0.6, 1.2);
|
|
1977
|
+
// The handle slider nudges its own edge by the same step.
|
|
1978
|
+
const start = host.root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
|
|
1979
|
+
keydown(start, "ArrowRight");
|
|
1980
|
+
await nextTick();
|
|
1981
|
+
expectWindow(lastWindow(host.windowChange), 0.7, 1.2);
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
it("keeps the keyboard range entry inside the caller's domain", async () => {
|
|
1985
|
+
// Shift+ArrowLeft on the full view opens a window from the domain start,
|
|
1986
|
+
// stopped at `max(d0 + minWindow, d1 - step)` — a COORDINATE floor. The
|
|
1987
|
+
// bare-width version went dead on axes whose end sits below minWindow
|
|
1988
|
+
// and emitted sub-minimum windows on tight axes.
|
|
1989
|
+
const open = async (domain: [number, number]): Promise<[number, number] | null> => {
|
|
1990
|
+
const host = mountHost(null, { domain });
|
|
1991
|
+
keydown(host.root.querySelector<HTMLElement>(".hk-range-ribbon-strip")!, "ArrowLeft", {
|
|
1992
|
+
shiftKey: true,
|
|
1993
|
+
});
|
|
1994
|
+
await nextTick();
|
|
1995
|
+
return host.windowChange.mock.calls.length === 0 ? null : lastWindow(host.windowChange);
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
// (a) An axis whose END is below the 0.02 floor: the entry must still
|
|
1999
|
+
// work (the old floor exceeded the domain end and returned early).
|
|
2000
|
+
expectWindow(await open([-1, 0]), -1, -0.05);
|
|
2001
|
+
// (b) A tight axis: the emitted width may never sit below minWindow (the
|
|
2002
|
+
// bare-width floor emitted 0.01995 against a 0.02 floor).
|
|
2003
|
+
const tight = await open([0.5, 0.521]);
|
|
2004
|
+
expectWindow(tight, 0.5, 0.52);
|
|
2005
|
+
expect(tight![1] - tight![0], "never narrower than minWindow").toBeGreaterThanOrEqual(0.02);
|
|
2006
|
+
// (c) The floor anchors at d0 + minWindow (-0.45), not at absolute 0.02.
|
|
2007
|
+
expectWindow(await open([-0.47, 0.03]), -0.47, 0.005);
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
it("keeps a live brush inside a domain that changed mid-gesture", async () => {
|
|
2011
|
+
// The anchor is captured at pointerdown. If the host narrows the axis
|
|
2012
|
+
// while the pointer is down, the anchor must be re-clamped before the
|
|
2013
|
+
// window is built — otherwise the committed window escapes the new axis.
|
|
2014
|
+
const container = document.createElement("div");
|
|
2015
|
+
document.body.appendChild(container);
|
|
2016
|
+
const liveDomain = ref<[number, number]>([0, 1]);
|
|
2017
|
+
const win = ref<[number, number] | null>(null);
|
|
2018
|
+
const windowChange = vi.fn();
|
|
2019
|
+
const Wrapper = defineComponent({
|
|
2020
|
+
setup() {
|
|
2021
|
+
return () =>
|
|
2022
|
+
h(HkRangeRibbon, {
|
|
2023
|
+
lanes: LANES,
|
|
2024
|
+
segments: SEGMENTS,
|
|
2025
|
+
domain: liveDomain.value,
|
|
2026
|
+
window: win.value,
|
|
2027
|
+
onWindowChange: (next: [number, number] | null) => {
|
|
2028
|
+
windowChange(next);
|
|
2029
|
+
win.value = next;
|
|
2030
|
+
},
|
|
2031
|
+
});
|
|
2032
|
+
},
|
|
2033
|
+
});
|
|
2034
|
+
const app = createApp(Wrapper);
|
|
2035
|
+
app.mount(container);
|
|
2036
|
+
mounts.push({ app, container });
|
|
2037
|
+
const root = container.querySelector<HTMLElement>(".hk-range-ribbon")!;
|
|
2038
|
+
const { strip, x } = stubStrip(root);
|
|
2039
|
+
|
|
2040
|
+
// Press at 95% of the unit axis, then narrow the axis to [0, 0.5].
|
|
2041
|
+
pointer("pointerdown", x(0.95), strip);
|
|
2042
|
+
liveDomain.value = [0, 0.5];
|
|
2043
|
+
await nextTick();
|
|
2044
|
+
// Moving to half of the NEW axis must build [0.25, 0.5] (a stale 0.95
|
|
2045
|
+
// anchor would have built [0.25, 0.95], outside the new axis).
|
|
2046
|
+
pointer("pointermove", x(0.5), strip);
|
|
2047
|
+
pointer("pointerup", x(0.5), strip);
|
|
2048
|
+
await nextTick();
|
|
2049
|
+
expectWindow(lastWindow(windowChange), 0.25, 0.5);
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
it("draws the caller's domain on minimal mode's bare bar", async () => {
|
|
2053
|
+
// Minimal mode suppresses the minimap lane rows and the whole detail
|
|
2054
|
+
// layer; the bare bar still lives in the overview container, and its axis
|
|
2055
|
+
// is the caller's domain.
|
|
2056
|
+
const rest = mountRibbon({ minimal: true, domain: [0, 2], window: [0, 2] });
|
|
2057
|
+
const restStart = rest.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
|
|
2058
|
+
const restEnd = rest.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
|
|
2059
|
+
expect(parseFloat(restStart.style.left), "domain start grip").toBeCloseTo(0, 3);
|
|
2060
|
+
expect(parseFloat(restEnd.style.left), "domain end grip").toBeCloseTo(100, 3);
|
|
2061
|
+
expect(restStart.getAttribute("aria-valuetext"), "labels read domain percents").toBe("0%");
|
|
2062
|
+
expect(restEnd.getAttribute("aria-valuetext")).toBe("200%");
|
|
2063
|
+
|
|
2064
|
+
// A sub-window is projected by the domain: [0.5, 1.5] of [0, 2] is
|
|
2065
|
+
// 25%..75%, and the scrims cover the domain the caller did not select.
|
|
2066
|
+
const sub = mountRibbon({ minimal: true, domain: [0, 2], window: [0.5, 1.5] });
|
|
2067
|
+
expect(
|
|
2068
|
+
parseFloat(
|
|
2069
|
+
sub.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!.style.left,
|
|
2070
|
+
),
|
|
2071
|
+
).toBeCloseTo(25, 3);
|
|
2072
|
+
expect(
|
|
2073
|
+
parseFloat(
|
|
2074
|
+
sub.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!.style.left,
|
|
2075
|
+
),
|
|
2076
|
+
).toBeCloseTo(75, 3);
|
|
2077
|
+
expectBox(
|
|
2078
|
+
sub.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="before"]'),
|
|
2079
|
+
{ left: 0, width: 25 },
|
|
2080
|
+
"before scrim",
|
|
2081
|
+
);
|
|
2082
|
+
expectBox(
|
|
2083
|
+
sub.querySelector<HTMLElement>('.hk-range-ribbon-scrim[data-side="after"]'),
|
|
2084
|
+
{ left: 75, width: 25 },
|
|
2085
|
+
"after scrim",
|
|
2086
|
+
);
|
|
2087
|
+
|
|
2088
|
+
// The pointer maps onto the same domain: axis 50% is coordinate 1.0, and
|
|
2089
|
+
// a press past the right edge clamps to the domain end 2.0.
|
|
2090
|
+
const host = mountHost([0.5, 1.5], { minimal: true, domain: [0, 2] });
|
|
2091
|
+
const { strip, x } = stubStrip(host.root);
|
|
2092
|
+
await drag(strip, 0.5, 1.4, x);
|
|
2093
|
+
expectWindow(lastWindow(host.windowChange), 1, 2);
|
|
2094
|
+
});
|
|
2095
|
+
});
|
|
2096
|
+
|
|
1360
2097
|
// ── Stylesheet contract ─────────────────────────────────────────────────
|
|
1361
2098
|
const componentDir = dirname(fileURLToPath(import.meta.url));
|
|
1362
2099
|
|
|
@@ -1511,6 +2248,17 @@ describe("HkRangeRibbon stylesheet contract", () => {
|
|
|
1511
2248
|
expect(css).toContain(".hk-range-ribbon-overview-gutter");
|
|
1512
2249
|
});
|
|
1513
2250
|
|
|
2251
|
+
it("makes the detail layer a plain column container for the lane list", () => {
|
|
2252
|
+
const detail = ruleBody(css, ".hk-range-ribbon-detail");
|
|
2253
|
+
expect(declaration(detail, "display")).toBe("flex");
|
|
2254
|
+
expect(declaration(detail, "flex-direction"), "the lane list keeps its own column").toBe(
|
|
2255
|
+
"column",
|
|
2256
|
+
);
|
|
2257
|
+
// The wrapper adds no rhythm of its own: the lanes keep their gap.
|
|
2258
|
+
expect(detail, "the wrapper owns no gap").not.toContain("gap:");
|
|
2259
|
+
expect(declaration(ruleBody(css, ".hk-range-ribbon-lanes"), "gap")).toBe("var(--space-2)");
|
|
2260
|
+
});
|
|
2261
|
+
|
|
1514
2262
|
it("anchors the tip inward at the track edges", () => {
|
|
1515
2263
|
const tip = ruleBody(css, ".hk-range-ribbon-tip");
|
|
1516
2264
|
expect(declaration(tip, "text-align")).toBe("start");
|
|
@@ -1525,12 +2273,12 @@ describe("HkRangeRibbon stylesheet contract", () => {
|
|
|
1525
2273
|
});
|
|
1526
2274
|
|
|
1527
2275
|
// Wave (2026-11-06 raw-records five): MINIMAL MODE - the bare dual-handle
|
|
1528
|
-
// bar. Contract: data-minimal on the root, strip present,
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
2276
|
+
// bar. Contract: data-minimal on the root, strip present, no lane rows and
|
|
2277
|
+
// no detail layer (the bar still lives in the overview container); the
|
|
2278
|
+
// shared label gutter is still rendered and only display:none in this mode
|
|
2279
|
+
// (the strip spans the full width). The standard mode keeps its lane chrome
|
|
2280
|
+
// + alignment probe. The uncontrolled default window is NULL here — the
|
|
2281
|
+
// grips need a controlled non-null window; the aria readings below come
|
|
1534
2282
|
// from the strip's null-window fallbacks, not from a real window.
|
|
1535
2283
|
it("minimal mode renders the bare dual-handle bar (no lane chrome)", () => {
|
|
1536
2284
|
const root = mountRibbon({ minimal: true });
|