@celestia-island/hikari 0.55.297 → 0.55.298

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.298",
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(
@@ -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
  }
@@ -1149,6 +1149,23 @@ describe("HkRangeRibbon a11y and i18n", () => {
1149
1149
  expect(host.windowChange).not.toHaveBeenCalled();
1150
1150
  expect(laneSegments(host.root)).toHaveLength(4);
1151
1151
  });
1152
+
1153
+ it("does not advertise the range affordance when the window is disabled", () => {
1154
+ const disabled = mountRibbon({ windowEnabled: false, window: null });
1155
+ const disabledStrip = disabled.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1156
+ expect(disabledStrip.hasAttribute("title"), "no drag hint on a disabled strip").toBe(false);
1157
+ expect(disabledStrip.hasAttribute("aria-describedby"), "nothing to describe").toBe(false);
1158
+ expect(disabled.querySelector(".hk-range-ribbon-hint"), "no sr-only hint").toBeNull();
1159
+
1160
+ // The enabled ribbon still carries all three.
1161
+ const enabled = mountRibbon({ window: null });
1162
+ const enabledStrip = enabled.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1163
+ expect(enabledStrip.getAttribute("title")).toContain("Drag to select a range");
1164
+ const describedBy = enabledStrip.getAttribute("aria-describedby");
1165
+ expect(describedBy, "the enabled strip describes its keyboard contract").toBeTruthy();
1166
+ expect(document.getElementById(describedBy!), "the hint element exists").toBeTruthy();
1167
+ expect(enabled.querySelector(".hk-range-ribbon-hint")).toBeTruthy();
1168
+ });
1152
1169
  });
1153
1170
 
1154
1171
  // ── Controlled / uncontrolled switch ────────────────────────────────────
@@ -578,7 +578,7 @@ export default defineComponent({
578
578
  aria-orientation="horizontal"
579
579
  tabindex={props.windowEnabled ? 0 : -1}
580
580
  aria-label={t("hikari::ribbon.rangeAria", "Observation range")}
581
- aria-describedby={hintId}
581
+ aria-describedby={props.windowEnabled ? hintId : undefined}
582
582
  aria-disabled={props.windowEnabled ? undefined : "true"}
583
583
  aria-valuemin={0}
584
584
  aria-valuemax={100}
@@ -586,10 +586,14 @@ export default defineComponent({
586
586
  aria-valuetext={`${Math.round((win ? win[0] : 0) * 100)}% – ${Math.round(
587
587
  (win ? win[1] : 1) * 100,
588
588
  )}%`}
589
- title={t(
590
- "hikari::ribbon.dragHint",
591
- "Drag to select a range; double-click, Escape or Home resets",
592
- )}
589
+ title={
590
+ props.windowEnabled
591
+ ? t(
592
+ "hikari::ribbon.dragHint",
593
+ "Drag to select a range; double-click, Escape or Home resets",
594
+ )
595
+ : undefined
596
+ }
593
597
  onPointerdown={onStripPointerDown}
594
598
  onDblclick={(event: MouseEvent) => {
595
599
  // Same rule as Escape/Home: only a double-click that actually
@@ -701,18 +705,20 @@ export default defineComponent({
701
705
  </>
702
706
  )}
703
707
  </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>
708
+ {props.windowEnabled && (
709
+ <span id={hintId} class="hk-range-ribbon-hint">
710
+ {`${t(
711
+ "hikari::ribbon.moveWindow",
712
+ "Move window",
713
+ )}: ←/→ · ${t(
714
+ "hikari::ribbon.resizeWindow",
715
+ "Resize window",
716
+ )}: ⇧+←/→ · ${t(
717
+ "hikari::ribbon.dragHint",
718
+ "Drag to select a range; double-click, Escape or Home resets",
719
+ )}`}
720
+ </span>
721
+ )}
716
722
  </div>
717
723
  <div class="hk-range-ribbon-lanes">
718
724
  {lanes.value.map((lane) => (