@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 +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/HkPopover.escape.test.tsx +155 -0
- package/src/components/HkPopover.tsx +8 -0
- package/src/components/HkRangeRibbon.test.tsx +17 -0
- package/src/components/HkRangeRibbon.tsx +23 -17
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(
|
|
@@ -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={
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
705
|
-
{
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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) => (
|