@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.297",
3
+ "version": "0.55.299",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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
- if (e.key === "Escape") close();
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
- Every colour/size comes from hikari theme tokens; the only motion is the
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, { type RibbonLane, type RibbonSegment } from "./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:\s*none/,
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
- /** Normalize a host window: swap inverted ranges, clamp to the domain,
97
- * ignore non-finite or malformed input (that means "no window"), and never
98
- * return a window narrower than `minWidth`. */
99
- function normalizeWindow(input: unknown, minWidth: number): WindowRange | null {
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 = Math.min(Math.max(minWidth, 0), 1);
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
- * it renders as a tick thanks to the sheet's min-width. */
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 normalizeWindow(source, minWidth.value);
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 handle sits over the strip: never start a brush underneath it.
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, Escape must still reach the host
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
- if (resetWindow()) event.preventDefault();
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={Math.round((win ? win[0] : 0) * 100)}
586
- aria-valuetext={`${Math.round((win ? win[0] : 0) * 100)}% – ${Math.round(
587
- (win ? win[1] : 1) * 100,
588
- )}%`}
589
- title={t(
590
- "hikari::ribbon.dragHint",
591
- "Drag to select a range; double-click, Escape or Home resets",
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={Math.round((win[1] - minWidth.value) * 100)}
671
- aria-valuenow={Math.round(win[0] * 100)}
672
- aria-valuetext={`${Math.round(win[0] * 100)}%`}
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={Math.round((win[0] + minWidth.value) * 100)}
739
+ aria-valuemin={percentNumber(win[0] + minWidth.value)}
685
740
  aria-valuemax={100}
686
- aria-valuenow={Math.round(win[1] * 100)}
687
- aria-valuetext={`${Math.round(win[1] * 100)}%`}
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
- <span id={hintId} class="hk-range-ribbon-hint">
705
- {`${t(
706
- "hikari::ribbon.moveWindow",
707
- "Move window",
708
- )}: ←/→ · ${t(
709
- "hikari::ribbon.resizeWindow",
710
- "Resize window",
711
- )}: ⇧+←/→ · ${t(
712
- "hikari::ribbon.dragHint",
713
- "Drag to select a range; double-click, Escape or Home resets",
714
- )}`}
715
- </span>
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,