@celestia-island/hikari 0.55.295 → 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/HkAdminTablePage.tsx +20 -52
- 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/src/components/HkScrollProgress.test.tsx +3 -3
- package/src/components/HkTable.tsx +28 -1
package/package.json
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
defineComponent,
|
|
3
|
-
inject,
|
|
4
|
-
onMounted,
|
|
5
|
-
onUnmounted,
|
|
6
|
-
ref,
|
|
7
|
-
type Component,
|
|
8
|
-
type PropType,
|
|
9
|
-
} from "vue";
|
|
1
|
+
import { defineComponent, type Component, type PropType } from "vue";
|
|
10
2
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
11
3
|
import HkCrossfade from "./HkCrossfade";
|
|
12
4
|
import { HkPageHeader } from "./HkPageHeader";
|
|
13
|
-
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
|
|
14
5
|
import type { HkRowManageGate } from "./HkTable";
|
|
15
6
|
|
|
16
7
|
|
|
@@ -76,9 +67,9 @@ export const HkAdminTablePage = defineComponent({
|
|
|
76
67
|
},
|
|
77
68
|
/** Authoritative row total the scroll-progress pill uses as its
|
|
78
69
|
* denominator (the server count behind a paged list — chest's
|
|
79
|
-
* usePagedTableFetch total).
|
|
80
|
-
*
|
|
81
|
-
*
|
|
70
|
+
* usePagedTableFetch total). Forwarded to HTable, which
|
|
71
|
+
* self-registers with the enclosing shell viewport's registrar.
|
|
72
|
+
* Omitted: the pill falls back to counting the delivered DOM rows. */
|
|
82
73
|
total: { type: Number, default: undefined },
|
|
83
74
|
},
|
|
84
75
|
emits: {
|
|
@@ -86,28 +77,6 @@ export const HkAdminTablePage = defineComponent({
|
|
|
86
77
|
manage: (_row: Record<string, unknown>, _opener: HTMLElement) => true,
|
|
87
78
|
},
|
|
88
79
|
setup(props, { emit, slots }) {
|
|
89
|
-
// Scroll-progress registration: the nearest enclosing HkScrollContainer
|
|
90
|
-
// with `scrollProgress` on provides the registrar (provide/inject
|
|
91
|
-
// scoping resolves the shell viewport for admin pages and keeps an
|
|
92
|
-
// inner container's lists out of the shell). The handle exposes the
|
|
93
|
-
// table card as the row root plus the `total` prop live — the pill's
|
|
94
|
-
// denominator follows it as pages load.
|
|
95
|
-
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
96
|
-
const tableCardRef = ref<HTMLElement | null>(null);
|
|
97
|
-
let handle: HkScrollProgressHandle | null = null;
|
|
98
|
-
onMounted(() => {
|
|
99
|
-
if (!registrar) return;
|
|
100
|
-
handle = {
|
|
101
|
-
getRoot: () => tableCardRef.value,
|
|
102
|
-
getTotal: () => props.total,
|
|
103
|
-
};
|
|
104
|
-
registrar.register(handle);
|
|
105
|
-
});
|
|
106
|
-
onUnmounted(() => {
|
|
107
|
-
if (registrar && handle) registrar.unregister(handle);
|
|
108
|
-
handle = null;
|
|
109
|
-
});
|
|
110
|
-
|
|
111
80
|
return () => {
|
|
112
81
|
const { t } = useI18n();
|
|
113
82
|
const emptyTitle = props.emptyTitle || t("hikari::tablePage.emptyTitle", "No data");
|
|
@@ -165,23 +134,22 @@ export const HkAdminTablePage = defineComponent({
|
|
|
165
134
|
) : slots.body ? (
|
|
166
135
|
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
167
136
|
) : (
|
|
168
|
-
<
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
</div>
|
|
137
|
+
<HCard padded={false}>
|
|
138
|
+
<HTable
|
|
139
|
+
columns={props.columns}
|
|
140
|
+
rows={props.rows}
|
|
141
|
+
rowKey={props.rowKey}
|
|
142
|
+
total={props.total}
|
|
143
|
+
draggable={props.draggable}
|
|
144
|
+
manageable={props.manageable}
|
|
145
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
146
|
+
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
147
|
+
emit("manage", row, opener)
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
{slots}
|
|
151
|
+
</HTable>
|
|
152
|
+
</HCard>
|
|
185
153
|
)}
|
|
186
154
|
</HkCrossfade>
|
|
187
155
|
{slots["create-modal"]?.()}
|
|
@@ -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) => (
|
|
@@ -439,11 +439,11 @@ describe("HkAdminTablePage scroll-progress registration", () => {
|
|
|
439
439
|
rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
|
|
440
440
|
left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
|
|
441
441
|
} as DOMRect));
|
|
442
|
-
// The list root is
|
|
443
|
-
//
|
|
442
|
+
// The list root is HkTable's own host element — the table
|
|
443
|
+
// self-registers (the page only forwards the total).
|
|
444
444
|
const card = container.querySelector("table");
|
|
445
445
|
expect(card).not.toBeNull();
|
|
446
|
-
const cardHost = container.querySelector<HTMLElement>(".hk-
|
|
446
|
+
const cardHost = container.querySelector<HTMLElement>(".hk-table-host")!;
|
|
447
447
|
expect(cardHost.contains(card!)).toBe(true);
|
|
448
448
|
rects.push(vi.spyOn(cardHost, "getBoundingClientRect").mockReturnValue({
|
|
449
449
|
left: 0, top: -200, right: 300, bottom: 3800, width: 300, height: 4000, x: 0, y: -200,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, inject, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
|
|
2
2
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
4
|
import { usePointerReorder } from "../composables/usePointerReorder";
|
|
@@ -10,6 +10,7 @@ import { ancestorZoom } from "../runtime/cssZoom";
|
|
|
10
10
|
import { useClipboardWithToast } from "../runtime/useClipboard";
|
|
11
11
|
import { useToast } from "../runtime/useToast";
|
|
12
12
|
import HkRowActions from "./HkRowActions";
|
|
13
|
+
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
|
|
13
14
|
import "./HkTable.scss";
|
|
14
15
|
|
|
15
16
|
interface Column {
|
|
@@ -91,6 +92,14 @@ export default defineComponent({
|
|
|
91
92
|
default: false,
|
|
92
93
|
},
|
|
93
94
|
emptyText: { type: String, default: "" },
|
|
95
|
+
/** Authoritative row total the scroll-progress pill uses as its
|
|
96
|
+
* denominator (the server count behind a paged list). Omitted: the
|
|
97
|
+
* pill falls back to counting the delivered DOM rows. Every HkTable
|
|
98
|
+
* self-registers with the nearest enclosing HkScrollContainer that
|
|
99
|
+
* has `scrollProgress` on — no wrapper needed (2026-11-06: moved
|
|
100
|
+
* here from HkAdminTablePage so the 17 bare-HkTable views inherit
|
|
101
|
+
* the pill too). */
|
|
102
|
+
total: { type: Number, default: undefined },
|
|
94
103
|
},
|
|
95
104
|
emits: {
|
|
96
105
|
"update:selectedRows": (_rows: Record<string, unknown>[]) => true,
|
|
@@ -195,6 +204,24 @@ export default defineComponent({
|
|
|
195
204
|
wrapperScrollbar = null;
|
|
196
205
|
});
|
|
197
206
|
|
|
207
|
+
// Scroll-progress registration: the nearest enclosing HkScrollContainer
|
|
208
|
+
// with `scrollProgress` on provides the registrar (provide/inject
|
|
209
|
+
// scoping resolves the shell viewport for admin pages and keeps an
|
|
210
|
+
// inner container's lists out of the shell). The handle exposes the
|
|
211
|
+
// table host as the row root plus the `total` prop live — the pill's
|
|
212
|
+
// denominator follows it as pages load.
|
|
213
|
+
const progressRegistrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
214
|
+
const progressHandle: HkScrollProgressHandle = {
|
|
215
|
+
getRoot: () => wrapperHostRef.value ?? null,
|
|
216
|
+
getTotal: () => props.total,
|
|
217
|
+
};
|
|
218
|
+
onMounted(() => {
|
|
219
|
+
progressRegistrar?.register(progressHandle);
|
|
220
|
+
});
|
|
221
|
+
onBeforeUnmount(() => {
|
|
222
|
+
progressRegistrar?.unregister(progressHandle);
|
|
223
|
+
});
|
|
224
|
+
|
|
198
225
|
function getRowKey(row: Record<string, unknown>, index: number): string {
|
|
199
226
|
if (props.rowKey && row[props.rowKey] != null) return String(row[props.rowKey]);
|
|
200
227
|
return String(index);
|