@celestia-island/hikari 0.55.298 → 0.55.299
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.
- package/package.json +1 -1
- package/src/components/HkModal.test.tsx +154 -0
- package/src/components/HkRangeRibbon.barrel.test.ts +9 -0
- package/src/components/HkRangeRibbon.scss +3 -1
- package/src/components/HkRangeRibbon.test.tsx +107 -2
- package/src/components/HkRangeRibbon.tsx +73 -22
- package/src/components/normalizeHkRangeWindow.test.ts +73 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -660,4 +660,158 @@ describe("HkModal Escape precedence", () => {
|
|
|
660
660
|
document.removeEventListener("keydown", pageListener);
|
|
661
661
|
}
|
|
662
662
|
});
|
|
663
|
+
|
|
664
|
+
it("does not close a non-closable window on a consumed Escape, and does not swallow it", async () => {
|
|
665
|
+
const container = document.createElement("div");
|
|
666
|
+
document.body.appendChild(container);
|
|
667
|
+
containers.push(container);
|
|
668
|
+
const open = ref(true);
|
|
669
|
+
const emitted: boolean[] = [];
|
|
670
|
+
const Wrapper = defineComponent({
|
|
671
|
+
setup() {
|
|
672
|
+
return () =>
|
|
673
|
+
h(HkModal, {
|
|
674
|
+
modelValue: open.value,
|
|
675
|
+
closable: false,
|
|
676
|
+
title: "Locked",
|
|
677
|
+
"onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
|
|
678
|
+
}, {
|
|
679
|
+
default: () => h("div", {
|
|
680
|
+
class: "escape-owner",
|
|
681
|
+
onKeydown: (e: KeyboardEvent) => {
|
|
682
|
+
if (e.key === "Escape") e.preventDefault();
|
|
683
|
+
},
|
|
684
|
+
}, "nested control"),
|
|
685
|
+
});
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
const app = createApp(Wrapper);
|
|
689
|
+
mounts.push(app);
|
|
690
|
+
app.mount(container);
|
|
691
|
+
await vi.waitFor(() => {
|
|
692
|
+
expect(document.body.querySelector(".escape-owner")).not.toBeNull();
|
|
693
|
+
});
|
|
694
|
+
const owner = document.body.querySelector<HTMLElement>(".escape-owner")!;
|
|
695
|
+
|
|
696
|
+
const pageHits: string[] = [];
|
|
697
|
+
const pageListener = (e: KeyboardEvent) => {
|
|
698
|
+
if (e.key === "Escape") pageHits.push("escape");
|
|
699
|
+
};
|
|
700
|
+
document.addEventListener("keydown", pageListener);
|
|
701
|
+
try {
|
|
702
|
+
const consumed = new KeyboardEvent("keydown", {
|
|
703
|
+
key: "Escape",
|
|
704
|
+
bubbles: true,
|
|
705
|
+
cancelable: true,
|
|
706
|
+
});
|
|
707
|
+
owner.dispatchEvent(consumed);
|
|
708
|
+
await nextTick();
|
|
709
|
+
expect(consumed.defaultPrevented, "the nested control consumed the key").toBe(true);
|
|
710
|
+
expect(emitted, "a non-closable window never emits on Escape").toEqual([]);
|
|
711
|
+
expect(open.value).toBe(true);
|
|
712
|
+
// Current semantics: the precedence guard returns before the close path
|
|
713
|
+
// and does NOT stopPropagation for a non-closable window, so page-level
|
|
714
|
+
// listeners still see the key.
|
|
715
|
+
expect(pageHits, "a non-closable window must not swallow the key").toEqual(["escape"]);
|
|
716
|
+
} finally {
|
|
717
|
+
document.removeEventListener("keydown", pageListener);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("leaves a bare Escape to the host when the window is not closable", async () => {
|
|
722
|
+
const container = document.createElement("div");
|
|
723
|
+
document.body.appendChild(container);
|
|
724
|
+
containers.push(container);
|
|
725
|
+
const open = ref(true);
|
|
726
|
+
const emitted: boolean[] = [];
|
|
727
|
+
const Wrapper = defineComponent({
|
|
728
|
+
setup() {
|
|
729
|
+
return () =>
|
|
730
|
+
h(HkModal, {
|
|
731
|
+
modelValue: open.value,
|
|
732
|
+
closable: false,
|
|
733
|
+
title: "Locked",
|
|
734
|
+
"onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
|
|
735
|
+
}, {
|
|
736
|
+
default: () => h("div", "content"),
|
|
737
|
+
});
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
const app = createApp(Wrapper);
|
|
741
|
+
mounts.push(app);
|
|
742
|
+
app.mount(container);
|
|
743
|
+
await vi.waitFor(() => {
|
|
744
|
+
expect(document.body.querySelector(".hk-modal-content")).not.toBeNull();
|
|
745
|
+
});
|
|
746
|
+
const content = document.body.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
747
|
+
|
|
748
|
+
const pageHits: string[] = [];
|
|
749
|
+
const pageListener = (e: KeyboardEvent) => {
|
|
750
|
+
if (e.key === "Escape") pageHits.push("escape");
|
|
751
|
+
};
|
|
752
|
+
document.addEventListener("keydown", pageListener);
|
|
753
|
+
try {
|
|
754
|
+
const bare = new KeyboardEvent("keydown", {
|
|
755
|
+
key: "Escape",
|
|
756
|
+
bubbles: true,
|
|
757
|
+
cancelable: true,
|
|
758
|
+
});
|
|
759
|
+
content.dispatchEvent(bare);
|
|
760
|
+
await nextTick();
|
|
761
|
+
expect(emitted, "a non-closable window must stay open").toEqual([]);
|
|
762
|
+
expect(open.value).toBe(true);
|
|
763
|
+
expect(pageHits, "the host still gets the key").toEqual(["escape"]);
|
|
764
|
+
expect(bare.defaultPrevented, "and the window did not consume it").toBe(false);
|
|
765
|
+
} finally {
|
|
766
|
+
document.removeEventListener("keydown", pageListener);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("keeps the Tab focus trap working when a nested control consumes Tab", async () => {
|
|
771
|
+
const container = document.createElement("div");
|
|
772
|
+
document.body.appendChild(container);
|
|
773
|
+
containers.push(container);
|
|
774
|
+
const Wrapper = defineComponent({
|
|
775
|
+
setup() {
|
|
776
|
+
return () =>
|
|
777
|
+
h(HkModal, {
|
|
778
|
+
modelValue: true,
|
|
779
|
+
closable: false,
|
|
780
|
+
title: "Trap",
|
|
781
|
+
}, {
|
|
782
|
+
default: () => h("div", {
|
|
783
|
+
class: "tab-owner",
|
|
784
|
+
onKeydown: (e: KeyboardEvent) => {
|
|
785
|
+
if (e.key === "Tab") e.preventDefault();
|
|
786
|
+
},
|
|
787
|
+
}, [
|
|
788
|
+
h("button", { class: "tab-first" }, "first"),
|
|
789
|
+
h("button", { class: "tab-last" }, "last"),
|
|
790
|
+
]),
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
const app = createApp(Wrapper);
|
|
795
|
+
mounts.push(app);
|
|
796
|
+
app.mount(container);
|
|
797
|
+
await vi.waitFor(() => {
|
|
798
|
+
expect(document.body.querySelector(".tab-last")).not.toBeNull();
|
|
799
|
+
});
|
|
800
|
+
const first = document.body.querySelector<HTMLButtonElement>(".tab-first")!;
|
|
801
|
+
const last = document.body.querySelector<HTMLButtonElement>(".tab-last")!;
|
|
802
|
+
const owner = document.body.querySelector<HTMLElement>(".tab-owner")!;
|
|
803
|
+
|
|
804
|
+
last.focus();
|
|
805
|
+
expect(document.activeElement, "the last focusable holds focus").toBe(last);
|
|
806
|
+
|
|
807
|
+
// A nested control consumes Tab; the focus trap must still run. This pins
|
|
808
|
+
// the defaultPrevented guard INSIDE the Escape branch: hoisting it to the
|
|
809
|
+
// top of the handler would silently disable wrapping for exactly this
|
|
810
|
+
// case.
|
|
811
|
+
owner.dispatchEvent(
|
|
812
|
+
new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }),
|
|
813
|
+
);
|
|
814
|
+
await nextTick();
|
|
815
|
+
expect(document.activeElement, "focus wraps back to the first focusable").toBe(first);
|
|
816
|
+
});
|
|
663
817
|
});
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
HkRangeRibbon,
|
|
5
|
+
normalizeHkRangeWindow,
|
|
5
6
|
type RibbonLane,
|
|
6
7
|
type RibbonSegment,
|
|
7
8
|
type RibbonStatus,
|
|
@@ -22,6 +23,14 @@ describe("the public barrel", () => {
|
|
|
22
23
|
expect(HkRangeRibbon.name).toBe("HkRangeRibbon");
|
|
23
24
|
});
|
|
24
25
|
|
|
26
|
+
it("re-exports the range-window normalizer hosts share", () => {
|
|
27
|
+
expect(typeof normalizeHkRangeWindow, "normalizeHkRangeWindow must be re-exported").toBe(
|
|
28
|
+
"function",
|
|
29
|
+
);
|
|
30
|
+
expect(normalizeHkRangeWindow([0.7, 0.3])).toEqual([0.3, 0.7]);
|
|
31
|
+
expect(normalizeHkRangeWindow(null)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
25
34
|
it("re-exports the ribbon's public types", () => {
|
|
26
35
|
// Type-only exports vanish at runtime, so each one is CONSUMED here: a
|
|
27
36
|
// dropped `type Ribbon*` line fails vue-tsc on this file instead of
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
the reset chip clears the edge handle (right: 4px = the handle's
|
|
13
13
|
half-width, so a handle at 0% / 100% is never covered).
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Colours and spacing come from hikari theme tokens; a few geometry values
|
|
16
|
+
(the 32px strip, the 8px grip, the 2px min-width floor) are deliberate
|
|
17
|
+
literals pinned by the contract, not token drift. The only motion is the
|
|
16
18
|
pending-segment breathe, registered in animation/registerAnimations.ts. */
|
|
17
19
|
|
|
18
20
|
.hk-range-ribbon {
|
|
@@ -6,7 +6,11 @@ import { compile } from "sass";
|
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { createApp, defineComponent, h, nextTick, ref, type Ref } from "vue";
|
|
8
8
|
|
|
9
|
-
import HkRangeRibbon, {
|
|
9
|
+
import HkRangeRibbon, {
|
|
10
|
+
normalizeHkRangeWindow,
|
|
11
|
+
type RibbonLane,
|
|
12
|
+
type RibbonSegment,
|
|
13
|
+
} from "./HkRangeRibbon";
|
|
10
14
|
import { setLocale } from "../i18n/context";
|
|
11
15
|
import jaBundle from "../i18n/locales/ja/components.json";
|
|
12
16
|
|
|
@@ -310,6 +314,50 @@ describe("HkRangeRibbon full domain", () => {
|
|
|
310
314
|
});
|
|
311
315
|
});
|
|
312
316
|
|
|
317
|
+
// ── Exported normalizer / component parity ──────────────────────────────
|
|
318
|
+
describe("HkRangeRibbon window normalizer parity", () => {
|
|
319
|
+
it("keeps the component on the exported helper (source contract)", () => {
|
|
320
|
+
// The rendered-parity cases below are DIVERGENCE detectors: a
|
|
321
|
+
// byte-identical inlined copy of the algorithm renders the same window
|
|
322
|
+
// and passes them. This source pin is the CALL-SITE guard — the
|
|
323
|
+
// component must keep CALLING the exported normalizer rather than
|
|
324
|
+
// growing a private copy again.
|
|
325
|
+
const tsx = readFileSync(resolve(componentDir, "HkRangeRibbon.tsx"), "utf8");
|
|
326
|
+
expect(tsx).toMatch(/return normalizeHkRangeWindow\(source, minWidth\.value\)/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("renders exactly the window the exported helper returns", () => {
|
|
330
|
+
const rawWindows: Array<[number, number]> = [
|
|
331
|
+
[0.7, 0.3],
|
|
332
|
+
[0.5, 0.5],
|
|
333
|
+
[1.2, 1.5],
|
|
334
|
+
[-0.5, -0.2],
|
|
335
|
+
[1, 1],
|
|
336
|
+
[0.49, 0.51],
|
|
337
|
+
];
|
|
338
|
+
for (const raw of rawWindows) {
|
|
339
|
+
const normalized = normalizeHkRangeWindow(raw);
|
|
340
|
+
expect(normalized, `${raw} normalizes`).not.toBeNull();
|
|
341
|
+
|
|
342
|
+
const root = mountRibbon({ window: raw });
|
|
343
|
+
expect(root.hasAttribute("data-windowed"), `${raw} is windowed`).toBe(true);
|
|
344
|
+
const highlight = root.querySelector<HTMLElement>(".hk-range-ribbon-window")!;
|
|
345
|
+
expect(boxOf(highlight).left, `${raw} left`).toBeCloseTo(normalized![0] * 100, 4);
|
|
346
|
+
expect(boxOf(highlight).width, `${raw} width`).toBeCloseTo(
|
|
347
|
+
(normalized![1] - normalized![0]) * 100,
|
|
348
|
+
4,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// Feeding the helper's output back in is idempotent — the rendered
|
|
352
|
+
// box is byte-identical, which is what lets a host pass either the raw
|
|
353
|
+
// or the normalized value into the same prop.
|
|
354
|
+
const again = mountRibbon({ window: [...normalized!] as [number, number] });
|
|
355
|
+
const againHighlight = again.querySelector<HTMLElement>(".hk-range-ribbon-window")!;
|
|
356
|
+
expect(boxOf(againHighlight), `${raw} idempotent`).toEqual(boxOf(highlight));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
313
361
|
// ── Windowed view: clipping ─────────────────────────────────────────────
|
|
314
362
|
describe("HkRangeRibbon windowed view", () => {
|
|
315
363
|
it("clips and re-normalizes only intersecting segments — the only clipping site", () => {
|
|
@@ -802,6 +850,27 @@ describe("HkRangeRibbon reset", () => {
|
|
|
802
850
|
expect(second.defaultPrevented).toBe(true);
|
|
803
851
|
});
|
|
804
852
|
|
|
853
|
+
it("cancels an in-flight brush with Escape and consumes the key", async () => {
|
|
854
|
+
const host = mountHost(null);
|
|
855
|
+
const { strip, x } = stubStrip(host.root);
|
|
856
|
+
// Pointer down and stay inside the brush slop: no window exists yet.
|
|
857
|
+
pointer("pointerdown", x(0.5), strip);
|
|
858
|
+
pointer("pointermove", x(0.5) + 2, strip); // 2px < the 3px mouse slop
|
|
859
|
+
await nextTick();
|
|
860
|
+
expect(host.windowChange, "still inside the slop").not.toHaveBeenCalled();
|
|
861
|
+
|
|
862
|
+
const escape = keydown(strip, "Escape");
|
|
863
|
+
await nextTick();
|
|
864
|
+
expect(escape.defaultPrevented, "cancelling a live gesture consumes the key").toBe(true);
|
|
865
|
+
expect(host.windowChange, "cancelling is not a window change").not.toHaveBeenCalled();
|
|
866
|
+
|
|
867
|
+
// PROBE: the gesture is gone — a later move must not revive the brush.
|
|
868
|
+
pointer("pointermove", x(0.8), strip);
|
|
869
|
+
await nextTick();
|
|
870
|
+
expect(host.windowChange, "no brush may be revived after the cancel").not.toHaveBeenCalled();
|
|
871
|
+
expect(host.root.hasAttribute("data-windowed")).toBe(false);
|
|
872
|
+
});
|
|
873
|
+
|
|
805
874
|
it("drops an in-flight brush when a reset interrupts it", async () => {
|
|
806
875
|
const host = mountHost(null);
|
|
807
876
|
const { strip, x } = stubStrip(host.root);
|
|
@@ -1107,6 +1176,38 @@ describe("HkRangeRibbon a11y and i18n", () => {
|
|
|
1107
1176
|
expect(hint!.textContent).toContain("Resize window");
|
|
1108
1177
|
});
|
|
1109
1178
|
|
|
1179
|
+
it("announces a narrow window with a decimal so it differs from the full domain", () => {
|
|
1180
|
+
const full = mountRibbon({ window: null });
|
|
1181
|
+
expect(
|
|
1182
|
+
full.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuetext"),
|
|
1183
|
+
"the full domain keeps the classic integer text",
|
|
1184
|
+
).toBe("0% – 100%");
|
|
1185
|
+
|
|
1186
|
+
const narrow = mountRibbon({ window: [0.004, 0.996] });
|
|
1187
|
+
const strip = narrow.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1188
|
+
const text = strip.getAttribute("aria-valuetext")!;
|
|
1189
|
+
expect(text, "a fractional window keeps one decimal").toContain("0.4%");
|
|
1190
|
+
expect(text).toContain("99.6%");
|
|
1191
|
+
expect(text, "rounding to integers would be indistinguishable").not.toBe("0% – 100%");
|
|
1192
|
+
expect(strip.getAttribute("aria-valuenow"), "valuenow uses the same precision").toBe("0.4");
|
|
1193
|
+
|
|
1194
|
+
const start = narrow.querySelector<HTMLElement>('[data-edge="start"]')!;
|
|
1195
|
+
const end = narrow.querySelector<HTMLElement>('[data-edge="end"]')!;
|
|
1196
|
+
expect(start.getAttribute("aria-valuenow")).toBe("0.4");
|
|
1197
|
+
expect(start.getAttribute("aria-valuetext")).toBe("0.4%");
|
|
1198
|
+
expect(end.getAttribute("aria-valuetext")).toBe("99.6%");
|
|
1199
|
+
// The handle bounds share the precision: at a razor-narrow window an
|
|
1200
|
+
// integer-rounded max (97) would contradict a decimal valuenow (97.5).
|
|
1201
|
+
expect(start.getAttribute("aria-valuemax"), "start max keeps the decimal").toBe("97.6");
|
|
1202
|
+
expect(end.getAttribute("aria-valuemin"), "end min keeps the decimal").toBe("2.4");
|
|
1203
|
+
|
|
1204
|
+
// An integer window keeps the integer text the earlier pins assert.
|
|
1205
|
+
const integer = mountRibbon({ window: [0.2, 0.6] });
|
|
1206
|
+
expect(
|
|
1207
|
+
integer.querySelector<HTMLElement>(".hk-range-ribbon-strip")!.getAttribute("aria-valuetext"),
|
|
1208
|
+
).toBe("20% – 60%");
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1110
1211
|
it("localizes the labels and hint from the active locale bundle", async () => {
|
|
1111
1212
|
await setLocale("ja");
|
|
1112
1213
|
const root = mountRibbon({ window: [0.2, 0.6] });
|
|
@@ -1293,8 +1394,12 @@ describe("HkRangeRibbon stylesheet contract", () => {
|
|
|
1293
1394
|
);
|
|
1294
1395
|
expect(declaration(handle, "cursor")).toBe("ew-resize");
|
|
1295
1396
|
expect(handle).toContain("touch-action: none");
|
|
1397
|
+
// Require the UNPREFIXED declaration at a declaration boundary: the old
|
|
1398
|
+
// /user-select:\s*none/ also matched "-webkit-user-select: none", so a
|
|
1399
|
+
// hypothetical polyfill that emitted only the prefixed property would
|
|
1400
|
+
// satisfy the pin while the standard property was gone.
|
|
1296
1401
|
expect(handle, "a handle drag cannot start a text selection").toMatch(
|
|
1297
|
-
/user-select
|
|
1402
|
+
/(?:^|[;{])\s*user-select\s*:\s*none/,
|
|
1298
1403
|
);
|
|
1299
1404
|
|
|
1300
1405
|
// A clipped 2px sliver must still be visible.
|
|
@@ -87,21 +87,60 @@ function clamp01(value: number): number {
|
|
|
87
87
|
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Percent with adaptive precision. An integer percentage stays an integer —
|
|
91
|
+
* the full domain must keep announcing the classic "0% – 100%" — while a
|
|
92
|
+
* fractional one keeps a decimal, so a narrow window ("0.4% – 99.6%") is
|
|
93
|
+
* distinguishable from the full domain instead of rounding to the same
|
|
94
|
+
* text. */
|
|
95
|
+
function percentNumber(fraction: number): number {
|
|
96
|
+
const percentage = fraction * 100;
|
|
97
|
+
const rounded = Math.round(percentage);
|
|
98
|
+
return Math.abs(percentage - rounded) < 1e-9 ? rounded : Number(percentage.toFixed(1));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function percentText(fraction: number): string {
|
|
102
|
+
return `${percentNumber(fraction)}%`;
|
|
103
|
+
}
|
|
104
|
+
|
|
90
105
|
/** Inline left/width pair — percentages are deterministic in tests and do
|
|
91
106
|
* not depend on layout measurement for rendering. */
|
|
92
107
|
function percent(fraction: number): string {
|
|
93
108
|
return `${Number((fraction * 100).toFixed(4))}%`;
|
|
94
109
|
}
|
|
95
110
|
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a host-supplied window into the ribbon's contract.
|
|
113
|
+
*
|
|
114
|
+
* This is the ONE implementation the component and its hosts share — the
|
|
115
|
+
* component's former private helper, identical except the min-guard, which
|
|
116
|
+
* absorbs the component's existing sanitizer (that helper had no other
|
|
117
|
+
* observers, so this is not a behaviour change). A consumer that keeps its
|
|
118
|
+
* own rendering/filter driven by the same window (chest's trace model builds
|
|
119
|
+
* its row filter from these fractions) must import this instead of mirroring
|
|
120
|
+
* the algorithm: a copy forks silently the moment the algorithm or the 0.02
|
|
121
|
+
* default changes upstream.
|
|
122
|
+
*
|
|
123
|
+
* @param input Any host window value. `null` / `undefined`, a non-array, an
|
|
124
|
+
* array with fewer than two entries, or either member non-finite all mean
|
|
125
|
+
* "no window" and return `null` (the host reads that as the full domain).
|
|
126
|
+
* @param minWidth Narrowest window the ribbon will produce, defaulting to
|
|
127
|
+
* 0.02. Clamped to [0, 1]; a non-finite or non-positive value falls back
|
|
128
|
+
* to the default.
|
|
129
|
+
* @returns An ordered, domain-clamped `[start, end]` pair of full-domain
|
|
130
|
+
* fractions, never narrower than `minWidth`, or `null` for "no window".
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeHkRangeWindow(
|
|
133
|
+
input: unknown,
|
|
134
|
+
minWidth: number = MIN_WINDOW_FALLBACK,
|
|
135
|
+
): [number, number] | null {
|
|
100
136
|
if (input == null || !Array.isArray(input) || input.length < 2) return null;
|
|
101
137
|
const first = Number(input[0]);
|
|
102
138
|
const second = Number(input[1]);
|
|
103
139
|
if (!Number.isFinite(first) || !Number.isFinite(second)) return null;
|
|
104
|
-
const min =
|
|
140
|
+
const min =
|
|
141
|
+
Number.isFinite(minWidth) && minWidth > 0
|
|
142
|
+
? Math.min(minWidth, 1)
|
|
143
|
+
: MIN_WINDOW_FALLBACK;
|
|
105
144
|
let lo = clamp01(Math.min(first, second));
|
|
106
145
|
let hi = clamp01(Math.max(first, second));
|
|
107
146
|
if (hi - lo < min) {
|
|
@@ -142,8 +181,10 @@ function intersectsWindow(placed: PlacedSegment, win: WindowRange): boolean {
|
|
|
142
181
|
}
|
|
143
182
|
|
|
144
183
|
/** Clamp every segment into the domain; drop NaN/unusable entries (a broken
|
|
145
|
-
* row must not take the whole ribbon down). A zero-length segment is kept
|
|
146
|
-
*
|
|
184
|
+
* row must not take the whole ribbon down). A zero-length segment is kept:
|
|
185
|
+
* the LANE sheet gives it a 2px min-width floor under `box-sizing:
|
|
186
|
+
* border-box`, so a lane tick is exactly that 2px (the 1px borders sit
|
|
187
|
+
* inside it); the overview twin draws 1px. */
|
|
147
188
|
function placeSegments(segments: RibbonSegment[] | undefined): PlacedSegment[] {
|
|
148
189
|
if (!Array.isArray(segments)) return [];
|
|
149
190
|
const placed: PlacedSegment[] = [];
|
|
@@ -254,7 +295,7 @@ export default defineComponent({
|
|
|
254
295
|
const activeWindow = computed<WindowRange | null>(() => {
|
|
255
296
|
if (!props.windowEnabled) return null;
|
|
256
297
|
const source = controlled.value ? props.window : internalWindow.value;
|
|
257
|
-
return
|
|
298
|
+
return normalizeHkRangeWindow(source, minWidth.value);
|
|
258
299
|
});
|
|
259
300
|
|
|
260
301
|
const lanes = computed<RibbonLane[]>(() =>
|
|
@@ -402,7 +443,12 @@ export default defineComponent({
|
|
|
402
443
|
if (!props.windowEnabled || gesture) return;
|
|
403
444
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
404
445
|
if (!activeWindow.value) return;
|
|
405
|
-
// The
|
|
446
|
+
// The handles are SIBLINGS of the strip, not children of it, so this
|
|
447
|
+
// press never reaches the strip's brush handler in the first place.
|
|
448
|
+
// The stopPropagation below keeps the press out of BUBBLE-phase
|
|
449
|
+
// listeners in the consuming host — none exist in-package today (every
|
|
450
|
+
// in-package document-level pointerdown listener is capture-phase and
|
|
451
|
+
// still sees the press).
|
|
406
452
|
event.stopPropagation();
|
|
407
453
|
gesture = { kind: "edge", pointerId: event.pointerId, edge };
|
|
408
454
|
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
|
|
@@ -470,10 +516,17 @@ export default defineComponent({
|
|
|
470
516
|
function onRootKeyDown(event: KeyboardEvent): void {
|
|
471
517
|
if (!props.windowEnabled) return;
|
|
472
518
|
// Escape resets from anywhere inside the ribbon — but only a REAL reset
|
|
473
|
-
// consumes the key: with no window
|
|
474
|
-
// (a dialog closes on it while focus sits in here).
|
|
519
|
+
// consumes the key: with no window AND no live gesture, Escape still
|
|
520
|
+
// reaches the host (a dialog closes on it while focus sits in here).
|
|
475
521
|
if (event.key === "Escape") {
|
|
476
|
-
|
|
522
|
+
// An in-flight drag is a real, unsaved gesture: cancel it even before
|
|
523
|
+
// the first commit (a brush still inside the slop has produced no
|
|
524
|
+
// window yet, so the reset path alone would find nothing to do).
|
|
525
|
+
// Cancelling consumes the key on its own; with NO gesture and no
|
|
526
|
+
// window the key is still handed back to the host.
|
|
527
|
+
const hadGesture = gesture !== null;
|
|
528
|
+
gesture = null;
|
|
529
|
+
if (resetWindow() || hadGesture) event.preventDefault();
|
|
477
530
|
return;
|
|
478
531
|
}
|
|
479
532
|
if (event.key !== "Home") return;
|
|
@@ -582,10 +635,8 @@ export default defineComponent({
|
|
|
582
635
|
aria-disabled={props.windowEnabled ? undefined : "true"}
|
|
583
636
|
aria-valuemin={0}
|
|
584
637
|
aria-valuemax={100}
|
|
585
|
-
aria-valuenow={
|
|
586
|
-
aria-valuetext={`${
|
|
587
|
-
(win ? win[1] : 1) * 100,
|
|
588
|
-
)}%`}
|
|
638
|
+
aria-valuenow={percentNumber(win ? win[0] : 0)}
|
|
639
|
+
aria-valuetext={`${percentText(win ? win[0] : 0)} – ${percentText(win ? win[1] : 1)}`}
|
|
589
640
|
title={
|
|
590
641
|
props.windowEnabled
|
|
591
642
|
? t(
|
|
@@ -671,9 +722,9 @@ export default defineComponent({
|
|
|
671
722
|
tabindex={0}
|
|
672
723
|
aria-label={t("hikari::ribbon.windowStart", "Window start")}
|
|
673
724
|
aria-valuemin={0}
|
|
674
|
-
aria-valuemax={
|
|
675
|
-
aria-valuenow={
|
|
676
|
-
aria-valuetext={
|
|
725
|
+
aria-valuemax={percentNumber(win[1] - minWidth.value)}
|
|
726
|
+
aria-valuenow={percentNumber(win[0])}
|
|
727
|
+
aria-valuetext={percentText(win[0])}
|
|
677
728
|
style={{ left: percent(win[0]) }}
|
|
678
729
|
onPointerdown={(event: PointerEvent) => onHandlePointerDown("start", event)}
|
|
679
730
|
onKeydown={(event: KeyboardEvent) => onHandleKeyDown("start", event)}
|
|
@@ -685,10 +736,10 @@ export default defineComponent({
|
|
|
685
736
|
aria-orientation="horizontal"
|
|
686
737
|
tabindex={0}
|
|
687
738
|
aria-label={t("hikari::ribbon.windowEnd", "Window end")}
|
|
688
|
-
aria-valuemin={
|
|
739
|
+
aria-valuemin={percentNumber(win[0] + minWidth.value)}
|
|
689
740
|
aria-valuemax={100}
|
|
690
|
-
aria-valuenow={
|
|
691
|
-
aria-valuetext={
|
|
741
|
+
aria-valuenow={percentNumber(win[1])}
|
|
742
|
+
aria-valuetext={percentText(win[1])}
|
|
692
743
|
style={{ left: percent(win[1]) }}
|
|
693
744
|
onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
|
|
694
745
|
onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { normalizeHkRangeWindow } from "./HkRangeRibbon";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Public contract of `normalizeHkRangeWindow` — the ONE normalizer the
|
|
7
|
+
* ribbon and its hosts share.
|
|
8
|
+
*
|
|
9
|
+
* Motivation: chest's trace model mirrored this algorithm line-by-line
|
|
10
|
+
* (including the 0.02 minimum) to filter its rows with the same window. A
|
|
11
|
+
* mirror like that only has prose pins, so an upstream algorithm/default
|
|
12
|
+
* change forks the host silently. These pins are the function's contract —
|
|
13
|
+
* exact tuples, not "close enough" — because hosts bake these numbers into
|
|
14
|
+
* their own filters.
|
|
15
|
+
*/
|
|
16
|
+
/** Window endpoints at double precision + the contract invariants exactly
|
|
17
|
+
* (ordered, in-domain, at least `minWidth` wide). The endpoints of an
|
|
18
|
+
* expanded window are arithmetic results (e.g. 0.5 - 0.7/2), so pinning the
|
|
19
|
+
* 17th significant digit would test IEEE-754, not the contract. */
|
|
20
|
+
function expectWindow(
|
|
21
|
+
actual: [number, number] | null,
|
|
22
|
+
expected: [number, number],
|
|
23
|
+
minWidth = 0.02,
|
|
24
|
+
): void {
|
|
25
|
+
expect(actual, "a window is returned").not.toBeNull();
|
|
26
|
+
expect(actual![0], "start").toBeCloseTo(expected[0], 12);
|
|
27
|
+
expect(actual![1], "end").toBeCloseTo(expected[1], 12);
|
|
28
|
+
expect(actual![0], "ordered").toBeLessThan(actual![1]);
|
|
29
|
+
expect(actual![0], "clamped to the domain").toBeGreaterThanOrEqual(0);
|
|
30
|
+
expect(actual![1], "clamped to the domain").toBeLessThanOrEqual(1);
|
|
31
|
+
expect(actual![1] - actual![0], "never narrower than minWidth").toBeGreaterThanOrEqual(
|
|
32
|
+
minWidth - 1e-12,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("normalizeHkRangeWindow", () => {
|
|
37
|
+
it("returns null for anything that is not a usable window", () => {
|
|
38
|
+
expect(normalizeHkRangeWindow(null)).toBeNull();
|
|
39
|
+
expect(normalizeHkRangeWindow(undefined)).toBeNull();
|
|
40
|
+
// Not an array / not a pair / not finite: all mean "no window".
|
|
41
|
+
expect(normalizeHkRangeWindow(0.5)).toBeNull();
|
|
42
|
+
expect(normalizeHkRangeWindow("0.2,0.4")).toBeNull();
|
|
43
|
+
expect(normalizeHkRangeWindow([])).toBeNull();
|
|
44
|
+
expect(normalizeHkRangeWindow([0.2])).toBeNull();
|
|
45
|
+
expect(normalizeHkRangeWindow([Number.NaN, 0.5])).toBeNull();
|
|
46
|
+
expect(normalizeHkRangeWindow([0.2, Number.POSITIVE_INFINITY])).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("orders inverted windows and clamps them to the domain", () => {
|
|
50
|
+
expectWindow(normalizeHkRangeWindow([0.7, 0.3]), [0.3, 0.7]);
|
|
51
|
+
// Both members beyond the end: collapsed to the trailing minimum.
|
|
52
|
+
expectWindow(normalizeHkRangeWindow([1.2, 1.5]), [0.98, 1]);
|
|
53
|
+
// Both members before the start: collapsed to the leading minimum.
|
|
54
|
+
expectWindow(normalizeHkRangeWindow([-0.5, -0.2]), [0, 0.02]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("expands a degenerate window to the default minimum (0.02)", () => {
|
|
58
|
+
expectWindow(normalizeHkRangeWindow([0.5, 0.5]), [0.49, 0.51]);
|
|
59
|
+
// Exactly the minimum width is left alone — no gratuitous expansion.
|
|
60
|
+
expectWindow(normalizeHkRangeWindow([0.49, 0.51]), [0.49, 0.51]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("honours a custom minWidth", () => {
|
|
64
|
+
expectWindow(normalizeHkRangeWindow([0.5, 0.5], 0.2), [0.4, 0.6], 0.2);
|
|
65
|
+
expectWindow(normalizeHkRangeWindow([0.5, 0.5], 0.7), [0.15, 0.85], 0.7);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to the default for an unusable minWidth", () => {
|
|
69
|
+
for (const minWidth of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
70
|
+
expectWindow(normalizeHkRangeWindow([0.5, 0.5], minWidth), [0.49, 0.51]);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -826,6 +826,7 @@ export { default as HkTagDotRow } from "./components/HkTagDotRow";
|
|
|
826
826
|
export { default as HkModelCard, type ModelCardTier } from "./components/HkModelCard";
|
|
827
827
|
export {
|
|
828
828
|
default as HkRangeRibbon,
|
|
829
|
+
normalizeHkRangeWindow,
|
|
829
830
|
type RibbonLane,
|
|
830
831
|
type RibbonSegment,
|
|
831
832
|
type RibbonStatus,
|