@celestia-island/hikari 0.55.244 → 0.55.246
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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
|
|
2
|
+
|
|
3
|
+
/* HkDateTag — the locale-aware stacked date capsule (see the component
|
|
4
|
+
* docblock for the contract). Rows are calendar segments; the year and
|
|
5
|
+
* the optional time row read as context (muted, a touch smaller), the
|
|
6
|
+
* month/day figures carry the full text color. */
|
|
7
|
+
|
|
8
|
+
.hk-date-tag {
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
align-items: center;
|
|
12
|
+
padding: var(--space-2, 2px) var(--space-10, 0.625rem);
|
|
13
|
+
border-radius: var(--radius-full, 999px);
|
|
14
|
+
background: rgb(var(--color-text) / 6%);
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.hk-date-tag__row {
|
|
19
|
+
display: block;
|
|
20
|
+
text-align: center;
|
|
21
|
+
line-height: 1.25;
|
|
22
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
23
|
+
font-variant-numeric: tabular-nums;
|
|
24
|
+
color: rgb(var(--color-text));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Context rows: the year (wherever the locale puts it) and the optional
|
|
28
|
+
* time row — muted and a touch smaller than the month/day figures. */
|
|
29
|
+
.hk-date-tag__row--year,
|
|
30
|
+
.hk-date-tag__row--time {
|
|
31
|
+
color: rgb(var(--color-text) / 55%);
|
|
32
|
+
font-size: calc(var(--text-2xs, 0.625rem) - 1px);
|
|
33
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createApp, h, nextTick } from "vue";
|
|
4
|
+
|
|
5
|
+
import HkDateTagDefault, { HkDateTag } from "./HkDateTag";
|
|
6
|
+
import { setLocale } from "../i18n/context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HkDateTag — the locale-aware date capsule. These cases pin the
|
|
10
|
+
* COMPONENT contract: the row order follows the ACTIVE hikari locale
|
|
11
|
+
* (year on top for zh-Hans, year LAST for en), the order re-derives
|
|
12
|
+
* live when the host flips the locale, `time` appends the muted
|
|
13
|
+
* fourth row, and unparseable input renders the empty capsule shell.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
17
|
+
|
|
18
|
+
// Built in LOCAL time so the calendar fields (2024-10-06 14:30) survive
|
|
19
|
+
// every CI time zone; the capsule renders local calendar segments.
|
|
20
|
+
const SHIP_DATE = new Date(2024, 9, 6, 14, 30);
|
|
21
|
+
|
|
22
|
+
function mountTag(props: { value: string | number | Date; time?: boolean }): HTMLElement {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const app = createApp({ render: () => h(HkDateTag, props) });
|
|
26
|
+
app.mount(container);
|
|
27
|
+
mounts.push({ app, container });
|
|
28
|
+
return container;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rowsOf(container: HTMLElement): HTMLElement[] {
|
|
32
|
+
return [...container.querySelectorAll<HTMLElement>(".hk-date-tag__row")];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function capsuleOf(container: HTMLElement): HTMLElement {
|
|
36
|
+
const el = container.querySelector<HTMLElement>(".hk-date-tag");
|
|
37
|
+
expect(el, "capsule renders").toBeTruthy();
|
|
38
|
+
return el!;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// setLocale mutates the module-global i18n state — restore the default
|
|
42
|
+
// after every case (the HkSolarLocationSetting suite's idiom).
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
45
|
+
app.unmount();
|
|
46
|
+
container.remove();
|
|
47
|
+
}
|
|
48
|
+
await setLocale("en");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("HkDateTag locale ordering", () => {
|
|
52
|
+
it("renders year/month/day rows top to bottom for zh-Hans", async () => {
|
|
53
|
+
await setLocale("zh-Hans");
|
|
54
|
+
const container = mountTag({ value: SHIP_DATE });
|
|
55
|
+
|
|
56
|
+
const capsule = capsuleOf(container);
|
|
57
|
+
expect(capsule.classList.contains("hk-date-tag")).toBe(true);
|
|
58
|
+
const rows = rowsOf(container);
|
|
59
|
+
expect(rows.map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
|
|
60
|
+
// The year row carries its modifier wherever it lands.
|
|
61
|
+
expect(rows[0].classList.contains("hk-date-tag__row--year")).toBe(true);
|
|
62
|
+
expect(rows[1].classList.contains("hk-date-tag__row--year")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("orders month/day/year for en — the year goes LAST", async () => {
|
|
66
|
+
await setLocale("en");
|
|
67
|
+
const container = mountTag({ value: SHIP_DATE });
|
|
68
|
+
|
|
69
|
+
const rows = rowsOf(container);
|
|
70
|
+
expect(rows.map((r) => r.textContent)).toEqual(["10", "06", "2024"]);
|
|
71
|
+
expect(rows[2].classList.contains("hk-date-tag__row--year")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("re-orders the rows live when the locale flips under a mounted capsule", async () => {
|
|
75
|
+
await setLocale("zh-Hans");
|
|
76
|
+
const container = mountTag({ value: SHIP_DATE });
|
|
77
|
+
expect(rowsOf(container).map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
|
|
78
|
+
|
|
79
|
+
await setLocale("en");
|
|
80
|
+
await nextTick();
|
|
81
|
+
expect(rowsOf(container).map((r) => r.textContent)).toEqual(["10", "06", "2024"]);
|
|
82
|
+
|
|
83
|
+
await setLocale("zh-Hans");
|
|
84
|
+
await nextTick();
|
|
85
|
+
expect(rowsOf(container).map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("HkDateTag time row", () => {
|
|
90
|
+
it("appends the muted time row only when asked", async () => {
|
|
91
|
+
await setLocale("zh-Hans");
|
|
92
|
+
const withTime = rowsOf(mountTag({ value: SHIP_DATE, time: true }));
|
|
93
|
+
expect(withTime).toHaveLength(4);
|
|
94
|
+
expect(withTime[3].classList.contains("hk-date-tag__row--time")).toBe(true);
|
|
95
|
+
expect(withTime[3].textContent).toBe("14:30");
|
|
96
|
+
|
|
97
|
+
const withoutTime = rowsOf(mountTag({ value: SHIP_DATE }));
|
|
98
|
+
expect(withoutTime).toHaveLength(3);
|
|
99
|
+
expect(withoutTime.some((r) => r.classList.contains("hk-date-tag__row--time"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("formats the time row for the active locale", async () => {
|
|
103
|
+
await setLocale("en");
|
|
104
|
+
const rows = rowsOf(mountTag({ value: SHIP_DATE, time: true }));
|
|
105
|
+
expect(rows).toHaveLength(4);
|
|
106
|
+
expect(rows[3].textContent).toBe("02:30 PM");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("HkDateTag value normalization", () => {
|
|
111
|
+
it("accepts epoch milliseconds and date strings like the Date form", async () => {
|
|
112
|
+
await setLocale("en");
|
|
113
|
+
const expected = ["10", "06", "2024"];
|
|
114
|
+
expect(rowsOf(mountTag({ value: SHIP_DATE })).map((r) => r.textContent)).toEqual(expected);
|
|
115
|
+
// A number is epoch MILLISECONDS.
|
|
116
|
+
expect(rowsOf(mountTag({ value: SHIP_DATE.getTime() })).map((r) => r.textContent)).toEqual(expected);
|
|
117
|
+
expect(rowsOf(mountTag({ value: "2024-10-06T14:30:00" })).map((r) => r.textContent)).toEqual(expected);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("renders the empty capsule shell for unparseable input", async () => {
|
|
121
|
+
await setLocale("zh-Hans");
|
|
122
|
+
const container = mountTag({ value: "not-a-date" });
|
|
123
|
+
|
|
124
|
+
const capsule = capsuleOf(container);
|
|
125
|
+
expect(capsule.children).toHaveLength(0);
|
|
126
|
+
expect(rowsOf(container)).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("HkDateTag module surface", () => {
|
|
131
|
+
it("exports the same component as the default and named binding", () => {
|
|
132
|
+
expect(HkDateTagDefault).toBe(HkDateTag);
|
|
133
|
+
expect((HkDateTag as unknown as { name: string }).name).toBe("HkDateTag");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
|
|
2
|
+
import { computed, defineComponent, type PropType } from "vue";
|
|
3
|
+
|
|
4
|
+
import { activeLocale } from "../i18n/context";
|
|
5
|
+
import "./HkDateTag.scss";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkDateTag — a locale-aware date capsule: a small pill whose calendar
|
|
9
|
+
* segments are stacked vertically, ONE PER ROW, each centered, ordered
|
|
10
|
+
* the way the ACTIVE hikari locale orders dates (zh: year → month → day
|
|
11
|
+
* top to bottom; en: month → day → year, the year LAST; de: day first …).
|
|
12
|
+
* With the `time` flag a fourth, muted row carries the locale time
|
|
13
|
+
* (hour + minute, 2-digit each).
|
|
14
|
+
*
|
|
15
|
+
* Generic business part (peer of HkTemplateText / HkMarkdownRenderer,
|
|
16
|
+
* NOT of the HkInput family): hosts render compact per-row dates in data
|
|
17
|
+
* tables and cards — wowsp's water-meter ship tables are the first
|
|
18
|
+
* consumer.
|
|
19
|
+
*
|
|
20
|
+
* Contract:
|
|
21
|
+
* - `value` accepts a Date, an epoch-MILLISECONDS number or a
|
|
22
|
+
* date-ish string, normalized with `new Date(value)` (so a number is
|
|
23
|
+
* NEVER seconds — pass what `Date.now()` returns). Unparseable input
|
|
24
|
+
* renders NOTHING but the empty capsule shell; the caller owns the
|
|
25
|
+
* empty state.
|
|
26
|
+
* - The row order is derived from the locale itself, never a hardcoded
|
|
27
|
+
* locale→order map: `Intl.DateTimeFormat(activeLocale(), { year,
|
|
28
|
+
* month, day }).formatToParts(date)` supplies the year/month/day
|
|
29
|
+
* segments in the order that locale writes them. The component is a
|
|
30
|
+
* computed over the reactive i18n state, so a host `setLocale`
|
|
31
|
+
* re-orders the rows live.
|
|
32
|
+
* - `time` (default false) appends the muted time row
|
|
33
|
+
* (`hk-date-tag__row--time`) under the date rows.
|
|
34
|
+
*/
|
|
35
|
+
export const HkDateTag = defineComponent({
|
|
36
|
+
name: "HkDateTag",
|
|
37
|
+
props: {
|
|
38
|
+
/** The date to render — Date, epoch milliseconds or a parseable
|
|
39
|
+
* string. Invalid input renders the empty capsule. */
|
|
40
|
+
value: {
|
|
41
|
+
type: [String, Number, Date] as PropType<string | number | Date>,
|
|
42
|
+
required: true,
|
|
43
|
+
},
|
|
44
|
+
/** Append the muted locale time row (hour + minute, 2-digit). */
|
|
45
|
+
time: { type: Boolean, default: false },
|
|
46
|
+
},
|
|
47
|
+
setup(props) {
|
|
48
|
+
const date = computed<Date | null>(() => {
|
|
49
|
+
const d = new Date(props.value);
|
|
50
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The locale is read through the reactive i18n state (activeLocale
|
|
54
|
+
// reads it on every evaluation), so this computed re-runs — and the
|
|
55
|
+
// rows re-order — whenever the host calls setLocale. One formatter
|
|
56
|
+
// per shape per locale, rebuilt only on a locale/value change (the
|
|
57
|
+
// HkCalendarHeatmap idiom).
|
|
58
|
+
const locale = computed(() => activeLocale());
|
|
59
|
+
|
|
60
|
+
/** The year/month/day segments in the locale's own order. */
|
|
61
|
+
const rows = computed<Intl.DateTimeFormatPart[]>(() => {
|
|
62
|
+
const d = date.value;
|
|
63
|
+
if (d === null) return [];
|
|
64
|
+
return new Intl.DateTimeFormat(locale.value, {
|
|
65
|
+
year: "numeric",
|
|
66
|
+
month: "2-digit",
|
|
67
|
+
day: "2-digit",
|
|
68
|
+
})
|
|
69
|
+
.formatToParts(d)
|
|
70
|
+
.filter((part) => part.type === "year" || part.type === "month" || part.type === "day");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** The locale time (hour + minute, 2-digit) when the capsule opts
|
|
74
|
+
* in — null without `time` or for an invalid date. */
|
|
75
|
+
const timeText = computed<string | null>(() => {
|
|
76
|
+
const d = date.value;
|
|
77
|
+
if (!props.time || d === null) return null;
|
|
78
|
+
return new Intl.DateTimeFormat(locale.value, { hour: "2-digit", minute: "2-digit" }).format(d);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return () => (
|
|
82
|
+
<span class="hk-date-tag">
|
|
83
|
+
{rows.value.map((part) => (
|
|
84
|
+
<span
|
|
85
|
+
key={part.type}
|
|
86
|
+
class={part.type === "year" ? "hk-date-tag__row hk-date-tag__row--year" : "hk-date-tag__row"}
|
|
87
|
+
>
|
|
88
|
+
{part.value}
|
|
89
|
+
</span>
|
|
90
|
+
))}
|
|
91
|
+
{timeText.value !== null && (
|
|
92
|
+
<span key="time" class="hk-date-tag__row hk-date-tag__row--time">{timeText.value}</span>
|
|
93
|
+
)}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export default HkDateTag;
|
|
@@ -542,4 +542,36 @@ describe("HkSidebar standalone rail: the menu group fold survives its own swap",
|
|
|
542
542
|
"the expanded sidebar inherits the closed group",
|
|
543
543
|
).toBe("false");
|
|
544
544
|
});
|
|
545
|
+
|
|
546
|
+
it("keeps the scroll position across density flips (the #44 P3 audit finding)", async () => {
|
|
547
|
+
vi.useFakeTimers();
|
|
548
|
+
const c = mountSidebar({ width: "240px", collapsible: true, collapsedWidth: "64px" });
|
|
549
|
+
|
|
550
|
+
// Collapse to the rail and settle the dissolve.
|
|
551
|
+
c.toggle().click();
|
|
552
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
553
|
+
const bodies = () => c.container.querySelectorAll<HTMLElement>(".hk-sidebar-body");
|
|
554
|
+
const active = () => bodies()[bodies().length - 1]!;
|
|
555
|
+
expect(bodies().length, "one settled generation").toBe(1);
|
|
556
|
+
|
|
557
|
+
// The user scrolls the rail partway down…
|
|
558
|
+
active().scrollTop = 120;
|
|
559
|
+
|
|
560
|
+
// …then a peek flips the density (the shell keys the swap on
|
|
561
|
+
// `railActive && !peeking`, so every peek open/close is a flip).
|
|
562
|
+
c.aside().dispatchEvent(new Event("pointerenter"));
|
|
563
|
+
await vi.advanceTimersByTimeAsync(900);
|
|
564
|
+
expect(active().scrollTop, "the peek's full generation resumes the scroll").toBe(120);
|
|
565
|
+
|
|
566
|
+
// Leaving the peek flips back to the rail dress; the position must
|
|
567
|
+
// survive that remount too.
|
|
568
|
+
c.aside().dispatchEvent(new Event("pointerleave"));
|
|
569
|
+
await vi.advanceTimersByTimeAsync(900);
|
|
570
|
+
expect(active().scrollTop, "the rail generation resumes the scroll").toBe(120);
|
|
571
|
+
|
|
572
|
+
// And a full expand is the same crossing.
|
|
573
|
+
c.toggle().click();
|
|
574
|
+
await vi.advanceTimersByTimeAsync(900);
|
|
575
|
+
expect(active().scrollTop, "the expanded body resumes the scroll").toBe(120);
|
|
576
|
+
});
|
|
545
577
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
1
|
+
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, nextTick } from "vue";
|
|
2
2
|
|
|
3
3
|
import "./HkSidebar.scss";
|
|
4
4
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
@@ -143,6 +143,34 @@ export default defineComponent({
|
|
|
143
143
|
onMounted(attachBodyScrollbar);
|
|
144
144
|
watch([bodyRef, bodyWrapRef], attachBodyScrollbar);
|
|
145
145
|
|
|
146
|
+
// Density flips remount the body band (the rail swap renders each
|
|
147
|
+
// generation's body FRESH), which used to snap the scroll back to the
|
|
148
|
+
// top on every flip — including each hover-peek open/close, because
|
|
149
|
+
// the shell keys the swap on `railActive && !peeking` (the #44 P3
|
|
150
|
+
// audit finding; probed: scrollTop 120 -> peek -> 0 -> back -> 0).
|
|
151
|
+
// The sidebar's own instance SURVIVES the flip (the swap lives inside
|
|
152
|
+
// it), so the position only needs to cross the remount. A flip patches
|
|
153
|
+
// the shared ref old -> null -> new: the null step still sees the
|
|
154
|
+
// LEAVING element as the old value (its DOM is alive for the
|
|
155
|
+
// dissolve), which is where the position is read from — an event-only
|
|
156
|
+
// save would miss programmatic scrolls (no `scroll` event fires for
|
|
157
|
+
// them), so the read happens on the ref hand-off itself and the
|
|
158
|
+
// onScroll below merely keeps the value fresh between flips. Raw
|
|
159
|
+
// scrollTop is an approximation across dresses (rail rows and full
|
|
160
|
+
// rows have different heights) — an exact restore would need an
|
|
161
|
+
// item-keyed anchor, not worth it for a nav list; the browser clamps
|
|
162
|
+
// any overshoot.
|
|
163
|
+
const savedScrollTop = ref(0);
|
|
164
|
+
const onBodyScroll = (e: Event): void => {
|
|
165
|
+
savedScrollTop.value = (e.currentTarget as HTMLElement).scrollTop;
|
|
166
|
+
};
|
|
167
|
+
watch(bodyRef, async (body, leaving) => {
|
|
168
|
+
if (leaving) savedScrollTop.value = leaving.scrollTop;
|
|
169
|
+
if (!body) return;
|
|
170
|
+
await nextTick();
|
|
171
|
+
body.scrollTop = savedScrollTop.value;
|
|
172
|
+
});
|
|
173
|
+
|
|
146
174
|
onBeforeUnmount(() => {
|
|
147
175
|
bodyScrollbar?.detach();
|
|
148
176
|
bodyScrollbar = null;
|
|
@@ -199,7 +227,7 @@ export default defineComponent({
|
|
|
199
227
|
<header class="hk-sidebar-header">{slots.header()}</header>
|
|
200
228
|
) : null}
|
|
201
229
|
<div ref={bodyWrapRef} class="hk-sidebar-body-wrap">
|
|
202
|
-
<div ref={bodyRef} class="hk-sidebar-body">
|
|
230
|
+
<div ref={bodyRef} class="hk-sidebar-body" onScroll={onBodyScroll}>
|
|
203
231
|
{/* Scoped params (ignored by plain slots): the density that
|
|
204
232
|
owns THIS generation so a host renders icon-only rows
|
|
205
233
|
while collapsed and full rows during a peek, plus the
|
package/src/index.ts
CHANGED
|
@@ -555,6 +555,7 @@ export { HkNavSidebar as HNavSidebar } from "./components/HkNavSidebar";
|
|
|
555
555
|
export { HkThemeToggle as HThemeToggle, type ThemeItemScope } from "./components/HkThemeToggle";
|
|
556
556
|
export { HkThemePanelContent as HThemePanelContent } from "./components/HkThemePanelContent";
|
|
557
557
|
export { HkSolarLocationSetting as HSolarLocationSetting } from "./components/HkSolarLocationSetting";
|
|
558
|
+
export { HkDateTag as HDateTag } from "./components/HkDateTag";
|
|
558
559
|
export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
|
|
559
560
|
export { default as HAuthMethodList } from "./components/HkAuthMethodList";
|
|
560
561
|
export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
|
|
@@ -836,6 +837,7 @@ export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
|
|
|
836
837
|
export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
|
|
837
838
|
export { HkThemePanelContent as HkThemePanelContent } from "./components/HkThemePanelContent";
|
|
838
839
|
export { HkSolarLocationSetting as HkSolarLocationSetting } from "./components/HkSolarLocationSetting";
|
|
840
|
+
export { HkDateTag as HkDateTag } from "./components/HkDateTag";
|
|
839
841
|
export { HkAuthCard as HkAuthCard } from "./components/HkAuthCard";
|
|
840
842
|
export { HkSignInCard as HkSignInCard } from "./components/HkSignInCard";
|
|
841
843
|
export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";
|