@celestia-island/hikari 0.55.297 → 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/HkDrawer.test.tsx +57 -0
- package/src/components/HkDrawer.tsx +7 -2
- package/src/components/HkImageLightbox.test.tsx +23 -0
- package/src/components/HkImageLightbox.tsx +6 -1
- package/src/components/HkModal.test.tsx +154 -0
- package/src/components/HkPopover.escape.test.tsx +155 -0
- package/src/components/HkPopover.tsx +8 -0
- package/src/components/HkRangeRibbon.barrel.test.ts +9 -0
- package/src/components/HkRangeRibbon.scss +3 -1
- package/src/components/HkRangeRibbon.test.tsx +124 -2
- package/src/components/HkRangeRibbon.tsx +96 -39
- package/src/components/normalizeHkRangeWindow.test.ts +73 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -146,3 +146,60 @@ describe("HkDrawer window-stack close channel", () => {
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
describe("HkDrawer Escape precedence", () => {
|
|
151
|
+
async function settle(): Promise<void> {
|
|
152
|
+
await nextTick();
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
154
|
+
await nextTick();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it("lets a nested control own Escape while a bare Escape still closes", async () => {
|
|
158
|
+
const open = ref(true);
|
|
159
|
+
const container = document.createElement("div");
|
|
160
|
+
document.body.appendChild(container);
|
|
161
|
+
containers.push(container);
|
|
162
|
+
const Wrapper = defineComponent({
|
|
163
|
+
setup() {
|
|
164
|
+
return () =>
|
|
165
|
+
h(HkDrawer, {
|
|
166
|
+
modelValue: open.value,
|
|
167
|
+
closable: true,
|
|
168
|
+
title: "Inspector",
|
|
169
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
170
|
+
}, {
|
|
171
|
+
default: () => h("div", {
|
|
172
|
+
class: "escape-owner",
|
|
173
|
+
onKeydown: (e: KeyboardEvent) => {
|
|
174
|
+
if (e.key === "Escape") e.preventDefault();
|
|
175
|
+
},
|
|
176
|
+
}, "nested control"),
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const app = createApp(Wrapper);
|
|
181
|
+
mounts.push(app);
|
|
182
|
+
app.mount(container);
|
|
183
|
+
await settle();
|
|
184
|
+
|
|
185
|
+
const owner = document.body.querySelector<HTMLElement>(".hk-drawer-panel .escape-owner");
|
|
186
|
+
expect(owner, "the slot content renders inside the panel").not.toBeNull();
|
|
187
|
+
|
|
188
|
+
// `cancelable: true` is load-bearing: preventDefault() on a non-cancelable
|
|
189
|
+
// synthetic event is a no-op, and the drawer would then close as if
|
|
190
|
+
// nothing had consumed the key.
|
|
191
|
+
owner!.dispatchEvent(
|
|
192
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
193
|
+
);
|
|
194
|
+
await settle();
|
|
195
|
+
expect(open.value, "a consumed Escape must not close the drawer").toBe(true);
|
|
196
|
+
|
|
197
|
+
// A bare Escape (nothing below consumed it) still closes.
|
|
198
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-drawer-panel")!;
|
|
199
|
+
panel.dispatchEvent(
|
|
200
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
201
|
+
);
|
|
202
|
+
await settle();
|
|
203
|
+
expect(open.value).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -211,7 +211,12 @@ export default defineComponent({
|
|
|
211
211
|
if (props.closable && props.overlay) close();
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
function onEscape() {
|
|
214
|
+
function onEscape(e: KeyboardEvent) {
|
|
215
|
+
// Precedence (the same class as HkModal's 0.55.295 fix): a nested
|
|
216
|
+
// control that already consumed Escape owns the key, so the drawer
|
|
217
|
+
// must not close underneath it. Precedence only — this surface has
|
|
218
|
+
// never preventDefault'd/stopPropagation'd Escape, and still does not.
|
|
219
|
+
if (e.defaultPrevented) return;
|
|
215
220
|
if (props.closable) close();
|
|
216
221
|
}
|
|
217
222
|
|
|
@@ -300,7 +305,7 @@ export default defineComponent({
|
|
|
300
305
|
aria-modal={props.overlay}
|
|
301
306
|
tabindex={-1}
|
|
302
307
|
onKeydown={(e: KeyboardEvent) => {
|
|
303
|
-
if (e.key === "Escape") onEscape();
|
|
308
|
+
if (e.key === "Escape") onEscape(e);
|
|
304
309
|
else if (e.key === "Tab" && panelRef.value) trapFocus(panelRef.value, e);
|
|
305
310
|
}}
|
|
306
311
|
>
|
|
@@ -90,6 +90,29 @@ describe("HkImageLightbox", () => {
|
|
|
90
90
|
expect(s.emitted).toEqual([false]);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
it("a nested consumed Escape does not close the lightbox", async () => {
|
|
94
|
+
const s = mountLightbox(true);
|
|
95
|
+
const dialog = s.dialog()!;
|
|
96
|
+
const owner = dialog.querySelector<HTMLElement>(".hk-image-viewer-img")!;
|
|
97
|
+
// Model a nested control that owns Escape. `cancelable: true` is
|
|
98
|
+
// load-bearing: preventDefault() on a non-cancelable event is a no-op.
|
|
99
|
+
owner.addEventListener("keydown", (e) => {
|
|
100
|
+
if (e.key === "Escape") e.preventDefault();
|
|
101
|
+
}, { once: true });
|
|
102
|
+
|
|
103
|
+
owner.dispatchEvent(
|
|
104
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
105
|
+
);
|
|
106
|
+
await nextTick();
|
|
107
|
+
expect(s.emitted, "a consumed Escape must not close the lightbox").toEqual([]);
|
|
108
|
+
expect(s.dialog()).not.toBeNull();
|
|
109
|
+
|
|
110
|
+
// A bare Escape (nothing below consumed it) still closes.
|
|
111
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true }));
|
|
112
|
+
await nextTick();
|
|
113
|
+
expect(s.emitted).toEqual([false]);
|
|
114
|
+
});
|
|
115
|
+
|
|
93
116
|
it("the Escape listener is removed once closed (no stray closes)", async () => {
|
|
94
117
|
const s = mountLightbox(true);
|
|
95
118
|
|
|
@@ -37,7 +37,12 @@ export default defineComponent({
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function onWindowKeydown(e: KeyboardEvent) {
|
|
40
|
-
|
|
40
|
+
// A nested control that consumed Escape owns the key. This listener
|
|
41
|
+
// sits on `window`, i.e. LAST in the bubble path, so
|
|
42
|
+
// `event.defaultPrevented` is the cross-component signal (same
|
|
43
|
+
// precedence rule as HkModal's 0.55.295 fix).
|
|
44
|
+
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
45
|
+
close();
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
watch(
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref, type Ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkPopover from "./HkPopover";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkPopover Escape precedence (the same class as HkModal 0.55.295 and the
|
|
8
|
+
* HkDrawer / HkImageLightbox siblings).
|
|
9
|
+
*
|
|
10
|
+
* The popover panel hosts the consumer's DEFAULT SLOT, so it is a
|
|
11
|
+
* composition surface: a control inside it may consume Escape first
|
|
12
|
+
* (HkRangeRibbon resetting a brushed range, a date-picker trigger, a tag
|
|
13
|
+
* search). The popover must honour that consumption instead of closing the
|
|
14
|
+
* panel underneath the control. Its own consumption is unchanged — when it
|
|
15
|
+
* does close it still preventDefaults and still does not stopPropagation.
|
|
16
|
+
*/
|
|
17
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
21
|
+
app.unmount();
|
|
22
|
+
container.remove();
|
|
23
|
+
}
|
|
24
|
+
document.body
|
|
25
|
+
.querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
|
|
26
|
+
.forEach((el) => el.remove());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
async function settle(): Promise<void> {
|
|
30
|
+
await nextTick();
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
32
|
+
await nextTick();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Setup {
|
|
36
|
+
open: Ref<boolean>;
|
|
37
|
+
emitted: boolean[];
|
|
38
|
+
panel: () => HTMLElement | null;
|
|
39
|
+
owner: () => HTMLElement | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mountPopover(options: { closeOnEscape?: boolean } = {}): Setup {
|
|
43
|
+
const container = document.createElement("div");
|
|
44
|
+
document.body.appendChild(container);
|
|
45
|
+
const open = ref(true);
|
|
46
|
+
const emitted: boolean[] = [];
|
|
47
|
+
const Wrapper = defineComponent({
|
|
48
|
+
setup() {
|
|
49
|
+
return () =>
|
|
50
|
+
h(HkPopover as never, {
|
|
51
|
+
modelValue: open.value,
|
|
52
|
+
title: "probe",
|
|
53
|
+
closeOnEscape: options.closeOnEscape ?? true,
|
|
54
|
+
"onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
|
|
55
|
+
}, {
|
|
56
|
+
default: () => h("div", {
|
|
57
|
+
class: "escape-owner",
|
|
58
|
+
onKeydown: (e: KeyboardEvent) => {
|
|
59
|
+
if (e.key === "Escape") e.preventDefault();
|
|
60
|
+
},
|
|
61
|
+
}, "nested control"),
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const app = createApp(Wrapper);
|
|
66
|
+
app.mount(container);
|
|
67
|
+
mounts.push({ app, container });
|
|
68
|
+
return {
|
|
69
|
+
open,
|
|
70
|
+
emitted,
|
|
71
|
+
panel: () => document.body.querySelector<HTMLElement>(".hk-popover-panel"),
|
|
72
|
+
owner: () => document.body.querySelector<HTMLElement>(".hk-popover-panel .escape-owner"),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("HkPopover Escape precedence", () => {
|
|
77
|
+
it("lets a nested slot control own Escape", async () => {
|
|
78
|
+
const s = mountPopover();
|
|
79
|
+
await settle();
|
|
80
|
+
const owner = s.owner();
|
|
81
|
+
expect(owner, "the slot control renders inside the panel").not.toBeNull();
|
|
82
|
+
|
|
83
|
+
// `cancelable: true` is load-bearing: preventDefault() on a
|
|
84
|
+
// non-cancelable synthetic event is a no-op, and the popover would then
|
|
85
|
+
// close as if nothing had consumed the key.
|
|
86
|
+
owner!.dispatchEvent(
|
|
87
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
88
|
+
);
|
|
89
|
+
await settle();
|
|
90
|
+
|
|
91
|
+
expect(s.emitted, "a consumed Escape must not close the popover").toEqual([]);
|
|
92
|
+
expect(s.open.value).toBe(true);
|
|
93
|
+
expect(s.panel(), "the panel stays mounted").not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("a bare Escape still closes, is consumed, and is not stopPropagation'd", async () => {
|
|
97
|
+
const s = mountPopover();
|
|
98
|
+
await settle();
|
|
99
|
+
const panel = s.panel();
|
|
100
|
+
expect(panel, "the panel renders").not.toBeNull();
|
|
101
|
+
|
|
102
|
+
const bare = new KeyboardEvent("keydown", {
|
|
103
|
+
key: "Escape",
|
|
104
|
+
bubbles: true,
|
|
105
|
+
cancelable: true,
|
|
106
|
+
});
|
|
107
|
+
let propagatedToDocument = false;
|
|
108
|
+
const onDocumentKeydown = () => {
|
|
109
|
+
propagatedToDocument = true;
|
|
110
|
+
};
|
|
111
|
+
document.addEventListener("keydown", onDocumentKeydown);
|
|
112
|
+
try {
|
|
113
|
+
panel!.dispatchEvent(bare);
|
|
114
|
+
} finally {
|
|
115
|
+
document.removeEventListener("keydown", onDocumentKeydown);
|
|
116
|
+
}
|
|
117
|
+
await settle();
|
|
118
|
+
|
|
119
|
+
expect(s.emitted).toEqual([false]);
|
|
120
|
+
expect(s.open.value).toBe(false);
|
|
121
|
+
// The popover's own consumption, unchanged by the precedence guard.
|
|
122
|
+
expect(bare.defaultPrevented, "the popover still consumes the key it acts on").toBe(true);
|
|
123
|
+
expect(propagatedToDocument, "the popover still does not stopPropagation").toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("closeOnEscape=false never closes, bare or consumed", async () => {
|
|
127
|
+
const s = mountPopover({ closeOnEscape: false });
|
|
128
|
+
await settle();
|
|
129
|
+
const owner = s.owner();
|
|
130
|
+
const panel = s.panel();
|
|
131
|
+
expect(owner, "the slot control renders inside the panel").not.toBeNull();
|
|
132
|
+
expect(panel, "the panel renders").not.toBeNull();
|
|
133
|
+
|
|
134
|
+
// A consumed Escape changes nothing either way. This also pins the
|
|
135
|
+
// invariant that lets a future refactor hoist the defaultPrevented
|
|
136
|
+
// guard above the closeOnEscape check safely: close() must stay gated
|
|
137
|
+
// on the prop, and this case proves that gate is load-bearing.
|
|
138
|
+
owner!.dispatchEvent(
|
|
139
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
140
|
+
);
|
|
141
|
+
await settle();
|
|
142
|
+
|
|
143
|
+
const bare = new KeyboardEvent("keydown", {
|
|
144
|
+
key: "Escape",
|
|
145
|
+
bubbles: true,
|
|
146
|
+
cancelable: true,
|
|
147
|
+
});
|
|
148
|
+
panel!.dispatchEvent(bare);
|
|
149
|
+
await settle();
|
|
150
|
+
|
|
151
|
+
expect(s.emitted).toEqual([]);
|
|
152
|
+
expect(s.open.value).toBe(true);
|
|
153
|
+
expect(bare.defaultPrevented, "no close => no consumption").toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -627,6 +627,14 @@ export default defineComponent({
|
|
|
627
627
|
|
|
628
628
|
function onPanelKeydown(e: KeyboardEvent) {
|
|
629
629
|
if (e.key === "Escape" && props.closeOnEscape) {
|
|
630
|
+
// Precedence (the same class as HkModal 0.55.295 and the
|
|
631
|
+
// HkDrawer / HkImageLightbox siblings): the panel hosts the
|
|
632
|
+
// consumer's default slot, so a control inside it that already
|
|
633
|
+
// consumed Escape (preventDefault) owns the key — the popover must
|
|
634
|
+
// not close underneath it. The popover's own consumption is
|
|
635
|
+
// unchanged: it still preventDefaults when it closes, and it has
|
|
636
|
+
// never stopped propagation.
|
|
637
|
+
if (e.defaultPrevented) return;
|
|
630
638
|
e.preventDefault();
|
|
631
639
|
close();
|
|
632
640
|
}
|
|
@@ -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] });
|
|
@@ -1149,6 +1250,23 @@ describe("HkRangeRibbon a11y and i18n", () => {
|
|
|
1149
1250
|
expect(host.windowChange).not.toHaveBeenCalled();
|
|
1150
1251
|
expect(laneSegments(host.root)).toHaveLength(4);
|
|
1151
1252
|
});
|
|
1253
|
+
|
|
1254
|
+
it("does not advertise the range affordance when the window is disabled", () => {
|
|
1255
|
+
const disabled = mountRibbon({ windowEnabled: false, window: null });
|
|
1256
|
+
const disabledStrip = disabled.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1257
|
+
expect(disabledStrip.hasAttribute("title"), "no drag hint on a disabled strip").toBe(false);
|
|
1258
|
+
expect(disabledStrip.hasAttribute("aria-describedby"), "nothing to describe").toBe(false);
|
|
1259
|
+
expect(disabled.querySelector(".hk-range-ribbon-hint"), "no sr-only hint").toBeNull();
|
|
1260
|
+
|
|
1261
|
+
// The enabled ribbon still carries all three.
|
|
1262
|
+
const enabled = mountRibbon({ window: null });
|
|
1263
|
+
const enabledStrip = enabled.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
|
|
1264
|
+
expect(enabledStrip.getAttribute("title")).toContain("Drag to select a range");
|
|
1265
|
+
const describedBy = enabledStrip.getAttribute("aria-describedby");
|
|
1266
|
+
expect(describedBy, "the enabled strip describes its keyboard contract").toBeTruthy();
|
|
1267
|
+
expect(document.getElementById(describedBy!), "the hint element exists").toBeTruthy();
|
|
1268
|
+
expect(enabled.querySelector(".hk-range-ribbon-hint")).toBeTruthy();
|
|
1269
|
+
});
|
|
1152
1270
|
});
|
|
1153
1271
|
|
|
1154
1272
|
// ── Controlled / uncontrolled switch ────────────────────────────────────
|
|
@@ -1276,8 +1394,12 @@ describe("HkRangeRibbon stylesheet contract", () => {
|
|
|
1276
1394
|
);
|
|
1277
1395
|
expect(declaration(handle, "cursor")).toBe("ew-resize");
|
|
1278
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.
|
|
1279
1401
|
expect(handle, "a handle drag cannot start a text selection").toMatch(
|
|
1280
|
-
/user-select
|
|
1402
|
+
/(?:^|[;{])\s*user-select\s*:\s*none/,
|
|
1281
1403
|
);
|
|
1282
1404
|
|
|
1283
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;
|
|
@@ -578,18 +631,20 @@ export default defineComponent({
|
|
|
578
631
|
aria-orientation="horizontal"
|
|
579
632
|
tabindex={props.windowEnabled ? 0 : -1}
|
|
580
633
|
aria-label={t("hikari::ribbon.rangeAria", "Observation range")}
|
|
581
|
-
aria-describedby={hintId}
|
|
634
|
+
aria-describedby={props.windowEnabled ? hintId : undefined}
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
638
|
+
aria-valuenow={percentNumber(win ? win[0] : 0)}
|
|
639
|
+
aria-valuetext={`${percentText(win ? win[0] : 0)} – ${percentText(win ? win[1] : 1)}`}
|
|
640
|
+
title={
|
|
641
|
+
props.windowEnabled
|
|
642
|
+
? t(
|
|
643
|
+
"hikari::ribbon.dragHint",
|
|
644
|
+
"Drag to select a range; double-click, Escape or Home resets",
|
|
645
|
+
)
|
|
646
|
+
: undefined
|
|
647
|
+
}
|
|
593
648
|
onPointerdown={onStripPointerDown}
|
|
594
649
|
onDblclick={(event: MouseEvent) => {
|
|
595
650
|
// Same rule as Escape/Home: only a double-click that actually
|
|
@@ -667,9 +722,9 @@ export default defineComponent({
|
|
|
667
722
|
tabindex={0}
|
|
668
723
|
aria-label={t("hikari::ribbon.windowStart", "Window start")}
|
|
669
724
|
aria-valuemin={0}
|
|
670
|
-
aria-valuemax={
|
|
671
|
-
aria-valuenow={
|
|
672
|
-
aria-valuetext={
|
|
725
|
+
aria-valuemax={percentNumber(win[1] - minWidth.value)}
|
|
726
|
+
aria-valuenow={percentNumber(win[0])}
|
|
727
|
+
aria-valuetext={percentText(win[0])}
|
|
673
728
|
style={{ left: percent(win[0]) }}
|
|
674
729
|
onPointerdown={(event: PointerEvent) => onHandlePointerDown("start", event)}
|
|
675
730
|
onKeydown={(event: KeyboardEvent) => onHandleKeyDown("start", event)}
|
|
@@ -681,10 +736,10 @@ export default defineComponent({
|
|
|
681
736
|
aria-orientation="horizontal"
|
|
682
737
|
tabindex={0}
|
|
683
738
|
aria-label={t("hikari::ribbon.windowEnd", "Window end")}
|
|
684
|
-
aria-valuemin={
|
|
739
|
+
aria-valuemin={percentNumber(win[0] + minWidth.value)}
|
|
685
740
|
aria-valuemax={100}
|
|
686
|
-
aria-valuenow={
|
|
687
|
-
aria-valuetext={
|
|
741
|
+
aria-valuenow={percentNumber(win[1])}
|
|
742
|
+
aria-valuetext={percentText(win[1])}
|
|
688
743
|
style={{ left: percent(win[1]) }}
|
|
689
744
|
onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
|
|
690
745
|
onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
|
|
@@ -701,18 +756,20 @@ export default defineComponent({
|
|
|
701
756
|
</>
|
|
702
757
|
)}
|
|
703
758
|
</div>
|
|
704
|
-
|
|
705
|
-
{
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
759
|
+
{props.windowEnabled && (
|
|
760
|
+
<span id={hintId} class="hk-range-ribbon-hint">
|
|
761
|
+
{`${t(
|
|
762
|
+
"hikari::ribbon.moveWindow",
|
|
763
|
+
"Move window",
|
|
764
|
+
)}: ←/→ · ${t(
|
|
765
|
+
"hikari::ribbon.resizeWindow",
|
|
766
|
+
"Resize window",
|
|
767
|
+
)}: ⇧+←/→ · ${t(
|
|
768
|
+
"hikari::ribbon.dragHint",
|
|
769
|
+
"Drag to select a range; double-click, Escape or Home resets",
|
|
770
|
+
)}`}
|
|
771
|
+
</span>
|
|
772
|
+
)}
|
|
716
773
|
</div>
|
|
717
774
|
<div class="hk-range-ribbon-lanes">
|
|
718
775
|
{lanes.value.map((lane) => (
|
|
@@ -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,
|