@celestia-island/hikari 0.55.323 → 0.55.326
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/HkAdminShell.test.tsx +11 -5
- package/src/components/HkAdminTablePage.test.tsx +57 -0
- package/src/components/HkAdminTablePage.tsx +113 -46
- package/src/components/HkCardList.tsx +13 -13
- package/src/components/HkFaceShell.test.tsx +53 -2
- package/src/components/HkFaceShell.tsx +37 -13
- package/src/components/HkFilterOperatorIcon.test.tsx +61 -0
- package/src/components/HkFilterOperatorIcon.tsx +8 -2
- package/src/components/HkFilterPanel.test.tsx +97 -0
- package/src/components/HkFilterPanel.tsx +226 -115
- package/src/components/HkInfiniteTail.test.tsx +5 -3
- package/src/components/HkInfiniteTail.tsx +3 -2
- package/src/components/HkLoadMore.test.tsx +15 -3
- package/src/components/HkLoadMore.tsx +28 -5
- package/src/components/HkScrollContainer.test.ts +46 -1
- package/src/components/HkScrollContainer.tsx +18 -193
- package/src/components/HkScrollProgressDock.scss +101 -0
- package/src/components/HkScrollProgressDock.test.tsx +1069 -0
- package/src/components/HkScrollProgressDock.tsx +532 -0
- package/src/components/HkTable.sections.test.tsx +1 -1
- package/src/components/HkTable.tsx +10 -9
- package/src/i18n/locales/ar/components.json +7 -2
- package/src/i18n/locales/de/components.json +7 -2
- package/src/i18n/locales/en/components.json +7 -2
- package/src/i18n/locales/es/components.json +7 -2
- package/src/i18n/locales/fr/components.json +7 -2
- package/src/i18n/locales/ja/components.json +7 -2
- package/src/i18n/locales/ko/components.json +7 -2
- package/src/i18n/locales/pt/components.json +7 -2
- package/src/i18n/locales/ru/components.json +7 -2
- package/src/i18n/locales/zh-Hans/components.json +7 -2
- package/src/i18n/locales/zh-Hant/components.json +7 -2
- package/src/index.ts +9 -6
- package/src/components/HkScrollProgress.scss +0 -58
- package/src/components/HkScrollProgress.test.tsx +0 -669
- package/src/components/HkScrollProgress.tsx +0 -77
package/package.json
CHANGED
|
@@ -81,7 +81,14 @@ const setWidth = (w: number) => {
|
|
|
81
81
|
function paddingInner(container: HTMLElement): HTMLElement | null | undefined {
|
|
82
82
|
const viewport = container.querySelector(".hk-scroll-container-viewport")
|
|
83
83
|
?? container.querySelector(".hk-scroll-container");
|
|
84
|
-
|
|
84
|
+
// The shell's fallback scroll-progress dock (2026-11-09) wraps the
|
|
85
|
+
// content with a display:contents scope — descend through it, the
|
|
86
|
+
// padding wrapper is still the viewport's only layout box.
|
|
87
|
+
let inner = viewport?.firstElementChild as HTMLElement | null | undefined;
|
|
88
|
+
if (inner?.classList.contains("hk-scroll-progress-dock-scope")) {
|
|
89
|
+
inner = inner.firstElementChild as HTMLElement;
|
|
90
|
+
}
|
|
91
|
+
return inner;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
function mobileInnerPadding(container: HTMLElement): string {
|
|
@@ -240,10 +247,9 @@ describe("HkAdminShell", () => {
|
|
|
240
247
|
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
241
248
|
));
|
|
242
249
|
// The padded inner wrapper exists INSIDE the scroll viewport (not as
|
|
243
|
-
// padding on the container itself)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const inner = viewport?.firstElementChild as HTMLElement | null | undefined;
|
|
250
|
+
// padding on the container itself) — behind the fallback dock's
|
|
251
|
+
// display:contents scope (see paddingInner).
|
|
252
|
+
const inner = paddingInner(c);
|
|
247
253
|
expect(inner).toBeTruthy();
|
|
248
254
|
expect(inner?.style.padding).toBe("2rem");
|
|
249
255
|
});
|
|
@@ -138,4 +138,61 @@ describe("HkAdminTablePage", () => {
|
|
|
138
138
|
expect(got[0]![0]).toBe(rows[0]);
|
|
139
139
|
expect(got[0]![1]).toBe(btn);
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
it("renders the count readout in the header, preferring the declared total", () => {
|
|
143
|
+
// User direction 2026-11-08: every standard list names its size —
|
|
144
|
+
// the scaffold owns the "N in total" wording so pages stop
|
|
145
|
+
// hand-rolling their own count spans.
|
|
146
|
+
const c = mount(
|
|
147
|
+
h(HkAdminTablePage, {
|
|
148
|
+
title: "T",
|
|
149
|
+
rows: Array.from({ length: 12 }, (_, i) => ({ name: `r${i}` })),
|
|
150
|
+
columns: COLS,
|
|
151
|
+
total: 500,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
const readout = c.querySelector('[data-testid="hk-admin-table-page-count"]');
|
|
155
|
+
expect(readout).not.toBeNull();
|
|
156
|
+
expect(readout!.textContent).toBe("500 in total");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("falls back to the delivered row count when no total is declared", () => {
|
|
160
|
+
const c = mount(
|
|
161
|
+
h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }, { name: "b" }], columns: COLS }),
|
|
162
|
+
);
|
|
163
|
+
expect(c.querySelector('[data-testid="hk-admin-table-page-count"]')!.textContent).toBe("2 in total");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("hides the readout on an empty list and honors countReadout:false", () => {
|
|
167
|
+
const empty = mount(h(HkAdminTablePage, { title: "T", rows: [], columns: COLS }));
|
|
168
|
+
expect(empty.querySelector('[data-testid="hk-admin-table-page-count"]')).toBeNull();
|
|
169
|
+
const optedOut = mount(
|
|
170
|
+
h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }], columns: COLS, countReadout: false }),
|
|
171
|
+
);
|
|
172
|
+
expect(optedOut.querySelector('[data-testid="hk-admin-table-page-count"]')).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("renders the readout beside page actions, rightmost", () => {
|
|
176
|
+
const slots = { actions: () => h("button", { class: "page-action" }, "Do") };
|
|
177
|
+
const c = mount(
|
|
178
|
+
h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }], columns: COLS }, slots),
|
|
179
|
+
);
|
|
180
|
+
const header = c.querySelector(".hk-page-header")!;
|
|
181
|
+
expect(header.querySelector("button.page-action")).not.toBeNull();
|
|
182
|
+
// The count span trails the page's own actions inside the header row.
|
|
183
|
+
const actions = header.querySelector(".hk-page-header-actions") ?? header;
|
|
184
|
+
expect(actions.textContent).toContain("1 in total");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("keeps the headerless actions band for a bare readout", () => {
|
|
188
|
+
// No title: the band is the readout's only host — one slim right
|
|
189
|
+
// aligned row, never a second stacked header (安全扫描 report,
|
|
190
|
+
// 2026-11-08).
|
|
191
|
+
const c = mount(
|
|
192
|
+
h(HkAdminTablePage, { rows: [{ name: "a" }], columns: COLS }),
|
|
193
|
+
);
|
|
194
|
+
const readout = c.querySelector('[data-testid="hk-admin-table-page-count"]');
|
|
195
|
+
expect(readout).not.toBeNull();
|
|
196
|
+
expect(readout!.textContent).toBe("1 in total");
|
|
197
|
+
});
|
|
141
198
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineComponent, type Component, type PropType } from "vue";
|
|
2
2
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
3
3
|
import HkCrossfade from "./HkCrossfade";
|
|
4
|
+
import HkScrollProgressDock from "./HkScrollProgressDock";
|
|
4
5
|
import { HkPageHeader } from "./HkPageHeader";
|
|
5
6
|
import type { HkRowManageGate } from "./HkTable";
|
|
6
7
|
|
|
@@ -69,8 +70,35 @@ export const HkAdminTablePage = defineComponent({
|
|
|
69
70
|
* denominator (the server count behind a paged list — chest's
|
|
70
71
|
* usePagedTableFetch total). Forwarded to HTable, which
|
|
71
72
|
* self-registers with the enclosing shell viewport's registrar.
|
|
72
|
-
* Omitted: the pill falls back to counting the delivered DOM rows.
|
|
73
|
+
* Omitted: the pill falls back to counting the delivered DOM rows.
|
|
74
|
+
* The SAME number feeds the header's count readout below. */
|
|
73
75
|
total: { type: Number, default: undefined },
|
|
76
|
+
/** Top-right "共 N 条" count readout in the page header (user
|
|
77
|
+
* direction 2026-11-08: EVERY standard list names its size — the
|
|
78
|
+
* scaffold owns the wording so pages stop hand-rolling their own
|
|
79
|
+
* "{n} items" spans). Prefers `total` (the server count behind a
|
|
80
|
+
* paged list) and falls back to the delivered row count; hidden at
|
|
81
|
+
* zero so a fresh or empty list shows no "共 0 条" flash. Pages
|
|
82
|
+
* that render their own count elsewhere set this false. */
|
|
83
|
+
countReadout: { type: Boolean, default: true },
|
|
84
|
+
/** The page's own scroll-progress DOCK (user direction 2026-11-09:
|
|
85
|
+
* the list large-component uses the dock itself). DEFAULT ON — the
|
|
86
|
+
* page mounts an HkScrollProgressDock around the body crossfade so
|
|
87
|
+
* the table (or a body-slot roster) self-registers against it and
|
|
88
|
+
* the pill floats over the scrolling viewport; the crossfade's
|
|
89
|
+
* generation swaps happen INSIDE the dock, so the registration
|
|
90
|
+
* survives every exchange. A page under a shell fallback dock has
|
|
91
|
+
* its OWN dock stand down automatically (the nested-dock rule
|
|
92
|
+
* serves the outermost enabled dock — the fallback keeps the
|
|
93
|
+
* pill); set `false` to demote the page — lists then fall through
|
|
94
|
+
* to the nearest enabled dock above, if any. Layering: the shell
|
|
95
|
+
* master switch (HkFaceShell `scrollProgress`) > this prop > the
|
|
96
|
+
* dock's own `disabled`. Like every dock gate, the layering is
|
|
97
|
+
* RESOLVED
|
|
98
|
+
* ONCE at the dock's setup — hosts pass these statically, and a
|
|
99
|
+
* runtime flip needs a remount (the HkScrollContainer prop
|
|
100
|
+
* precedent). */
|
|
101
|
+
progressDock: { type: Boolean, default: true },
|
|
74
102
|
},
|
|
75
103
|
emits: {
|
|
76
104
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
@@ -80,21 +108,53 @@ export const HkAdminTablePage = defineComponent({
|
|
|
80
108
|
return () => {
|
|
81
109
|
const { t } = useI18n();
|
|
82
110
|
const emptyTitle = props.emptyTitle || t("hikari::tablePage.emptyTitle", "No data");
|
|
111
|
+
// The header count readout: the authoritative total when declared,
|
|
112
|
+
// else the delivered rows; hidden at zero (an empty list's empty
|
|
113
|
+
// state already says so — "共 0 条" next to it reads as noise).
|
|
114
|
+
const readoutCount = props.total ?? props.rows.length;
|
|
115
|
+
const countReadout =
|
|
116
|
+
props.countReadout && readoutCount > 0 ? (
|
|
117
|
+
<span
|
|
118
|
+
data-testid="hk-admin-table-page-count"
|
|
119
|
+
style={{
|
|
120
|
+
fontSize: "var(--text-xs, 0.75rem)",
|
|
121
|
+
color: "rgb(var(--color-muted, var(--color-text-secondary)))",
|
|
122
|
+
fontVariantNumeric: "tabular-nums",
|
|
123
|
+
whiteSpace: "nowrap",
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
{t("hikari::tablePage.totalCount", "{n} in total").replace("{n}", String(readoutCount))}
|
|
127
|
+
</span>
|
|
128
|
+
) : null;
|
|
83
129
|
// Extracted to an identifier: JSX children given as an inline object
|
|
84
130
|
// literal are not normalized to slots the way a bound one is (the
|
|
85
131
|
// chest retrySlots idiom this mirrors relies on the identifier form).
|
|
86
132
|
const emptySlots = slots["empty-action"]
|
|
87
133
|
? { action: () => slots["empty-action"]?.() }
|
|
88
134
|
: undefined;
|
|
135
|
+
// The readout rides the header's actions row RIGHTMOST — after the
|
|
136
|
+
// page's own actions — and is the band's sole content on a
|
|
137
|
+
// headerless scaffold, so a bare readout never claims a full-width
|
|
138
|
+
// row of its own (the 安全扫描 double-margin report, 2026-11-08).
|
|
139
|
+
const actionsSlot = slots.actions
|
|
140
|
+
? () => (
|
|
141
|
+
<>
|
|
142
|
+
{slots.actions?.()}
|
|
143
|
+
{countReadout}
|
|
144
|
+
</>
|
|
145
|
+
)
|
|
146
|
+
: countReadout
|
|
147
|
+
? () => countReadout
|
|
148
|
+
: undefined;
|
|
89
149
|
return (
|
|
90
150
|
<div>
|
|
91
151
|
{props.title ? (
|
|
92
152
|
<HkPageHeader title={props.title} subtitle={props.subtitle} icon={props.icon} dense={props.dense}>
|
|
93
|
-
{{ actions:
|
|
153
|
+
{{ actions: actionsSlot }}
|
|
94
154
|
</HkPageHeader>
|
|
95
|
-
) :
|
|
96
|
-
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "var(--space-16, 1rem)" }}>
|
|
97
|
-
{
|
|
155
|
+
) : actionsSlot ? (
|
|
156
|
+
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "var(--space-8, 0.5rem)", marginBottom: "var(--space-16, 1rem)" }}>
|
|
157
|
+
{actionsSlot()}
|
|
98
158
|
</div>
|
|
99
159
|
) : null}
|
|
100
160
|
{slots.filter ? <div class="hk-admin-table-page-filter">{slots.filter()}</div> : null}
|
|
@@ -110,48 +170,55 @@ export const HkAdminTablePage = defineComponent({
|
|
|
110
170
|
every host dissolves between them instead of popping. The
|
|
111
171
|
key collapses loading-vs-empty into one "placeholder"
|
|
112
172
|
generation: spinner → empty is a data arrival, not a slot
|
|
113
|
-
identity change worth animating.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
}
|
|
173
|
+
identity change worth animating.
|
|
174
|
+
The whole exchange rides INSIDE this page's scroll-progress
|
|
175
|
+
dock (default on; `progressDock:false` demotes to a bare
|
|
176
|
+
passthrough): the dock must wrap the CROSSFADE, not sit in
|
|
177
|
+
a generation — a generation-scoped dock would unmount with
|
|
178
|
+
every swap and drop the registration. */}
|
|
179
|
+
<HkScrollProgressDock disabled={!props.progressDock}>
|
|
180
|
+
<HkCrossfade
|
|
181
|
+
swapKey={
|
|
182
|
+
props.error ? "error"
|
|
183
|
+
: props.loading && !props.rows.length ? "placeholder"
|
|
184
|
+
: !props.rows.length ? "placeholder"
|
|
185
|
+
: "content"
|
|
186
|
+
}
|
|
187
|
+
>
|
|
188
|
+
{props.error ? (
|
|
189
|
+
<HAlert message={props.error} />
|
|
190
|
+
) : props.loading && !props.rows.length ? (
|
|
191
|
+
<HSpinner center />
|
|
192
|
+
) : !props.rows.length ? (
|
|
193
|
+
<HEmptyState
|
|
194
|
+
boxed
|
|
195
|
+
title={emptyTitle}
|
|
196
|
+
description={props.emptyDescription}
|
|
149
197
|
>
|
|
150
|
-
{
|
|
151
|
-
</
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
198
|
+
{emptySlots}
|
|
199
|
+
</HEmptyState>
|
|
200
|
+
) : slots.body ? (
|
|
201
|
+
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
202
|
+
) : (
|
|
203
|
+
<HCard padded={false}>
|
|
204
|
+
<HTable
|
|
205
|
+
columns={props.columns}
|
|
206
|
+
rows={props.rows}
|
|
207
|
+
rowKey={props.rowKey}
|
|
208
|
+
total={props.total}
|
|
209
|
+
draggable={props.draggable}
|
|
210
|
+
manageable={props.manageable}
|
|
211
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
212
|
+
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
213
|
+
emit("manage", row, opener)
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{slots}
|
|
217
|
+
</HTable>
|
|
218
|
+
</HCard>
|
|
219
|
+
)}
|
|
220
|
+
</HkCrossfade>
|
|
221
|
+
</HkScrollProgressDock>
|
|
155
222
|
{slots["create-modal"]?.()}
|
|
156
223
|
{slots["edit-modal"]?.()}
|
|
157
224
|
</div>
|
|
@@ -9,7 +9,7 @@ import HkTooltip from "./HkTooltip";
|
|
|
9
9
|
import { hasRenderedContent } from "../runtime/hasRenderedContent";
|
|
10
10
|
import { useMediaQuery } from "../runtime/useMediaQuery";
|
|
11
11
|
import type { StatTone } from "./HkStatCard";
|
|
12
|
-
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./
|
|
12
|
+
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgressDock";
|
|
13
13
|
import "./HkCardList.scss";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -47,22 +47,22 @@ export const HkCardList = defineComponent({
|
|
|
47
47
|
props: {
|
|
48
48
|
/** Authoritative row total the scroll-progress pill uses as its
|
|
49
49
|
* denominator (the server count behind a windowed/infinite list).
|
|
50
|
-
* Forwarded to the nearest enclosing
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* Forwarded to the nearest enclosing enabled HkScrollProgressDock,
|
|
51
|
+
* exactly like HkAdminTablePage's `total`. Omitted: the pill falls
|
|
52
|
+
* back to counting the delivered DOM rows (right for
|
|
53
|
+
* fully-delivered lists, drifting low for windowed ones — pass the
|
|
54
|
+
* server count there). */
|
|
55
55
|
total: { type: Number, default: undefined },
|
|
56
56
|
},
|
|
57
57
|
setup(props, { slots }) {
|
|
58
58
|
// Scroll-progress registration (the HkTable precedent): the NEAREST
|
|
59
|
-
// enclosing
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
59
|
+
// enclosing ENABLED HkScrollProgressDock provides the registrar —
|
|
60
|
+
// provide/inject scoping resolves the shell viewport for console
|
|
61
|
+
// pages and keeps a list registered against an inner dock out of
|
|
62
|
+
// the shell. The handle hands the pill the list element as the row
|
|
63
|
+
// root, the `total` prop live, and the rendered HkListRow elements
|
|
64
|
+
// as the rows (a card list's rows ARE its data rows — no
|
|
65
|
+
// section-band caveat).
|
|
66
66
|
const listRef = ref<HTMLElement | null>(null);
|
|
67
67
|
const progressRegistrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
68
68
|
const progressHandle: HkScrollProgressHandle = {
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { createApp, h } from "vue";
|
|
5
|
+
import { createApp, defineComponent, h, inject } from "vue";
|
|
6
6
|
|
|
7
7
|
// HDrawer teleports and animates; replace it with an inline passthrough
|
|
8
8
|
// (same stub shape as HkAdminShell.test.tsx) so the shell contract is
|
|
@@ -36,6 +36,7 @@ vi.mock("@celestia-island/hikari", async (importOriginal) => {
|
|
|
36
36
|
|
|
37
37
|
import { HkFaceShell } from "./HkFaceShell";
|
|
38
38
|
import { HkAdminShell } from "./HkAdminShell";
|
|
39
|
+
import { HK_SCROLL_PROGRESS_ALLOWED } from "./HkScrollProgressDock";
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* HkFaceShell — the generalized face shell extracted from HkAdminShell
|
|
@@ -96,6 +97,11 @@ describe("HkFaceShell stage mode", () => {
|
|
|
96
97
|
// The document host is GONE — that is the stage contract.
|
|
97
98
|
expect(c.querySelector(".hk-scroll-container")).toBeNull();
|
|
98
99
|
expect(c.querySelector(".hk-scroll-container-viewport")).toBeNull();
|
|
100
|
+
// …and so is the fallback scroll-progress dock (document-mode only —
|
|
101
|
+
// a stage face owns its own geometry, so the shell mounts no dock
|
|
102
|
+
// scope and provides no pill chrome at all).
|
|
103
|
+
expect(c.querySelector(".hk-scroll-progress-dock-scope")).toBeNull();
|
|
104
|
+
expect(c.querySelector(".hk-scroll-progress-dock")).toBeNull();
|
|
99
105
|
// No padding wrapper anywhere: the content slot's first element IS
|
|
100
106
|
// the main region's child (contentPadding is a document-mode prop).
|
|
101
107
|
expect(main!.firstElementChild?.textContent).toBe("CONTENT");
|
|
@@ -158,12 +164,57 @@ describe("HkFaceShell document mode (the default)", () => {
|
|
|
158
164
|
expect(c.querySelector(".hk-scroll-container")).toBeTruthy();
|
|
159
165
|
const viewport = c.querySelector(".hk-scroll-container-viewport")
|
|
160
166
|
?? c.querySelector(".hk-scroll-container");
|
|
161
|
-
|
|
167
|
+
// The shell's fallback scroll-progress dock wraps the content
|
|
168
|
+
// (2026-11-09): its scope is display: contents, so the padding
|
|
169
|
+
// wrapper below it is STILL the viewport's only layout box.
|
|
170
|
+
const scope = viewport!.firstElementChild as HTMLElement;
|
|
171
|
+
expect(scope.classList.contains("hk-scroll-progress-dock-scope")).toBe(true);
|
|
172
|
+
const inner = scope.firstElementChild as HTMLElement;
|
|
162
173
|
expect(inner.style.padding).toBe("2rem");
|
|
163
174
|
expect(inner.textContent).toContain("CONTENT");
|
|
164
175
|
});
|
|
165
176
|
});
|
|
166
177
|
|
|
178
|
+
describe("HkFaceShell scroll-progress dock master switch", () => {
|
|
179
|
+
const makeProbe = (box: { seen: unknown }) =>
|
|
180
|
+
defineComponent({
|
|
181
|
+
setup() {
|
|
182
|
+
box.seen = inject(HK_SCROLL_PROGRESS_ALLOWED, null);
|
|
183
|
+
return () => h("main", null, "CONTENT");
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("provides ALLOWED=true by default and mounts the fallback dock", () => {
|
|
188
|
+
const box: { seen: unknown } = { seen: "unset" };
|
|
189
|
+
const c = mount(h(HkFaceShell, {
|
|
190
|
+
navTitle: "Navigation",
|
|
191
|
+
}, {
|
|
192
|
+
header: () => null,
|
|
193
|
+
content: () => h(makeProbe(box)),
|
|
194
|
+
}));
|
|
195
|
+
// The `scrollProgress` prop (default true) is the face-level lever
|
|
196
|
+
// over every dock in the subtree.
|
|
197
|
+
expect(box.seen).toBe(true);
|
|
198
|
+
expect(c.querySelector(".hk-scroll-progress-dock-scope")).not.toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("scrollProgress:false closes the switch and stands the fallback dock down", () => {
|
|
202
|
+
const box: { seen: unknown } = { seen: "unset" };
|
|
203
|
+
const c = mount(h(HkFaceShell, {
|
|
204
|
+
navTitle: "Navigation",
|
|
205
|
+
scrollProgress: false,
|
|
206
|
+
}, {
|
|
207
|
+
header: () => null,
|
|
208
|
+
content: () => h(makeProbe(box)),
|
|
209
|
+
}));
|
|
210
|
+
expect(box.seen).toBe(false);
|
|
211
|
+
// Fully lazy: no scope, no pill anywhere in the shell.
|
|
212
|
+
expect(c.querySelector(".hk-scroll-progress-dock-scope")).toBeNull();
|
|
213
|
+
expect(c.querySelector(".hk-scroll-progress-dock")).toBeNull();
|
|
214
|
+
expect(c.querySelector(".hk-scroll-container-viewport")!.textContent).toContain("CONTENT");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
167
218
|
describe("the extraction boundary: the action-bar channel", () => {
|
|
168
219
|
it("HkFaceShell's header slot carries no actions param (no bar provided)", () => {
|
|
169
220
|
const seen: Array<Record<string, unknown>> = [];
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { computed, defineComponent, ref, watch, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, provide, ref, watch, type PropType } from "vue";
|
|
2
2
|
import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
4
|
import { useSidebarRail } from "../composables/useSidebarRail";
|
|
5
5
|
import { provideMenuGroupState } from "../composables/useMenuGroupState";
|
|
6
6
|
import HkSidebarRailSwap from "./HkSidebarRailSwap";
|
|
7
7
|
import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
|
|
8
|
+
import HkScrollProgressDock, { HK_SCROLL_PROGRESS_ALLOWED } from "./HkScrollProgressDock";
|
|
8
9
|
|
|
9
10
|
/** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
|
|
10
11
|
* padding (`inherit 0 inherit` is rejected by every engine, measured in
|
|
@@ -236,13 +237,19 @@ export const HkFaceShell = defineComponent({
|
|
|
236
237
|
* `stage` renders the content bare — the face owns scrolling,
|
|
237
238
|
* canvases and floating layers. */
|
|
238
239
|
contentMode: { type: String as PropType<"document" | "stage">, default: "document" },
|
|
239
|
-
/** Scroll-progress
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* `
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
240
|
+
/** Scroll-progress DOCK master switch (2026-11-09): the shell-level
|
|
241
|
+
* on/off lever for every HkScrollProgressDock in the face —
|
|
242
|
+
* provided to the subtree as `HK_SCROLL_PROGRESS_ALLOWED`, so a
|
|
243
|
+
* `false` here turns a page's own dock lazy too (the shell >
|
|
244
|
+
* page-level `progressDock` > the dock's own `disabled`). While on
|
|
245
|
+
* (the default), the shell ALSO mounts its own fallback dock
|
|
246
|
+
* around the document content: unmigrated pages and bare lists
|
|
247
|
+
* still float the pill with zero per-page work; a standard page's
|
|
248
|
+
* own dock nests INSIDE the fallback and is the side that stands
|
|
249
|
+
* down (the nested-dock rule serves the outermost enabled dock —
|
|
250
|
+
* one pill, never two). Lists register themselves (HkTable /
|
|
251
|
+
* HkCardList / HkAdminTablePage do it built-in). Pass `false` to
|
|
252
|
+
* run the face bare. */
|
|
246
253
|
scrollProgress: { type: Boolean, default: true },
|
|
247
254
|
/** Content-area padding (inside the scroll viewport). Document mode
|
|
248
255
|
* only — a stage face owns its own geometry. Padding is applied to
|
|
@@ -302,6 +309,12 @@ export const HkFaceShell = defineComponent({
|
|
|
302
309
|
// same one as the desktop sidebar.
|
|
303
310
|
provideMenuGroupState();
|
|
304
311
|
|
|
312
|
+
// ── scroll-progress dock master switch ───────────────────────────
|
|
313
|
+
// The `scrollProgress` prop is now the FACE-LEVEL lever over every
|
|
314
|
+
// dock in the subtree (see the prop doc): provided once, statically
|
|
315
|
+
// — hosts pass it statically, the HkScrollContainer prop precedent.
|
|
316
|
+
provide(HK_SCROLL_PROGRESS_ALLOWED, props.scrollProgress);
|
|
317
|
+
|
|
305
318
|
// ── desktop icon rail + hover peek (sidebarCollapsible) ──────────
|
|
306
319
|
// SEMI-CONTROLLED rail state: a local session-only ref is the live
|
|
307
320
|
// state, so the toggle works with NO channel bound at all (the
|
|
@@ -545,11 +558,22 @@ export const HkFaceShell = defineComponent({
|
|
|
545
558
|
{props.contentMode === "stage" ? (
|
|
546
559
|
renderContentStage()
|
|
547
560
|
) : (
|
|
548
|
-
<HScrollContainer class="s-admin-shell-scroll"
|
|
549
|
-
{/*
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
561
|
+
<HScrollContainer class="s-admin-shell-scroll">
|
|
562
|
+
{/* The shell's FALLBACK dock (2026-11-09): wraps the
|
|
563
|
+
padded content so unmigrated pages and bare lists
|
|
564
|
+
still float the pill. A page that mounts its own dock
|
|
565
|
+
(HkAdminTablePage's, by default) nests INSIDE this
|
|
566
|
+
one and is the side that stands down — the nested
|
|
567
|
+
-dock rule serves the OUTERMOST enabled dock, so the
|
|
568
|
+
fallback keeps sensing and one pill paints, never
|
|
569
|
+
two. The scope is display: contents — the padding
|
|
570
|
+
wrapper below stays the viewport's only layout box. */}
|
|
571
|
+
<HkScrollProgressDock>
|
|
572
|
+
{/* Padding lives INSIDE the scroll viewport (an inner
|
|
573
|
+
wrapper) so card box-shadows are not clipped at the
|
|
574
|
+
viewport edges. */}
|
|
575
|
+
<div style={contentStyle.value}>{renderContentStage()}</div>
|
|
576
|
+
</HkScrollProgressDock>
|
|
553
577
|
</HScrollContainer>
|
|
554
578
|
)}
|
|
555
579
|
</main>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkFilterOperatorIcon from "./HkFilterOperatorIcon";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(op: string) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => h(HkFilterOperatorIcon, { op: op as never }) });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** The custom glyphs are hikari-authored SVG in lucide's stroke grammar.
|
|
25
|
+
* Pin the exact path data: the ordered four's underlines ride y=19 — the
|
|
26
|
+
* SAME optical center as lucide's Equal / EqualNot (ink y5..19). The
|
|
27
|
+
* first cut drew them at y=20 and the ≥/≤ glyphs sat visibly low against
|
|
28
|
+
* the equals family inside the 28px operator toggles (user report
|
|
29
|
+
* 2026-11-08); this pin makes a revert to y=20 RED (mutation-verified:
|
|
30
|
+
* reverting both underlines failed this suite 2/2). */
|
|
31
|
+
describe("HkFilterOperatorIcon custom glyph geometry", () => {
|
|
32
|
+
it("draws lt/gt chevrons without an underline", () => {
|
|
33
|
+
const ds = (op: string) =>
|
|
34
|
+
[...mount(op).querySelectorAll("svg path")].map((p) => p.getAttribute("d"));
|
|
35
|
+
expect(ds("lt")).toEqual(["m19 6-11 6 11 6"]);
|
|
36
|
+
expect(ds("gt")).toEqual(["m5 6 11 6-11 6"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("draws the lte/gte underlines at y=19 (optically centered on y=12)", () => {
|
|
40
|
+
const ds = (op: string) =>
|
|
41
|
+
[...mount(op).querySelectorAll("svg path")].map((p) => p.getAttribute("d"));
|
|
42
|
+
expect(ds("lte")).toEqual(["m19 5-11 6 11 6", "M8 19h11"]);
|
|
43
|
+
expect(ds("gte")).toEqual(["m5 5 11 6-11 6", "M5 19h11"]);
|
|
44
|
+
// Belt against the exact regression: no underline path may reach
|
|
45
|
+
// y=20 — the ink band must stay y5..19 like the lucide equals pair.
|
|
46
|
+
for (const d of [...ds("lte"), ...ds("gte")]) {
|
|
47
|
+
expect(d).not.toMatch(/20/);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("composes napprox from the lucide wave pair + EqualNot slash", () => {
|
|
52
|
+
const ds = [...mount("napprox").querySelectorAll("svg path")].map((p) =>
|
|
53
|
+
p.getAttribute("d"),
|
|
54
|
+
);
|
|
55
|
+
expect(ds).toEqual([
|
|
56
|
+
"M5 15a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
|
|
57
|
+
"M5 9a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
|
|
58
|
+
"m5 19 14-14",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -24,9 +24,15 @@ const NAPPROX_PATHS = [
|
|
|
24
24
|
const CUSTOM: Partial<Record<HkFilterOperator, string[]>> = {
|
|
25
25
|
napprox: NAPPROX_PATHS,
|
|
26
26
|
lt: ["m19 6-11 6 11 6"],
|
|
27
|
-
|
|
27
|
+
// The comparison underline rides y=19, NOT y=20: the chevron spans
|
|
28
|
+
// y5..17, so a y19 line centers the ink band at y12 — the SAME optical
|
|
29
|
+
// center lucide's Equal / EqualNot (paths y5..19) draw. The old y20
|
|
30
|
+
// lines dragged the ink to y5..20 and the ≷ glyphs sat visibly low
|
|
31
|
+
// against the equals family inside the 28px operator toggles (user
|
|
32
|
+
// report 2026-11-08: ≥/≤ 没有与 = 垂直对齐).
|
|
33
|
+
lte: ["m19 5-11 6 11 6", "M8 19h11"],
|
|
28
34
|
gt: ["m5 6 11 6-11 6"],
|
|
29
|
-
gte: ["m5 5 11 6-11 6", "M5
|
|
35
|
+
gte: ["m5 5 11 6-11 6", "M5 19h11"],
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
const LUCIDE: Partial<Record<HkFilterOperator, typeof Equal>> = {
|