@celestia-island/hikari 0.55.292 → 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
CHANGED
|
@@ -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
|
|