@celestia-island/hikari 0.55.339 → 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,20 +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
32
|
* crossing;
|
|
32
|
-
* · the two layers stay explicit (minimap / detail) over
|
|
33
|
-
*
|
|
34
|
-
* detail
|
|
35
|
-
*
|
|
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.
|
|
36
37
|
* A stylesheet contract at the bottom compiles the sheet and pins the
|
|
37
38
|
* classes, geometry and the one animation (repo convention: vitest stubs
|
|
38
39
|
* CSS, so the runtime cases can never catch a dead rule).
|
|
@@ -288,6 +289,7 @@ describe("HkRangeRibbon full domain", () => {
|
|
|
288
289
|
{ id: "far", lane: "cpu", start: -0.5, end: 0.25, label: "far" },
|
|
289
290
|
{ id: "nan", lane: "cpu", start: Number.NaN, end: 0.4, label: "nan" },
|
|
290
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" },
|
|
291
293
|
];
|
|
292
294
|
const root = mountRibbon({ window: null, segments });
|
|
293
295
|
// Inverted → swapped, out-of-domain → clamped, zero-length kept as a tick.
|
|
@@ -302,6 +304,11 @@ describe("HkRangeRibbon full domain", () => {
|
|
|
302
304
|
// …and a segment can never invent an undeclared lane.
|
|
303
305
|
expect(segmentById(root, "orphan")).toBeNull();
|
|
304
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();
|
|
305
312
|
});
|
|
306
313
|
|
|
307
314
|
it("shares the label column with the lanes so both axes agree", () => {
|
|
@@ -327,7 +334,7 @@ describe("HkRangeRibbon window normalizer parity", () => {
|
|
|
327
334
|
// component must keep CALLING the exported normalizer rather than
|
|
328
335
|
// growing a private copy again.
|
|
329
336
|
const tsx = readFileSync(resolve(componentDir, "HkRangeRibbon.tsx"), "utf8");
|
|
330
|
-
expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value\)/);
|
|
337
|
+
expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value, domain\.value\)/);
|
|
331
338
|
});
|
|
332
339
|
|
|
333
340
|
it("renders exactly the window the exported helper returns", () => {
|
|
@@ -1361,14 +1368,83 @@ describe("HkRangeRibbon controlled contract", () => {
|
|
|
1361
1368
|
});
|
|
1362
1369
|
});
|
|
1363
1370
|
|
|
1364
|
-
// ──
|
|
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 ───────────────────────────
|
|
1365
1418
|
describe("HkRangeRibbon dual-layer waterfall", () => {
|
|
1366
|
-
/** The minimap's x for a
|
|
1367
|
-
*
|
|
1419
|
+
/** The minimap's x for a fraction on the deprecated headroom ALIAS axis
|
|
1420
|
+
* `[0, 1 + headroom]`, in percent. */
|
|
1368
1421
|
function axisPercent(fraction: number, headroom: number): number {
|
|
1369
1422
|
return (fraction / (1 + headroom)) * 100;
|
|
1370
1423
|
}
|
|
1371
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
|
+
|
|
1372
1448
|
it("marks both layers explicitly and keeps the lane markup intact", () => {
|
|
1373
1449
|
const root = mountRibbon({ window: [0.35, 0.67] });
|
|
1374
1450
|
const minimap = root.querySelector<HTMLElement>(".hk-range-ribbon-overview");
|
|
@@ -1397,85 +1473,204 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
|
|
|
1397
1473
|
expect(mountRibbon({ minimal: true }).querySelector(".hk-range-ribbon-detail")).toBeNull();
|
|
1398
1474
|
});
|
|
1399
1475
|
|
|
1400
|
-
it("
|
|
1401
|
-
//
|
|
1402
|
-
|
|
1403
|
-
const
|
|
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>(
|
|
1404
1597
|
'.hk-range-ribbon-overview-segment[data-segment-id="parse"]',
|
|
1405
1598
|
)!;
|
|
1406
|
-
expect(boxOf(
|
|
1407
|
-
|
|
1408
|
-
|
|
1599
|
+
expect(boxOf(overview).left, "both layers share the domain axis").toBeCloseTo(
|
|
1600
|
+
boxOf(parse).left,
|
|
1601
|
+
3,
|
|
1409
1602
|
);
|
|
1410
|
-
expect(boxOf(
|
|
1411
|
-
|
|
1412
|
-
|
|
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",
|
|
1413
1619
|
);
|
|
1414
1620
|
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1621
|
+
// The detail ignores the axis entirely and re-normalizes the window to
|
|
1622
|
+
// its own 0..100% track.
|
|
1417
1623
|
const parseDetail = segmentById(root, "parse")!;
|
|
1418
|
-
expect(boxOf(parseDetail).left, "detail starts at the window start").toBeCloseTo(0,
|
|
1624
|
+
expect(boxOf(parseDetail).left, "detail starts at the window start").toBeCloseTo(0, 3);
|
|
1419
1625
|
expect(boxOf(parseDetail).width, "detail width is window-relative").toBeCloseTo(
|
|
1420
1626
|
((0.45 - 0.35) / (0.67 - 0.35)) * 100,
|
|
1421
|
-
|
|
1627
|
+
3,
|
|
1422
1628
|
);
|
|
1423
1629
|
const writeDetail = segmentById(root, "write")!;
|
|
1424
1630
|
expect(
|
|
1425
1631
|
boxOf(writeDetail).left + boxOf(writeDetail).width,
|
|
1426
1632
|
"detail ends at the window end",
|
|
1427
|
-
).toBeCloseTo(100,
|
|
1633
|
+
).toBeCloseTo(100, 3);
|
|
1428
1634
|
|
|
1429
|
-
// A detail box under
|
|
1430
|
-
//
|
|
1431
|
-
const
|
|
1432
|
-
expect(boxOf(segmentById(root, "write")!)).toEqual(boxOf(segmentById(
|
|
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")!));
|
|
1433
1639
|
// …while the window frame is axis-projected on the minimap.
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
+
);
|
|
1437
1645
|
});
|
|
1438
1646
|
|
|
1439
|
-
it("
|
|
1440
|
-
// window [0.2, 1]:
|
|
1441
|
-
//
|
|
1442
|
-
// leave it bare.
|
|
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.
|
|
1443
1650
|
const root = mountRibbon({ window: [0.2, 1], headroom: 0.1 });
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
4,
|
|
1449
|
-
);
|
|
1450
|
-
expect(boxOf(after).left, "after scrim starts where content 100% sits").toBeCloseTo(
|
|
1451
|
-
axisPercent(1, 0.1),
|
|
1452
|
-
4,
|
|
1453
|
-
);
|
|
1454
|
-
expect(boxOf(after).width, "after scrim width = (span - window end) / span").toBeCloseTo(
|
|
1455
|
-
((1.1 - 1) / 1.1) * 100,
|
|
1456
|
-
4,
|
|
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",
|
|
1457
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);
|
|
1458
1659
|
expect(
|
|
1459
1660
|
boxOf(after).left + boxOf(after).width,
|
|
1460
|
-
"the
|
|
1461
|
-
).toBeCloseTo(100,
|
|
1462
|
-
|
|
1463
|
-
const frame = root.querySelector<HTMLElement>(".hk-range-ribbon-window")!;
|
|
1464
|
-
expect(boxOf(frame).left).toBeCloseTo(axisPercent(0.2, 0.1), 4);
|
|
1465
|
-
expect(boxOf(frame).width).toBeCloseTo(axisPercent(0.8, 0.1), 4);
|
|
1661
|
+
"the scrim reaches the axis end",
|
|
1662
|
+
).toBeCloseTo(100, 3);
|
|
1466
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
|
+
);
|
|
1467
1669
|
const start = root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
|
|
1468
1670
|
const end = root.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
|
|
1469
|
-
expect(parseFloat(start.style.left),
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
);
|
|
1473
|
-
expect(parseFloat(end.style.left), "end grip at content 100% on the axis").toBeCloseTo(
|
|
1474
|
-
axisPercent(1, 0.1),
|
|
1475
|
-
4,
|
|
1476
|
-
);
|
|
1477
|
-
// The a11y readings stay CONTENT percentages: the axis projection is
|
|
1478
|
-
// presentation, never the value the host or assistive tech sees.
|
|
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.
|
|
1479
1674
|
expect(end.getAttribute("aria-valuenow")).toBe("100");
|
|
1480
1675
|
expect(end.getAttribute("aria-valuetext")).toBe("100%");
|
|
1481
1676
|
expect(
|
|
@@ -1483,7 +1678,7 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
|
|
|
1483
1678
|
).toBe("20% – 100%");
|
|
1484
1679
|
});
|
|
1485
1680
|
|
|
1486
|
-
it("sanitizes the headroom
|
|
1681
|
+
it("sanitizes the deprecated headroom alias into a real axis", () => {
|
|
1487
1682
|
const cases: Array<[number, number]> = [
|
|
1488
1683
|
[0, 1],
|
|
1489
1684
|
[-0.5, 1],
|
|
@@ -1500,72 +1695,402 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
|
|
|
1500
1695
|
expect(parse, `headroom ${headroom} still paints`).toBeTruthy();
|
|
1501
1696
|
expect(boxOf(parse).left, `headroom ${headroom} maps through span ${span}`).toBeCloseTo(
|
|
1502
1697
|
(0.3 / span) * 100,
|
|
1503
|
-
|
|
1698
|
+
3,
|
|
1504
1699
|
);
|
|
1505
1700
|
}
|
|
1506
1701
|
});
|
|
1507
1702
|
|
|
1508
|
-
it("
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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",
|
|
1518
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
|
+
}
|
|
1519
1898
|
});
|
|
1520
1899
|
|
|
1521
|
-
it("
|
|
1522
|
-
|
|
1523
|
-
|
|
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
|
+
);
|
|
1524
1929
|
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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();
|
|
1540
1963
|
});
|
|
1541
1964
|
|
|
1542
|
-
it("
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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] });
|
|
1548
2057
|
const restStart = rest.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="start"]')!;
|
|
1549
2058
|
const restEnd = rest.querySelector<HTMLElement>('.hk-range-ribbon-handle[data-edge="end"]')!;
|
|
1550
|
-
expect(parseFloat(restStart.style.left), "
|
|
1551
|
-
expect(parseFloat(restEnd.style.left), "
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
//
|
|
1556
|
-
|
|
1557
|
-
const
|
|
1558
|
-
expect(
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
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] });
|
|
1566
2091
|
const { strip, x } = stubStrip(host.root);
|
|
1567
|
-
await drag(strip, 0.5, 1, x);
|
|
1568
|
-
expectWindow(lastWindow(host.windowChange),
|
|
2092
|
+
await drag(strip, 0.5, 1.4, x);
|
|
2093
|
+
expectWindow(lastWindow(host.windowChange), 1, 2);
|
|
1569
2094
|
});
|
|
1570
2095
|
});
|
|
1571
2096
|
|
|
@@ -1748,12 +2273,12 @@ describe("HkRangeRibbon stylesheet contract", () => {
|
|
|
1748
2273
|
});
|
|
1749
2274
|
|
|
1750
2275
|
// Wave (2026-11-06 raw-records five): MINIMAL MODE - the bare dual-handle
|
|
1751
|
-
// bar. Contract: data-minimal on the root, strip present,
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
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
|
|
1757
2282
|
// from the strip's null-window fallbacks, not from a real window.
|
|
1758
2283
|
it("minimal mode renders the bare dual-handle bar (no lane chrome)", () => {
|
|
1759
2284
|
const root = mountRibbon({ minimal: true });
|