@celestia-island/hikari 0.55.291 → 0.55.293
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 +54 -16
- package/src/components/HkFaceShell.tsx +9 -1
- package/src/components/HkScrollContainer.tsx +187 -0
- package/src/components/HkScrollProgress.scss +58 -0
- package/src/components/HkScrollProgress.test.tsx +502 -0
- package/src/components/HkScrollProgress.tsx +71 -0
- package/src/components/HkSolarLocationSetting.scss +22 -1
- package/src/components/HkSolarLocationSetting.styles.test.ts +77 -0
- package/src/components/HkSolarLocationSetting.test.tsx +44 -3
- package/src/components/HkSolarLocationSetting.tsx +17 -1
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
inject,
|
|
4
|
+
onMounted,
|
|
5
|
+
onUnmounted,
|
|
6
|
+
ref,
|
|
7
|
+
type Component,
|
|
8
|
+
type PropType,
|
|
9
|
+
} from "vue";
|
|
2
10
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
3
11
|
import HkCrossfade from "./HkCrossfade";
|
|
4
12
|
import { HkPageHeader } from "./HkPageHeader";
|
|
13
|
+
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
|
|
5
14
|
import type { HkRowManageGate } from "./HkTable";
|
|
6
15
|
|
|
7
16
|
|
|
@@ -65,12 +74,39 @@ export const HkAdminTablePage = defineComponent({
|
|
|
65
74
|
type: [Boolean, Function] as PropType<HkRowManageGate>,
|
|
66
75
|
default: false,
|
|
67
76
|
},
|
|
77
|
+
/** Authoritative row total the scroll-progress pill uses as its
|
|
78
|
+
* denominator (the server count behind a paged list — chest's
|
|
79
|
+
* usePagedTableFetch total). Omitted: the pill falls back to
|
|
80
|
+
* counting the delivered DOM rows. Registration into the
|
|
81
|
+
* enclosing shell viewport is automatic (see setup). */
|
|
82
|
+
total: { type: Number, default: undefined },
|
|
68
83
|
},
|
|
69
84
|
emits: {
|
|
70
85
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
71
86
|
manage: (_row: Record<string, unknown>, _opener: HTMLElement) => true,
|
|
72
87
|
},
|
|
73
88
|
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
|
+
});
|
|
74
110
|
|
|
75
111
|
return () => {
|
|
76
112
|
const { t } = useI18n();
|
|
@@ -129,21 +165,23 @@ export const HkAdminTablePage = defineComponent({
|
|
|
129
165
|
) : slots.body ? (
|
|
130
166
|
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
131
167
|
) : (
|
|
132
|
-
<
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
168
|
+
<div ref={tableCardRef} class="hk-admin-table-page-tablecard">
|
|
169
|
+
<HCard padded={false}>
|
|
170
|
+
<HTable
|
|
171
|
+
columns={props.columns}
|
|
172
|
+
rows={props.rows}
|
|
173
|
+
rowKey={props.rowKey}
|
|
174
|
+
draggable={props.draggable}
|
|
175
|
+
manageable={props.manageable}
|
|
176
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
177
|
+
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
178
|
+
emit("manage", row, opener)
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
{slots}
|
|
182
|
+
</HTable>
|
|
183
|
+
</HCard>
|
|
184
|
+
</div>
|
|
147
185
|
)}
|
|
148
186
|
</HkCrossfade>
|
|
149
187
|
{slots["create-modal"]?.()}
|
|
@@ -236,6 +236,14 @@ export const HkFaceShell = defineComponent({
|
|
|
236
236
|
* `stage` renders the content bare — the face owns scrolling,
|
|
237
237
|
* canvases and floating layers. */
|
|
238
238
|
contentMode: { type: String as PropType<"document" | "stage">, default: "document" },
|
|
239
|
+
/** Scroll-progress pill on the document viewport (2026-11): while a
|
|
240
|
+
* registered list scrolls, the shell floats an "xx / xx" counter
|
|
241
|
+
* near the viewport's bottom-center (HkScrollContainer
|
|
242
|
+
* `scrollProgress`; lists register themselves — HkAdminTablePage
|
|
243
|
+
* does it for every admin list — so hosts inherit the affordance
|
|
244
|
+
* with zero per-page work). Default ON: the shell is the
|
|
245
|
+
* system-wide default-on point. Pass `false` to run the face bare. */
|
|
246
|
+
scrollProgress: { type: Boolean, default: true },
|
|
239
247
|
/** Content-area padding (inside the scroll viewport). Document mode
|
|
240
248
|
* only — a stage face owns its own geometry. Padding is applied to
|
|
241
249
|
* an inner wrapper rather than the scroll container so card
|
|
@@ -537,7 +545,7 @@ export const HkFaceShell = defineComponent({
|
|
|
537
545
|
{props.contentMode === "stage" ? (
|
|
538
546
|
renderContentStage()
|
|
539
547
|
) : (
|
|
540
|
-
<HScrollContainer class="s-admin-shell-scroll">
|
|
548
|
+
<HScrollContainer class="s-admin-shell-scroll" scrollProgress={props.scrollProgress}>
|
|
541
549
|
{/* Padding lives INSIDE the scroll viewport (an inner
|
|
542
550
|
wrapper) so card box-shadows are not clipped at the
|
|
543
551
|
viewport edges. */}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
defineComponent,
|
|
4
4
|
onBeforeUnmount,
|
|
5
5
|
onMounted,
|
|
6
|
+
provide,
|
|
6
7
|
ref,
|
|
7
8
|
shallowRef,
|
|
8
9
|
watch,
|
|
@@ -16,6 +17,12 @@ import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrol
|
|
|
16
17
|
import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
|
|
17
18
|
import { provideScrollWindow } from "../composables/useScrollWindow";
|
|
18
19
|
import { SCROLL_HOST_CLASS } from "./HkScrollPin";
|
|
20
|
+
import {
|
|
21
|
+
HK_SCROLL_PROGRESS_REGISTRAR,
|
|
22
|
+
default as HkScrollProgress,
|
|
23
|
+
type HkScrollProgressHandle,
|
|
24
|
+
type HkScrollProgressRegistrar,
|
|
25
|
+
} from "./HkScrollProgress";
|
|
19
26
|
import { scheduleFrame, notifyScrollStart, onceFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
20
27
|
import HFab from "./HkFab";
|
|
21
28
|
|
|
@@ -193,6 +200,40 @@ export default defineComponent({
|
|
|
193
200
|
type: String,
|
|
194
201
|
default: '[aria-current]:not([aria-current="false"]), [aria-selected="true"], .is-active',
|
|
195
202
|
},
|
|
203
|
+
/** Scroll-progress pill opt-in (2026-11): while on, the container
|
|
204
|
+
* floats a small "xx / xx" counter near its bottom-center while the
|
|
205
|
+
* scroll offset is changing — first visible row index over total
|
|
206
|
+
* rows of whichever registered list is under the viewport. Lists
|
|
207
|
+
* opt in per-list by registering against the provided registrar
|
|
208
|
+
* (see `HK_SCROLL_PROGRESS_REGISTRAR`); provide/inject scoping
|
|
209
|
+
* keeps a list registered against an inner container from leaking
|
|
210
|
+
* to this one. Sensing rides the same rAF-coalesced update pass as
|
|
211
|
+
* the overflow mirror — no second scroll listener. Off by default
|
|
212
|
+
* (per-house additive-chrome discipline; HkFaceShell turns it on
|
|
213
|
+
* for the document-mode shell).
|
|
214
|
+
*
|
|
215
|
+
* Known edges (both accepted, documented for the next reader):
|
|
216
|
+
* the registrar is provided at SETUP time only — a container
|
|
217
|
+
* mounted OFF that flips the prop on later renders the pill with
|
|
218
|
+
* nothing registered (hosts pass the prop statically; FaceShell
|
|
219
|
+
* never toggles it). And sensing keys on THIS container's viewport
|
|
220
|
+
* scroll — a nested INNER scroller moving on its own triggers no
|
|
221
|
+
* pass here, so rows clipped inside the inner scroller can be
|
|
222
|
+
* counted by the outer pill while invisible. The admin grammar
|
|
223
|
+
* (one shell viewport) never nests. */
|
|
224
|
+
scrollProgress: { type: Boolean, default: false },
|
|
225
|
+
/** Idle window (ms) after the last scroll-offset change before the
|
|
226
|
+
* pill fades out. Read live; clamped to 300..3000. Only meaningful
|
|
227
|
+
* with `scrollProgress`. */
|
|
228
|
+
scrollProgressIdle: { type: Number, default: 900 },
|
|
229
|
+
/** Which descendants count as "rows" for the numerator/denominator.
|
|
230
|
+
* The default matches the table grammar every admin list ships —
|
|
231
|
+
* BODY rows only: a `tr` default would count the header row as row
|
|
232
|
+
* one and skew every index. A non-table list (a card roster)
|
|
233
|
+
* overrides with its own row spelling. Only read while
|
|
234
|
+
* `scrollProgress` is on; a runtime change re-senses on the next
|
|
235
|
+
* pass. */
|
|
236
|
+
scrollProgressSelector: { type: String, default: "tbody tr" },
|
|
196
237
|
},
|
|
197
238
|
emits: { approachEnd: () => true, "update:backTopVisible": (_v: boolean) => true },
|
|
198
239
|
setup(props, { slots, expose, emit }) {
|
|
@@ -275,6 +316,142 @@ export default defineComponent({
|
|
|
275
316
|
provideScrollWindow(viewportRef, props.overscanScreens);
|
|
276
317
|
}
|
|
277
318
|
|
|
319
|
+
// ── Scroll progress pill (scrollProgress opt-in) ──────────────────
|
|
320
|
+
// Registration store + pill state. The registrar is PROVIDED here —
|
|
321
|
+
// once, at setup, only when the prop is on (the windowed-mode
|
|
322
|
+
// precedent above) — so provide/inject scoping resolves a list's
|
|
323
|
+
// inject to the NEAREST enclosing container with the feature on: a
|
|
324
|
+
// list registered against an inner container never leaks to an
|
|
325
|
+
// outer one, and with the prop off there is no registrar at all.
|
|
326
|
+
const progressRegistrars = new Set<HkScrollProgressHandle>();
|
|
327
|
+
const progressShown = ref(false);
|
|
328
|
+
const progressActive = ref(false);
|
|
329
|
+
const progressNumerator = ref(0);
|
|
330
|
+
const progressDenominator = ref(0);
|
|
331
|
+
let progressIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
332
|
+
// The offset the last sensing pass saw; null = not baselined yet, so
|
|
333
|
+
// the mount-time pass never counts as "scrolling" (the pill appears
|
|
334
|
+
// on the first real offset change, not on mount).
|
|
335
|
+
let progressLastTop: number | null = null;
|
|
336
|
+
|
|
337
|
+
function clampProgressIdle(): number {
|
|
338
|
+
return Math.min(3000, Math.max(300, props.scrollProgressIdle));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function disarmProgressIdle() {
|
|
342
|
+
if (progressIdleTimer !== null) {
|
|
343
|
+
clearTimeout(progressIdleTimer);
|
|
344
|
+
progressIdleTimer = null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function armProgressIdle() {
|
|
349
|
+
disarmProgressIdle();
|
|
350
|
+
progressIdleTimer = setTimeout(() => {
|
|
351
|
+
progressIdleTimer = null;
|
|
352
|
+
progressActive.value = false;
|
|
353
|
+
}, clampProgressIdle());
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function registerProgress(handle: HkScrollProgressHandle) {
|
|
357
|
+
progressRegistrars.add(handle);
|
|
358
|
+
scheduleUpdate();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function unregisterProgress(handle: HkScrollProgressHandle) {
|
|
362
|
+
progressRegistrars.delete(handle);
|
|
363
|
+
scheduleUpdate();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (props.scrollProgress) {
|
|
367
|
+
provide(HK_SCROLL_PROGRESS_REGISTRAR, {
|
|
368
|
+
register: registerProgress,
|
|
369
|
+
unregister: unregisterProgress,
|
|
370
|
+
} satisfies HkScrollProgressRegistrar);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Drop every pill precondition at once. `progressLastTop` resets so
|
|
374
|
+
* the next appearance re-baselines — a pass that only re-armed the
|
|
375
|
+
* preconditions (content grew back) must not read as a scroll. */
|
|
376
|
+
function setProgressHidden() {
|
|
377
|
+
progressShown.value = false;
|
|
378
|
+
progressActive.value = false;
|
|
379
|
+
disarmProgressIdle();
|
|
380
|
+
progressLastTop = null;
|
|
381
|
+
progressNumerator.value = 0;
|
|
382
|
+
progressDenominator.value = 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The ACTIVE registration: the first registered list whose root
|
|
386
|
+
* intersects the container viewport (registration order — the Set
|
|
387
|
+
* preserves insertion); when none intersects, the one whose root
|
|
388
|
+
* top is nearest ABOVE the viewport top (the list the user just
|
|
389
|
+
* scrolled out of). Deterministic in both tiers. */
|
|
390
|
+
function pickActiveProgressRoot(band: DOMRect): { handle: HkScrollProgressHandle; root: HTMLElement } | null {
|
|
391
|
+
let firstIntersecting: { handle: HkScrollProgressHandle; root: HTMLElement } | null = null;
|
|
392
|
+
let nearestAbove: { handle: HkScrollProgressHandle; root: HTMLElement; top: number } | null = null;
|
|
393
|
+
for (const handle of progressRegistrars) {
|
|
394
|
+
const root = handle.getRoot();
|
|
395
|
+
if (!root || !root.isConnected) continue;
|
|
396
|
+
const rect = root.getBoundingClientRect();
|
|
397
|
+
if (rect.bottom > band.top && rect.top < band.bottom) {
|
|
398
|
+
if (!firstIntersecting) firstIntersecting = { handle, root };
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (rect.top <= band.top && (!nearestAbove || rect.top > nearestAbove.top)) {
|
|
402
|
+
nearestAbove = { handle, root, top: rect.top };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return firstIntersecting ?? (nearestAbove ? { handle: nearestAbove.handle, root: nearestAbove.root } : null);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Pill sensing, riding the same throttled pass as the overflow
|
|
409
|
+
* mirror (scroll / resize / content resize / refresh — no second
|
|
410
|
+
* listener). Preconditions: a registration, vertical overflow, and
|
|
411
|
+
* an active list with at least one row; failing any hides the pill
|
|
412
|
+
* outright. While they hold, the numerator is the first row whose
|
|
413
|
+
* bottom edge is still below the viewport top (1-based), and the
|
|
414
|
+
* denominator is the registered total — unless it is missing,
|
|
415
|
+
* non-finite, sub-1, or STALE (smaller than the live row count, a
|
|
416
|
+
* page that loaded more rows than it declared), in which case the
|
|
417
|
+
* DOM count wins so numerator can never exceed denominator. */
|
|
418
|
+
function senseScrollProgress(vp: HTMLElement) {
|
|
419
|
+
if (!props.scrollProgress || progressRegistrars.size === 0) {
|
|
420
|
+
setProgressHidden();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const band = vp.getBoundingClientRect();
|
|
424
|
+
const active = pickActiveProgressRoot(band);
|
|
425
|
+
const rows = active ? active.root.querySelectorAll(props.scrollProgressSelector) : [];
|
|
426
|
+
if (!active || rows.length === 0 || vp.scrollHeight <= vp.clientHeight + 1) {
|
|
427
|
+
setProgressHidden();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
let numerator = rows.length;
|
|
431
|
+
for (let i = 0; i < rows.length; i++) {
|
|
432
|
+
if ((rows[i] as HTMLElement).getBoundingClientRect().bottom > band.top) {
|
|
433
|
+
numerator = i + 1;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const declared = active.handle.getTotal();
|
|
438
|
+
const denominator =
|
|
439
|
+
typeof declared === "number" && Number.isFinite(declared) && declared >= 1
|
|
440
|
+
? Math.max(declared, rows.length)
|
|
441
|
+
: rows.length;
|
|
442
|
+
progressNumerator.value = Math.min(numerator, denominator);
|
|
443
|
+
progressDenominator.value = denominator;
|
|
444
|
+
progressShown.value = true;
|
|
445
|
+
// Scroll-active: only a changed offset counts (the pass also runs
|
|
446
|
+
// for resizes and content mutations). The idle fade re-arms on
|
|
447
|
+
// every moving pass, so continuous scrolling never snaps off.
|
|
448
|
+
if (progressLastTop !== null && vp.scrollTop !== progressLastTop) {
|
|
449
|
+
progressActive.value = true;
|
|
450
|
+
armProgressIdle();
|
|
451
|
+
}
|
|
452
|
+
progressLastTop = vp.scrollTop;
|
|
453
|
+
}
|
|
454
|
+
|
|
278
455
|
const hasV = () => props.axis !== "horizontal";
|
|
279
456
|
const hasH = () => props.axis !== "vertical";
|
|
280
457
|
// Safe centering applies to horizontal-axis containers only; the
|
|
@@ -414,6 +591,7 @@ export default defineComponent({
|
|
|
414
591
|
senseOverflow(vp);
|
|
415
592
|
senseBackTop(vp);
|
|
416
593
|
senseSelectionBeyond(vp);
|
|
594
|
+
senseScrollProgress(vp);
|
|
417
595
|
}
|
|
418
596
|
|
|
419
597
|
/** Back-to-top hysteresis, derived on the same throttled pass as the
|
|
@@ -622,6 +800,7 @@ export default defineComponent({
|
|
|
622
800
|
overlay?.detach();
|
|
623
801
|
overlay = null;
|
|
624
802
|
stopApproach();
|
|
803
|
+
disarmProgressIdle();
|
|
625
804
|
selectionMO?.disconnect();
|
|
626
805
|
selectionMO = null;
|
|
627
806
|
scheduled?.disconnect();
|
|
@@ -774,6 +953,14 @@ export default defineComponent({
|
|
|
774
953
|
{showAutoTag.value && (
|
|
775
954
|
<span class="hk-scroll-container-autotag" aria-hidden="true">{t("hikari::scrollContainer.auto", "Auto")}</span>
|
|
776
955
|
)}
|
|
956
|
+
{props.scrollProgress && (
|
|
957
|
+
<HkScrollProgress
|
|
958
|
+
shown={progressShown.value}
|
|
959
|
+
active={progressActive.value}
|
|
960
|
+
numerator={progressNumerator.value}
|
|
961
|
+
denominator={progressDenominator.value}
|
|
962
|
+
/>
|
|
963
|
+
)}
|
|
777
964
|
{props.autoFollow && props.followAffordance && !pinned.value && (
|
|
778
965
|
<HFab
|
|
779
966
|
class="hk-scroll-container-jump"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
3
|
+
/* ── Scroll-progress pill (HkScrollContainer scrollProgress) ──────
|
|
4
|
+
The "xx / xx" counter that floats near the bottom-center of a
|
|
5
|
+
scrolling list: first visible row index over total rows. It hangs
|
|
6
|
+
off the container HOST (position: relative), never the viewport —
|
|
7
|
+
viewport-absolute chrome scrolls away with the content, the same
|
|
8
|
+
reason the autotag band and the selection-hint gradients paint on
|
|
9
|
+
the root.
|
|
10
|
+
|
|
11
|
+
Visual vocabulary is the autotag band's (the container's other
|
|
12
|
+
bottom chrome): muted mono digits on the mixed surface pill, same
|
|
13
|
+
z-index tier (6). The bottom inset is safe-area aware the way
|
|
14
|
+
HkFab's corners are. Bare digits only — counters are locale-neutral
|
|
15
|
+
(the HkLoadMore counter precedent). */
|
|
16
|
+
.hk-scroll-progress-pill {
|
|
17
|
+
position: absolute;
|
|
18
|
+
left: 50%;
|
|
19
|
+
bottom: max(var(--space-10), env(safe-area-inset-bottom, 0px));
|
|
20
|
+
transform: translateX(-50%);
|
|
21
|
+
z-index: 6;
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
@include user-select(none);
|
|
24
|
+
padding: var(--space-2) var(--space-8);
|
|
25
|
+
font-family: var(--font-mono);
|
|
26
|
+
font-size: var(--text-2xs);
|
|
27
|
+
font-variant-numeric: tabular-nums;
|
|
28
|
+
letter-spacing: 0.04em;
|
|
29
|
+
color: var(--hi-color-muted, #6c6c6c);
|
|
30
|
+
background: color-mix(in srgb, var(--hi-color-surface, rgba(255, 255, 255, 0.95)) 78%, transparent);
|
|
31
|
+
border: 1px solid color-mix(in srgb, var(--hi-color-border, rgba(100, 100, 100, 0.08)) 12%, transparent);
|
|
32
|
+
border-radius: var(--radius-full, 9999px);
|
|
33
|
+
@include backdrop-filter(blur(4px));
|
|
34
|
+
|
|
35
|
+
/* Resting = transparent; only an in-flight scroll lights it. The
|
|
36
|
+
* ~200ms ease is the show AND hide fade (data-state flips on the
|
|
37
|
+
* container's throttled sensing pass). */
|
|
38
|
+
opacity: 0;
|
|
39
|
+
transition: opacity 200ms ease;
|
|
40
|
+
|
|
41
|
+
&[data-state="active"] {
|
|
42
|
+
opacity: 0.85;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* A failed precondition (no registered list, no vertical overflow,
|
|
46
|
+
* a list whose rows are empty) removes the box outright — the pill
|
|
47
|
+
* must never linger as dead chrome. */
|
|
48
|
+
&[data-state="hidden"] {
|
|
49
|
+
display: none;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Reduced motion: no fade at all — the state flips instantly. */
|
|
54
|
+
@media (prefers-reduced-motion: reduce) {
|
|
55
|
+
.hk-scroll-progress-pill {
|
|
56
|
+
transition: none;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Scroll-progress pill: registration contract, sensing behavior, and the
|
|
2
|
+
// HkAdminTablePage auto-registration. Geometry is stubbed the same way
|
|
3
|
+
// the overlay-scrollbar tests do it (happy-dom performs no layout) —
|
|
4
|
+
// Object.defineProperties for the viewport's scroll numbers, spyOn for
|
|
5
|
+
// the getBoundingClientRect boxes the numerator reads.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { createApp, defineComponent, h, inject, nextTick, onMounted, onUnmounted, provide, ref } from "vue";
|
|
11
|
+
|
|
12
|
+
import HkScrollContainer from "./HkScrollContainer";
|
|
13
|
+
import { HkAdminTablePage } from "./HkAdminTablePage";
|
|
14
|
+
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressRegistrar } from "./HkScrollProgress";
|
|
15
|
+
|
|
16
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
17
|
+
const rects: Array<ReturnType<typeof vi.spyOn>> = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
22
|
+
app.unmount();
|
|
23
|
+
container.remove();
|
|
24
|
+
}
|
|
25
|
+
for (const spy of rects.splice(0)) spy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function mountTree(root: ReturnType<typeof h>) {
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
const app = createApp({ render: () => root });
|
|
32
|
+
app.mount(container);
|
|
33
|
+
mounts.push({ app, container });
|
|
34
|
+
return container;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Scroller {
|
|
38
|
+
container: HTMLElement;
|
|
39
|
+
viewport: HTMLElement;
|
|
40
|
+
pill: () => HTMLElement | null;
|
|
41
|
+
refresh: () => void;
|
|
42
|
+
setScrollTop: (top: number) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Mount a HkScrollContainer with `scrollProgress` on and the given slot
|
|
46
|
+
* content. scrollTop rides a mutable cell so successive refresh() passes
|
|
47
|
+
* can move it (the sensing pass reads the live getter). */
|
|
48
|
+
function mountScroller(slot: () => ReturnType<typeof h>, props: Record<string, unknown> = {}): Scroller {
|
|
49
|
+
let scrollTop = 0;
|
|
50
|
+
const instance = ref<{ refresh: () => void } | null>(null);
|
|
51
|
+
const Wrapper = defineComponent({
|
|
52
|
+
setup() {
|
|
53
|
+
return () =>
|
|
54
|
+
h(HkScrollContainer, {
|
|
55
|
+
ref: instance as never,
|
|
56
|
+
scrollProgress: true,
|
|
57
|
+
...props,
|
|
58
|
+
}, { default: slot });
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const container = mountTree(h(Wrapper));
|
|
62
|
+
const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
|
|
63
|
+
if (!viewport) throw new Error("container did not render its viewport");
|
|
64
|
+
Object.defineProperties(viewport, {
|
|
65
|
+
scrollHeight: { configurable: true, get: () => 2000 },
|
|
66
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
67
|
+
scrollWidth: { configurable: true, get: () => 300 },
|
|
68
|
+
clientWidth: { configurable: true, get: () => 300 },
|
|
69
|
+
scrollLeft: { configurable: true, get: () => 0 },
|
|
70
|
+
scrollTop: { configurable: true, get: () => scrollTop },
|
|
71
|
+
});
|
|
72
|
+
rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
|
|
73
|
+
left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
|
|
74
|
+
} as DOMRect));
|
|
75
|
+
return {
|
|
76
|
+
container,
|
|
77
|
+
viewport,
|
|
78
|
+
pill: () => container.querySelector<HTMLElement>(".hk-scroll-progress-pill"),
|
|
79
|
+
refresh: () => instance.value?.refresh(),
|
|
80
|
+
setScrollTop: (top: number) => {
|
|
81
|
+
scrollTop = top;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A minimal registered list: `rows` tr elements inside a table (the
|
|
87
|
+
* default `tr` row grammar), the registrar contacted on mounted. */
|
|
88
|
+
function makeList(options: { rows: number; total?: number }) {
|
|
89
|
+
return defineComponent({
|
|
90
|
+
setup() {
|
|
91
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
92
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
93
|
+
onMounted(() => {
|
|
94
|
+
registrar?.register({ getRoot: () => rootRef.value, getTotal: () => options.total });
|
|
95
|
+
});
|
|
96
|
+
return () =>
|
|
97
|
+
h("div", { ref: rootRef }, [
|
|
98
|
+
h("table", { class: "probe-table" }, [
|
|
99
|
+
h("tbody", {}, Array.from({ length: options.rows }, (_, i) =>
|
|
100
|
+
h("tr", { "data-row": String(i), key: i }, `row ${i}`),
|
|
101
|
+
)),
|
|
102
|
+
]),
|
|
103
|
+
]);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Stub every row's rect: 40px tall, stacked from the content top, so a
|
|
109
|
+
* given scrollTop produces a deterministic first-visible row. */
|
|
110
|
+
function stubRows(container: HTMLElement, rows: number, scrollTop = 0) {
|
|
111
|
+
container.querySelectorAll<HTMLElement>("tr").forEach((row, i) => {
|
|
112
|
+
const top = i * 40 - scrollTop;
|
|
113
|
+
rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
|
|
114
|
+
left: 0, top, right: 100, bottom: top + 40, width: 100, height: 40, x: 0, y: top,
|
|
115
|
+
} as DOMRect));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stubRootRect(container: HTMLElement, selector: string, top: number, bottom: number, wrapper = false) {
|
|
120
|
+
// `wrapper`: stub the registered ROOT (the list's wrapper div) instead
|
|
121
|
+
// of the table inside it — pickActiveProgressRoot reads the root.
|
|
122
|
+
const anchor = container.querySelector<HTMLElement>(selector);
|
|
123
|
+
if (!anchor) throw new Error("list root missing");
|
|
124
|
+
const root = wrapper ? anchor.parentElement! : anchor;
|
|
125
|
+
rects.push(vi.spyOn(root, "getBoundingClientRect").mockReturnValue({
|
|
126
|
+
left: 0, top, right: 100, bottom, width: 100, height: bottom - top, x: 0, y: top,
|
|
127
|
+
} as DOMRect));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ROWS = 40;
|
|
131
|
+
|
|
132
|
+
describe("HkScrollProgress", () => {
|
|
133
|
+
it("renders no pill element when scrollProgress is off", async () => {
|
|
134
|
+
const scroller = mountScroller(() => h(makeList({ rows: ROWS, total: 500 })), { scrollProgress: false });
|
|
135
|
+
scroller.refresh();
|
|
136
|
+
await nextTick();
|
|
137
|
+
expect(scroller.pill()).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("answers hidden while nothing is registered", async () => {
|
|
141
|
+
const scroller = mountScroller(() => h("div", { class: "bare-content" }, "content"));
|
|
142
|
+
scroller.refresh();
|
|
143
|
+
await nextTick();
|
|
144
|
+
const pill = scroller.pill();
|
|
145
|
+
expect(pill).not.toBeNull();
|
|
146
|
+
expect(pill!.dataset.state).toBe("hidden");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("provides NO registrar when scrollProgress is off", () => {
|
|
150
|
+
// The provide is guarded by the prop — a list inside an OFF container
|
|
151
|
+
// must resolve null (so it falls through to an OUTER on-container, or
|
|
152
|
+
// registers nowhere). Pins the guard: deleting the `if (props.scrollProgress)`
|
|
153
|
+
// around the provide turns this red.
|
|
154
|
+
let saw: unknown = "unset";
|
|
155
|
+
const Probe = defineComponent({
|
|
156
|
+
setup() {
|
|
157
|
+
saw = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
158
|
+
return () => h("div", "probe");
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
mountScroller(() => h(Probe), { scrollProgress: false });
|
|
162
|
+
expect(saw).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("resolves inner-OFF inside outer-ON to the OUTER registrar identity", async () => {
|
|
166
|
+
const seen: unknown[] = [];
|
|
167
|
+
const Probe = defineComponent({
|
|
168
|
+
setup() {
|
|
169
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
170
|
+
onMounted(() => {
|
|
171
|
+
registrar?.register({ getRoot: () => null, getTotal: () => 7 });
|
|
172
|
+
seen.push(registrar);
|
|
173
|
+
});
|
|
174
|
+
return () => h("div", "probe");
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
// Three probes: (a) directly under the outer ON container, (b) inside
|
|
178
|
+
// an inner OFF container, (c) inside an inner ON container. Registrar
|
|
179
|
+
// IDENTITY pins the scoping matrix: (a)==(b) — the OFF inner provides
|
|
180
|
+
// nothing, so (b) falls through to the OUTER registrar — while (c)
|
|
181
|
+
// receives the INNER container's own registrar (shadowing). Deleting
|
|
182
|
+
// the `if (props.scrollProgress)` provide guard turns (b) into a
|
|
183
|
+
// third distinct registrar and this test red.
|
|
184
|
+
const scroller = mountScroller(() =>
|
|
185
|
+
h("div", [
|
|
186
|
+
h(Probe),
|
|
187
|
+
h(HkScrollContainer, { scrollProgress: false }, { default: () => h(Probe) }),
|
|
188
|
+
h(HkScrollContainer, { scrollProgress: true }, { default: () => h(Probe) }),
|
|
189
|
+
]),
|
|
190
|
+
);
|
|
191
|
+
scroller.refresh();
|
|
192
|
+
await nextTick();
|
|
193
|
+
expect(seen.length).toBe(3);
|
|
194
|
+
expect(seen[0]).not.toBeNull();
|
|
195
|
+
expect(seen[1]).toBe(seen[0]);
|
|
196
|
+
expect(seen[2]).not.toBe(seen[0]);
|
|
197
|
+
// Two pills exist (outer ON + inner ON; the OFF inner renders none).
|
|
198
|
+
expect(scroller.container.querySelectorAll(".hk-scroll-progress-pill").length).toBe(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("shows the active count over the registered total on scroll", async () => {
|
|
202
|
+
const List = makeList({ rows: ROWS, total: 340 });
|
|
203
|
+
const scroller = mountScroller(() => h(List));
|
|
204
|
+
// Baseline pass: preconditions hold, no offset change yet.
|
|
205
|
+
stubRows(scroller.container, ROWS, 0);
|
|
206
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
207
|
+
scroller.refresh();
|
|
208
|
+
await nextTick();
|
|
209
|
+
expect(scroller.pill()!.dataset.state).toBe("idle");
|
|
210
|
+
|
|
211
|
+
// A real offset change lights the pill: row 9's bottom edge lands at
|
|
212
|
+
// the viewport top, so the first VISIBLE row is #11; total 340.
|
|
213
|
+
scroller.setScrollTop(400);
|
|
214
|
+
stubRows(scroller.container, ROWS, 400);
|
|
215
|
+
scroller.refresh();
|
|
216
|
+
await nextTick();
|
|
217
|
+
const pill = scroller.pill()!;
|
|
218
|
+
expect(pill.dataset.state).toBe("active");
|
|
219
|
+
expect(pill.textContent).toBe("11 / 340");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("falls back to the DOM row count when the total is absent", async () => {
|
|
223
|
+
const List = makeList({ rows: ROWS });
|
|
224
|
+
const scroller = mountScroller(() => h(List));
|
|
225
|
+
scroller.setScrollTop(400);
|
|
226
|
+
stubRows(scroller.container, ROWS, 400);
|
|
227
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
228
|
+
scroller.refresh();
|
|
229
|
+
await nextTick();
|
|
230
|
+
expect(scroller.pill()!.textContent).toBe("11 / 40");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("falls back to the DOM row count when the declared total went stale", async () => {
|
|
234
|
+
// A total smaller than the delivered rows (a page loaded more than it
|
|
235
|
+
// declared) must never let the numerator exceed the denominator.
|
|
236
|
+
const List = makeList({ rows: ROWS, total: 5 });
|
|
237
|
+
const scroller = mountScroller(() => h(List));
|
|
238
|
+
scroller.setScrollTop(400);
|
|
239
|
+
stubRows(scroller.container, ROWS, 400);
|
|
240
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
241
|
+
scroller.refresh();
|
|
242
|
+
await nextTick();
|
|
243
|
+
expect(scroller.pill()!.textContent).toBe("11 / 40");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("answers hidden when the content fits without overflow", async () => {
|
|
247
|
+
const List = makeList({ rows: ROWS, total: 340 });
|
|
248
|
+
const scroller = mountScroller(() => h(List));
|
|
249
|
+
Object.defineProperties(scroller.viewport, {
|
|
250
|
+
scrollHeight: { configurable: true, get: () => 400 },
|
|
251
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
252
|
+
});
|
|
253
|
+
stubRows(scroller.container, ROWS, 0);
|
|
254
|
+
stubRootRect(scroller.container, ".probe-table", 0, 400);
|
|
255
|
+
scroller.refresh();
|
|
256
|
+
await nextTick();
|
|
257
|
+
expect(scroller.pill()!.dataset.state).toBe("hidden");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("fades to idle after the idle window and re-arms on the next move", async () => {
|
|
261
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
262
|
+
const List = makeList({ rows: ROWS, total: 340 });
|
|
263
|
+
const scroller = mountScroller(() => h(List));
|
|
264
|
+
stubRows(scroller.container, ROWS, 0);
|
|
265
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
266
|
+
scroller.refresh();
|
|
267
|
+
await nextTick();
|
|
268
|
+
scroller.setScrollTop(400);
|
|
269
|
+
stubRows(scroller.container, ROWS, 400);
|
|
270
|
+
scroller.refresh();
|
|
271
|
+
await nextTick();
|
|
272
|
+
expect(scroller.pill()!.dataset.state).toBe("active");
|
|
273
|
+
// Idle window (default 900ms): opacity fades via data-state="idle".
|
|
274
|
+
vi.advanceTimersByTime(900);
|
|
275
|
+
await nextTick();
|
|
276
|
+
expect(scroller.pill()!.dataset.state).toBe("idle");
|
|
277
|
+
// The next offset change re-arms the active state.
|
|
278
|
+
scroller.setScrollTop(500);
|
|
279
|
+
stubRows(scroller.container, ROWS, 500);
|
|
280
|
+
scroller.refresh();
|
|
281
|
+
await nextTick();
|
|
282
|
+
expect(scroller.pill()!.dataset.state).toBe("active");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("tracks the intersecting list among several registrations", async () => {
|
|
286
|
+
// List A sits fully above the viewport; list B intersects. The pill
|
|
287
|
+
// describes B; once B unregisters, the nearest-above tier takes A.
|
|
288
|
+
const bHandle: { fn?: () => void } = {};
|
|
289
|
+
const listA = defineComponent({
|
|
290
|
+
setup() {
|
|
291
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
292
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
293
|
+
onMounted(() => registrar?.register({ getRoot: () => rootRef.value, getTotal: () => 10 }));
|
|
294
|
+
return () =>
|
|
295
|
+
h("div", { ref: rootRef }, [
|
|
296
|
+
h("table", { class: "probe-table-a" }, [h("tbody", {}, Array.from({ length: 10 }, (_, i) => h("tr", { "data-list": "a", key: i }, `a${i}`)))]),
|
|
297
|
+
]);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const listB = defineComponent({
|
|
301
|
+
setup() {
|
|
302
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
303
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
304
|
+
onMounted(() => {
|
|
305
|
+
const handle = { getRoot: () => rootRef.value, getTotal: () => 20 };
|
|
306
|
+
registrar?.register(handle);
|
|
307
|
+
bHandle.fn = () => registrar?.unregister(handle);
|
|
308
|
+
});
|
|
309
|
+
return () =>
|
|
310
|
+
h("div", { ref: rootRef }, [
|
|
311
|
+
h("table", { class: "probe-table-b" }, [h("tbody", {}, Array.from({ length: 20 }, (_, i) => h("tr", { "data-list": "b", key: i }, `b${i}`)))]),
|
|
312
|
+
]);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const scroller = mountScroller(() => h("div", [h(listA), h(listB)]));
|
|
316
|
+
// A's rows live above the band; B's rows intersect it.
|
|
317
|
+
scroller.container.querySelectorAll<HTMLTableRowElement>("tr").forEach((row) => {
|
|
318
|
+
const above = row.dataset.list === "a";
|
|
319
|
+
const top = above ? -2000 : 100;
|
|
320
|
+
rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
|
|
321
|
+
left: 0, top, right: 100, bottom: top + 40, width: 100, height: 40, x: 0, y: top,
|
|
322
|
+
} as DOMRect));
|
|
323
|
+
});
|
|
324
|
+
// Roots: A above (bottom above the band's top), B intersecting.
|
|
325
|
+
stubRootRect(scroller.container, ".probe-table-a", -2000, -1600, true);
|
|
326
|
+
stubRootRect(scroller.container, ".probe-table-b", 100, 900, true);
|
|
327
|
+
scroller.setScrollTop(100);
|
|
328
|
+
scroller.refresh();
|
|
329
|
+
await nextTick();
|
|
330
|
+
// B's rows all intersect the band, so the first visible row is #1.
|
|
331
|
+
expect(scroller.pill()!.textContent).toBe("1 / 20");
|
|
332
|
+
|
|
333
|
+
// B leaves the registry: the nearest-above tier describes A — every
|
|
334
|
+
// A row sits above the band, so the numerator reads as "past the
|
|
335
|
+
// end" (rows.length) over A's total.
|
|
336
|
+
bHandle.fn?.();
|
|
337
|
+
scroller.refresh();
|
|
338
|
+
await nextTick();
|
|
339
|
+
expect(scroller.pill()!.textContent).toBe("10 / 10");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("hides once the registration goes away", async () => {
|
|
343
|
+
const handleBox: { fn?: () => void } = {};
|
|
344
|
+
const List = defineComponent({
|
|
345
|
+
setup() {
|
|
346
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
347
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
348
|
+
onMounted(() => {
|
|
349
|
+
const handle = { getRoot: () => rootRef.value, getTotal: () => 10 };
|
|
350
|
+
registrar?.register(handle);
|
|
351
|
+
handleBox.fn = () => registrar?.unregister(handle);
|
|
352
|
+
});
|
|
353
|
+
return () =>
|
|
354
|
+
h("div", { ref: rootRef }, [
|
|
355
|
+
h("table", {}, [h("tbody", {}, Array.from({ length: 10 }, (_, i) => h("tr", { key: i }, `r${i}`)))]),
|
|
356
|
+
]);
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
const scroller = mountScroller(() => h(List));
|
|
360
|
+
stubRows(scroller.container, 10, 0);
|
|
361
|
+
stubRootRect(scroller.container, "table", -100, 1900);
|
|
362
|
+
// First pass baselines the offset (no active yet); the second move
|
|
363
|
+
// lights the pill.
|
|
364
|
+
scroller.setScrollTop(100);
|
|
365
|
+
scroller.refresh();
|
|
366
|
+
await nextTick();
|
|
367
|
+
scroller.setScrollTop(200);
|
|
368
|
+
scroller.refresh();
|
|
369
|
+
await nextTick();
|
|
370
|
+
expect(scroller.pill()!.dataset.state).toBe("active");
|
|
371
|
+
handleBox.fn?.();
|
|
372
|
+
scroller.refresh();
|
|
373
|
+
await nextTick();
|
|
374
|
+
expect(scroller.pill()!.dataset.state).toBe("hidden");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("HkAdminTablePage scroll-progress registration", () => {
|
|
379
|
+
it("registers its table root and total with the provided registrar", async () => {
|
|
380
|
+
const registered: Array<{ getRoot: () => HTMLElement | null; getTotal: () => number | undefined }> = [];
|
|
381
|
+
const unregistered: unknown[] = [];
|
|
382
|
+
const registrar: HkScrollProgressRegistrar = {
|
|
383
|
+
register: (handle) => registered.push(handle),
|
|
384
|
+
unregister: (handle) => unregistered.push(handle),
|
|
385
|
+
};
|
|
386
|
+
const Provider = defineComponent({
|
|
387
|
+
setup() {
|
|
388
|
+
provide(HK_SCROLL_PROGRESS_REGISTRAR, registrar);
|
|
389
|
+
return () =>
|
|
390
|
+
h(HkAdminTablePage, {
|
|
391
|
+
title: "T",
|
|
392
|
+
rows: [{ name: "a" }, { name: "b" }],
|
|
393
|
+
columns: [{ key: "name", title: "Name" }],
|
|
394
|
+
total: 99,
|
|
395
|
+
});
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const container = mountTree(h(Provider));
|
|
399
|
+
expect(registered.length).toBe(1);
|
|
400
|
+
expect(registered[0].getTotal()).toBe(99);
|
|
401
|
+
// The table card is the row root — the element containing the rows.
|
|
402
|
+
expect(registered[0].getRoot()).not.toBeNull();
|
|
403
|
+
expect(registered[0].getRoot()!.contains(container.querySelector("table"))).toBe(true);
|
|
404
|
+
|
|
405
|
+
// Unmount unregisters the SAME handle.
|
|
406
|
+
mounts[0].app.unmount();
|
|
407
|
+
expect(unregistered.length).toBe(1);
|
|
408
|
+
expect(unregistered[0]).toBe(registered[0]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("renders its rows into the shell viewport's pill denominator end-to-end", async () => {
|
|
412
|
+
// The real provide/inject path: AdminTablePage inside a
|
|
413
|
+
// scrollProgress-on container registers itself, and the pill's
|
|
414
|
+
// denominator is the page's declared total (not the delivered rows).
|
|
415
|
+
const page = h(HkAdminTablePage, {
|
|
416
|
+
title: "T",
|
|
417
|
+
rows: Array.from({ length: 30 }, (_, i) => ({ name: `r${i}` })),
|
|
418
|
+
columns: [{ key: "name", title: "Name" }],
|
|
419
|
+
total: 500,
|
|
420
|
+
});
|
|
421
|
+
const instance = ref<{ refresh: () => void } | null>(null);
|
|
422
|
+
let scrollTop = 0;
|
|
423
|
+
const Wrapper = defineComponent({
|
|
424
|
+
setup() {
|
|
425
|
+
return () =>
|
|
426
|
+
h(HkScrollContainer, { ref: instance as never, scrollProgress: true }, { default: () => page });
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
const container = mountTree(h(Wrapper));
|
|
430
|
+
const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
431
|
+
Object.defineProperties(viewport, {
|
|
432
|
+
scrollHeight: { configurable: true, get: () => 4000 },
|
|
433
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
434
|
+
scrollWidth: { configurable: true, get: () => 300 },
|
|
435
|
+
clientWidth: { configurable: true, get: () => 300 },
|
|
436
|
+
scrollLeft: { configurable: true, get: () => 0 },
|
|
437
|
+
scrollTop: { configurable: true, get: () => scrollTop },
|
|
438
|
+
});
|
|
439
|
+
rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
|
|
440
|
+
left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
|
|
441
|
+
} as DOMRect));
|
|
442
|
+
// The list root is AdminTablePage's table-card wrapper (the anchor
|
|
443
|
+
// class the page ships for exactly this geometry contract).
|
|
444
|
+
const card = container.querySelector("table");
|
|
445
|
+
expect(card).not.toBeNull();
|
|
446
|
+
const cardHost = container.querySelector<HTMLElement>(".hk-admin-table-page-tablecard")!;
|
|
447
|
+
expect(cardHost.contains(card!)).toBe(true);
|
|
448
|
+
rects.push(vi.spyOn(cardHost, "getBoundingClientRect").mockReturnValue({
|
|
449
|
+
left: 0, top: -200, right: 300, bottom: 3800, width: 300, height: 4000, x: 0, y: -200,
|
|
450
|
+
} as DOMRect));
|
|
451
|
+
container.querySelectorAll<HTMLElement>("tbody tr").forEach((row, i) => {
|
|
452
|
+
const top = i * 40 - 200;
|
|
453
|
+
rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
|
|
454
|
+
left: 0, top, right: 300, bottom: top + 40, width: 300, height: 40, x: 0, y: top,
|
|
455
|
+
} as DOMRect));
|
|
456
|
+
});
|
|
457
|
+
scrollTop = 200;
|
|
458
|
+
instance.value!.refresh();
|
|
459
|
+
await nextTick();
|
|
460
|
+
const pill = container.querySelector<HTMLElement>(".hk-scroll-progress-pill");
|
|
461
|
+
expect(pill).not.toBeNull();
|
|
462
|
+
// First visible row #6 over the DECLARED total — the delivered rows
|
|
463
|
+
// (30) never override the page's server count.
|
|
464
|
+
expect(pill!.textContent).toBe("6 / 500");
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
// ── SCSS contract (textual pins — happy-dom does not lay out) ────────
|
|
470
|
+
// The house style pins sheet claims with readFileSync probes
|
|
471
|
+
// (HkButton.test.tsx icon-only precedent): these guard the paint
|
|
472
|
+
// decisions the DOM tests cannot see.
|
|
473
|
+
const scss = readFileSync(join(__dirname, "HkScrollProgress.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
474
|
+
|
|
475
|
+
describe("HkScrollProgress SCSS contract", () => {
|
|
476
|
+
it("hides by display:none, never just transparency", () => {
|
|
477
|
+
const hidden = scss.match(/&\[data-state="hidden"\]\s*{[^}]*}/)?.[0] ?? "";
|
|
478
|
+
expect(hidden, "hidden-state rule must exist").toBeTruthy();
|
|
479
|
+
expect(hidden).toContain("display: none");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("fades via a 200ms opacity transition on the pill itself", () => {
|
|
483
|
+
const rule = scss.match(/\.hk-scroll-progress-pill\s*{[^}]*}/)?.[0] ?? "";
|
|
484
|
+
expect(rule, "pill rule must exist").toBeTruthy();
|
|
485
|
+
expect(rule).toContain("transition: opacity 200ms ease");
|
|
486
|
+
expect(rule).toContain("pointer-events: none");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("hangs bottom-center with the safe-area inset and the autotag z-tier", () => {
|
|
490
|
+
const rule = scss.match(/\.hk-scroll-progress-pill\s*{[^}]*}/)?.[0] ?? "";
|
|
491
|
+
expect(rule).toContain("position: absolute");
|
|
492
|
+
expect(rule).toContain("left: 50%");
|
|
493
|
+
expect(rule).toContain("max(var(--space-10), env(safe-area-inset-bottom, 0px))");
|
|
494
|
+
expect(rule).toContain("z-index: 6");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("drops the fade entirely under prefers-reduced-motion", () => {
|
|
498
|
+
const reduced = scss.match(/@media \(prefers-reduced-motion: reduce\)\s*{[^}@]*{[^}]*}\s*}/)?.[0] ?? "";
|
|
499
|
+
expect(reduced, "reduced-motion block must exist").toBeTruthy();
|
|
500
|
+
expect(reduced).toContain("transition: none");
|
|
501
|
+
});
|
|
502
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Scroll-progress pill: the shared registration vocabulary and the pill
|
|
2
|
+
// renderer for HkScrollContainer's `scrollProgress` opt-in.
|
|
3
|
+
//
|
|
4
|
+
// This module owns three things:
|
|
5
|
+
// 1. the provide/inject key (`HK_SCROLL_PROGRESS_REGISTRAR`) a list uses to
|
|
6
|
+
// find the registrar of the NEAREST enclosing HkScrollContainer that has
|
|
7
|
+
// the feature on — provide/inject scoping is what makes a list registered
|
|
8
|
+
// against an inner container never leak to an outer one;
|
|
9
|
+
// 2. the registration types (`HkScrollProgressHandle`,
|
|
10
|
+
// `HkScrollProgressRegistrar`);
|
|
11
|
+
// 3. the pill renderer itself — HkScrollContainer imports and mounts it
|
|
12
|
+
// inside its host, so the component stays internal to the feature.
|
|
13
|
+
//
|
|
14
|
+
// The pill shows bare digits "12 / 340" (first visible row / total rows) —
|
|
15
|
+
// deliberately locale-neutral, the same contract as HkLoadMore's counter.
|
|
16
|
+
import { defineComponent, type InjectionKey } from "vue";
|
|
17
|
+
|
|
18
|
+
import "./HkScrollProgress.scss";
|
|
19
|
+
|
|
20
|
+
/** What a list hands to the registrar so the pill can describe it:
|
|
21
|
+
* the list's root element (the box that scrolls inside the container's
|
|
22
|
+
* viewport) and the authoritative row total when the list knows one
|
|
23
|
+
* (undefined = the pill falls back to counting DOM rows). */
|
|
24
|
+
export interface HkScrollProgressHandle {
|
|
25
|
+
getRoot(): HTMLElement | null;
|
|
26
|
+
getTotal(): number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Provided by every HkScrollContainer instance whose `scrollProgress`
|
|
30
|
+
* prop is on, and never when the prop is off. Lists inject it (null
|
|
31
|
+
* fallback) and register on mounted / unregister on unmounted. */
|
|
32
|
+
export interface HkScrollProgressRegistrar {
|
|
33
|
+
register(handle: HkScrollProgressHandle): void;
|
|
34
|
+
unregister(handle: HkScrollProgressHandle): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const HK_SCROLL_PROGRESS_REGISTRAR: InjectionKey<HkScrollProgressRegistrar> =
|
|
38
|
+
Symbol("hk-scroll-progress-registrar");
|
|
39
|
+
|
|
40
|
+
/** The pill renderer. Three states, mirrored on `data-state` so the SCSS
|
|
41
|
+
* owns the paint:
|
|
42
|
+
* - "hidden" — a precondition fails (no registration, no overflow, no
|
|
43
|
+
* rows): `display: none`, never just transparent;
|
|
44
|
+
* - "active" — the scroll offset is changing: opaque;
|
|
45
|
+
* - "idle" — scrolling stopped: opacity 0 over the ~200ms fade.
|
|
46
|
+
* A11y honesty: `aria-hidden` throughout — the pill is DECORATIVE chrome
|
|
47
|
+
* and nothing announces the row position (HkInfiniteTail's role=status
|
|
48
|
+
* announces load / end-of-list events only, never a scroll position);
|
|
49
|
+
* this component adds no announce channel of its own. */
|
|
50
|
+
const HkScrollProgress = defineComponent({
|
|
51
|
+
name: "HkScrollProgress",
|
|
52
|
+
props: {
|
|
53
|
+
shown: { type: Boolean, default: false },
|
|
54
|
+
active: { type: Boolean, default: false },
|
|
55
|
+
numerator: { type: Number, default: 0 },
|
|
56
|
+
denominator: { type: Number, default: 0 },
|
|
57
|
+
},
|
|
58
|
+
setup(props) {
|
|
59
|
+
return () => (
|
|
60
|
+
<span
|
|
61
|
+
class="hk-scroll-progress-pill"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
data-state={!props.shown ? "hidden" : props.active ? "active" : "idle"}
|
|
64
|
+
>
|
|
65
|
+
{props.numerator} / {props.denominator}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export default HkScrollProgress;
|
|
@@ -59,11 +59,32 @@
|
|
|
59
59
|
|
|
60
60
|
/* The icon-only action's tooltip host: the button itself is disabled in
|
|
61
61
|
* manual modes and therefore loses pointer events (native `disabled`), so
|
|
62
|
-
* the title has to live on this wrapper.
|
|
62
|
+
* the title has to live on this wrapper. `relative` anchors the visually
|
|
63
|
+
* hidden reason span below. */
|
|
63
64
|
.hk-solar-edit-wrap {
|
|
64
65
|
display: inline-flex;
|
|
65
66
|
flex: none;
|
|
66
67
|
align-items: center;
|
|
68
|
+
position: relative;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* The disabled-state reason for screen readers: a natively disabled button
|
|
72
|
+
* is unfocusable, so keyboard/AT users can never reach a tooltip. The span
|
|
73
|
+
* is referenced from the button's aria-describedby (rendered only while
|
|
74
|
+
* disabled) and collapses to nothing visually — the clip pair hides the
|
|
75
|
+
* box entirely without `display:none`, which would drop it from the
|
|
76
|
+
* accessibility tree too. */
|
|
77
|
+
.hk-solar-edit-reason {
|
|
78
|
+
position: absolute;
|
|
79
|
+
width: 1px;
|
|
80
|
+
height: 1px;
|
|
81
|
+
margin: -1px;
|
|
82
|
+
padding: 0;
|
|
83
|
+
border-width: 0;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
white-space: nowrap;
|
|
86
|
+
clip: rect(0 0 0 0);
|
|
87
|
+
clip-path: inset(50%);
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
.hk-solar-edit {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkSolarLocationSetting row-geometry contract (S3 follow-up, 2026-11-05):
|
|
9
|
+
* the first polish wave let `line-height: 1` clip descenders through every
|
|
10
|
+
* gate because ONLY Vue↔Rust sheet equality was guarded — both forms were
|
|
11
|
+
* wrong together. This spec pins the VALUES the row's single-center
|
|
12
|
+
* geometry depends on, so a future "simplification" of the leading (or the
|
|
13
|
+
* row minimum, or the glyph box) fails here before it ships.
|
|
14
|
+
*
|
|
15
|
+
* The vendored Rust mirror is byte-guarded by
|
|
16
|
+
* packages/components/tests/vue_mirror_drift_tests.rs
|
|
17
|
+
* (mirror_bodies_match_their_upstream_sheets); this file only has to pin
|
|
18
|
+
* the canonical Vue sheet.
|
|
19
|
+
*/
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const scss = readFileSync(join(here, "HkSolarLocationSetting.scss"), "utf8");
|
|
22
|
+
|
|
23
|
+
/** Comments stripped: a comment naming a property must not satisfy a guard
|
|
24
|
+
* about it (the house rule from HkAmbientLayer's pointer contract). */
|
|
25
|
+
const declarations = scss.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
26
|
+
|
|
27
|
+
/** One ruleset's body, or null when the selector never appears. */
|
|
28
|
+
function bodyOf(selector: string): string | null {
|
|
29
|
+
const re = new RegExp(
|
|
30
|
+
selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*\\{([^{}]*)\\}",
|
|
31
|
+
);
|
|
32
|
+
const m = declarations.match(re);
|
|
33
|
+
return m ? m[1] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("HkSolarLocationSetting row geometry", () => {
|
|
37
|
+
it("pins the row's cross size to the 28px action square", () => {
|
|
38
|
+
const row = bodyOf(".hk-solar-row");
|
|
39
|
+
expect(row, "the row ruleset exists").not.toBeNull();
|
|
40
|
+
// The single geometric center: the row's cross size is the icon-button
|
|
41
|
+
// square, not the summary's line box.
|
|
42
|
+
expect(row).toMatch(/min-height:\s*var\(--space-28,\s*1\.75rem\)/);
|
|
43
|
+
expect(row).toMatch(/align-items:\s*center/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("pins the summary leading to the resolved font's own metrics", () => {
|
|
47
|
+
const summary = bodyOf(".hk-solar-summary");
|
|
48
|
+
expect(summary, "the summary ruleset exists").not.toBeNull();
|
|
49
|
+
// `normal` — NOT a hand-picked multiplier: fixed 1em/1.2em line boxes
|
|
50
|
+
// clipped descenders in Latin, Arabic and CJK fallback faces (the text
|
|
51
|
+
// clips at the ellipsis via overflow:hidden).
|
|
52
|
+
expect(summary).toMatch(/line-height:\s*normal/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("keeps the glyph box leading-free", () => {
|
|
56
|
+
const icon = bodyOf(".hk-solar-summary-icon");
|
|
57
|
+
expect(icon, "the glyph ruleset exists").not.toBeNull();
|
|
58
|
+
expect(icon).toMatch(/line-height:\s*0/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("collapses the screen-reader reason without display:none", () => {
|
|
62
|
+
const reason = bodyOf(".hk-solar-edit-reason");
|
|
63
|
+
expect(reason, "the reason ruleset exists").not.toBeNull();
|
|
64
|
+
// display:none would drop the span from the accessibility tree, which
|
|
65
|
+
// defeats the aria-describedby contract; the clip pair hides it
|
|
66
|
+
// visually while keeping it readable.
|
|
67
|
+
expect(reason).not.toMatch(/display:\s*none/);
|
|
68
|
+
expect(reason).toMatch(/position:\s*absolute/);
|
|
69
|
+
expect(reason).toMatch(/clip-path:\s*inset\(50%\)/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("anchors the reason span inside the wrapper", () => {
|
|
73
|
+
const wrap = bodyOf(".hk-solar-edit-wrap");
|
|
74
|
+
expect(wrap, "the wrapper ruleset exists").not.toBeNull();
|
|
75
|
+
expect(wrap).toMatch(/position:\s*relative/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -256,14 +256,21 @@ describe("HkSolarLocationSetting card", () => {
|
|
|
256
256
|
it("grays the edit action outside auto mode and re-enables it in auto", async () => {
|
|
257
257
|
const { setMode } = useTheme();
|
|
258
258
|
// Mount in AUTO first, then drive the live mode transitions a host's
|
|
259
|
-
// own switch performs —
|
|
260
|
-
//
|
|
259
|
+
// own switch performs — the user path. The "enabled at mount" assertion
|
|
260
|
+
// below DEPENDS on the beforeEach resetEngineState() forcing the theme
|
|
261
|
+
// singleton back to "system"; do not remove that reset, or the first
|
|
262
|
+
// assertion here must go.
|
|
261
263
|
const container = mountSetting();
|
|
262
264
|
await settle();
|
|
263
265
|
expect(editButton(container).disabled, "auto mode enables the editor").toBe(false);
|
|
264
266
|
expect(editButton(container).closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
|
|
265
267
|
"Edit",
|
|
266
268
|
);
|
|
269
|
+
expect(
|
|
270
|
+
editButton(container).getAttribute("aria-describedby"),
|
|
271
|
+
"no reason reference while enabled",
|
|
272
|
+
).toBeNull();
|
|
273
|
+
expect(container.querySelector(".hk-solar-edit-reason"), "no reason span while enabled").toBeNull();
|
|
267
274
|
|
|
268
275
|
setMode("light");
|
|
269
276
|
await settle();
|
|
@@ -273,19 +280,33 @@ describe("HkSolarLocationSetting card", () => {
|
|
|
273
280
|
expect(btn.closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
|
|
274
281
|
"Switch to Auto mode to change the daylight location",
|
|
275
282
|
);
|
|
283
|
+
// The disabled reason must reach keyboard/AT users too: the tooltip is
|
|
284
|
+
// mouse-only (a natively disabled control is unfocusable), so the same
|
|
285
|
+
// text rides a visually hidden span referenced via aria-describedby.
|
|
286
|
+
const describedBy = btn.getAttribute("aria-describedby");
|
|
287
|
+
expect(describedBy, "the disabled button points at its reason text").toBeTruthy();
|
|
288
|
+
const reason = container.querySelector(`#${describedBy}`);
|
|
289
|
+
expect(reason, "the referenced reason span exists").toBeTruthy();
|
|
290
|
+
expect(reason?.classList.contains("hk-solar-edit-reason")).toBe(true);
|
|
291
|
+
expect(reason?.textContent?.trim()).toBe(
|
|
292
|
+
"Switch to Auto mode to change the daylight location",
|
|
293
|
+
);
|
|
276
294
|
|
|
277
295
|
// A disabled native button cannot open the editor.
|
|
278
296
|
btn.click();
|
|
279
297
|
await settle();
|
|
280
298
|
expect(modalHeader(), "disabled edit never opens the modal").toBeNull();
|
|
281
299
|
|
|
282
|
-
// Back to auto: the SAME mounted card re-enables on the live mode ref
|
|
300
|
+
// Back to auto: the SAME mounted card re-enables on the live mode ref,
|
|
301
|
+
// and the a11y reason leaves the DOM with the disabled state.
|
|
283
302
|
setMode("system");
|
|
284
303
|
await settle();
|
|
285
304
|
expect(editButton(container).disabled).toBe(false);
|
|
286
305
|
expect(editButton(container).closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
|
|
287
306
|
"Edit",
|
|
288
307
|
);
|
|
308
|
+
expect(editButton(container).getAttribute("aria-describedby")).toBeNull();
|
|
309
|
+
expect(container.querySelector(".hk-solar-edit-reason")).toBeNull();
|
|
289
310
|
|
|
290
311
|
// Dark is just another manual side of the same gate.
|
|
291
312
|
setMode("dark");
|
|
@@ -293,6 +314,26 @@ describe("HkSolarLocationSetting card", () => {
|
|
|
293
314
|
expect(editButton(container).disabled).toBe(true);
|
|
294
315
|
});
|
|
295
316
|
|
|
317
|
+
it("gives each mounted card its own disabled-reason id", async () => {
|
|
318
|
+
const { setMode } = useTheme();
|
|
319
|
+
setMode("dark");
|
|
320
|
+
const first = mountSetting();
|
|
321
|
+
const second = mountSetting();
|
|
322
|
+
await settle();
|
|
323
|
+
|
|
324
|
+
const ids = [first, second].map((c) =>
|
|
325
|
+
c.querySelector<HTMLElement>(".hk-solar-edit-reason")?.getAttribute("id"),
|
|
326
|
+
);
|
|
327
|
+
expect(ids.every(Boolean), "both disabled cards render their reason text").toBe(true);
|
|
328
|
+
expect(ids[0]).not.toBe(ids[1]);
|
|
329
|
+
// Each button points at its OWN card's span, not the other one's.
|
|
330
|
+
for (const c of [first, second]) {
|
|
331
|
+
const btn = editButton(c);
|
|
332
|
+
const own = c.querySelector(`#${btn.getAttribute("aria-describedby")}`);
|
|
333
|
+
expect(own, "the describedby reference resolves inside the same card").toBeTruthy();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
296
337
|
it("is a standard hikari card: localized default title, overridable, hideable", async () => {
|
|
297
338
|
const container = mountSetting();
|
|
298
339
|
await settle();
|
|
@@ -38,6 +38,12 @@ import "./HkSolarLocationSetting.scss";
|
|
|
38
38
|
// actually opens the manual pane — the settings card stays weightless.
|
|
39
39
|
const HkTimeZoneGlobe = defineAsyncComponent(() => import("./HkTimeZoneGlobe"));
|
|
40
40
|
|
|
41
|
+
/** Per-instance id for the disabled-state reason text (aria-describedby).
|
|
42
|
+
* A host page may mount more than one card (settings + a popover), so a
|
|
43
|
+
* static id would collide; a module counter keeps ids unique across mounts
|
|
44
|
+
* while staying deterministic for tests. */
|
|
45
|
+
let nextSolarReasonId = 0;
|
|
46
|
+
|
|
41
47
|
/** One copyable value of the editor's detected-value readout. `mono`
|
|
42
48
|
* faces technical ids (zone ids, IPs, ASNs) in the badge mono face;
|
|
43
49
|
* `wrap` renders the rectangular, soft-wrapping chip for long prose
|
|
@@ -181,6 +187,7 @@ export const HkSolarLocationSetting = defineComponent({
|
|
|
181
187
|
// AUTO-mode gate: the sun clock only drives the theme in "system".
|
|
182
188
|
const { currentMode } = useTheme();
|
|
183
189
|
const editorDisabled = computed(() => currentMode.value !== "system");
|
|
190
|
+
const reasonId = `hk-solar-edit-reason-${(nextSolarReasonId += 1)}`;
|
|
184
191
|
// The engine hydrates lazily (storage-silent imports): a host that
|
|
185
192
|
// mounts this card before initTheme() still sees the stored strategy.
|
|
186
193
|
hydrateSolarLocationState();
|
|
@@ -449,7 +456,10 @@ export const HkSolarLocationSetting = defineComponent({
|
|
|
449
456
|
{/* Icon-only Edit (2026-11-05): the row's action is the standard
|
|
450
457
|
ghost icon-button square, not a text pill. Disabled outside
|
|
451
458
|
auto mode; the wrapper owns the tooltip because a disabled
|
|
452
|
-
button drops pointer events
|
|
459
|
+
button drops pointer events, and — since a natively disabled
|
|
460
|
+
control is also UNFOCUSABLE, so keyboard/AT users never see a
|
|
461
|
+
tooltip — the same explanation rides a visually hidden span
|
|
462
|
+
referenced through aria-describedby. */}
|
|
453
463
|
<span
|
|
454
464
|
class="hk-solar-edit-wrap"
|
|
455
465
|
title={
|
|
@@ -463,11 +473,17 @@ export const HkSolarLocationSetting = defineComponent({
|
|
|
463
473
|
variant="ghost"
|
|
464
474
|
class="hk-solar-edit"
|
|
465
475
|
aria-label={t("hikari::theme.solar.edit")}
|
|
476
|
+
aria-describedby={editorDisabled.value ? reasonId : undefined}
|
|
466
477
|
disabled={editorDisabled.value}
|
|
467
478
|
onClick={openEditor}
|
|
468
479
|
>
|
|
469
480
|
{{ icon: () => <Pencil size={16} /> }}
|
|
470
481
|
</HkIconButton>
|
|
482
|
+
{editorDisabled.value && (
|
|
483
|
+
<span id={reasonId} class="hk-solar-edit-reason">
|
|
484
|
+
{t("hikari::theme.solar.editAutoOnly")}
|
|
485
|
+
</span>
|
|
486
|
+
)}
|
|
471
487
|
</span>
|
|
472
488
|
</div>
|
|
473
489
|
|
package/src/index.ts
CHANGED
|
@@ -507,6 +507,12 @@ export type { PasswordValidationResult, PasswordLevel, PasswordStrengthEvaluator
|
|
|
507
507
|
export { FOCUSABLE_SELECTOR, getFocusableElements, focusFirst, trapFocus, scrollToElement } from "./utils/dom";
|
|
508
508
|
export { useApproachEnd } from "./composables/useApproachEnd";
|
|
509
509
|
export type { ApproachEndOptions, ApproachEndHandle } from "./composables/useApproachEnd";
|
|
510
|
+
// The scroll-progress pill's public vocabulary: HkScrollContainer's
|
|
511
|
+
// `scrollProgress` registrar — lists (HkAdminTablePage does it built-in)
|
|
512
|
+
// inject this key to register their row root + total with the nearest
|
|
513
|
+
// enclosing container that has the feature on.
|
|
514
|
+
export { HK_SCROLL_PROGRESS_REGISTRAR } from "./components/HkScrollProgress";
|
|
515
|
+
export type { HkScrollProgressHandle, HkScrollProgressRegistrar } from "./components/HkScrollProgress";
|
|
510
516
|
// The change-mark rail's public vocabulary: HkScrollContainer's `markers`
|
|
511
517
|
// prop is typed with it, so consumers can name what they pass.
|
|
512
518
|
export type { OverlayScrollMapSegment, OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
|