@celestia-island/hikari 0.55.11 → 0.55.13
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/DemoApp.tsx +2 -1
- package/src/components/HkAdminHeader.tsx +4 -5
- package/src/components/HkAdminTablePage.tsx +1 -1
- package/src/components/HkButton.test.tsx +2 -2
- package/src/components/HkButton.tsx +45 -1
- package/src/components/HkEmptyState.scss +7 -1
- package/src/components/HkEmptyState.surface.contract.test.ts +63 -0
- package/src/components/HkEmptyState.test.tsx +12 -0
- package/src/components/HkEmptyState.tsx +7 -0
- package/src/components/HkInput.password.test.tsx +478 -64
- package/src/components/HkInput.tsx +6 -3
- package/src/components/HkPasswordSurface.scss +3 -2
- package/src/components/HkPasswordSurface.tsx +134 -18
- package/src/components/HkSelectionGrid.scss +13 -0
- package/src/components/HkSelectionGrid.test.tsx +49 -0
- package/src/components/HkSelectionGrid.tsx +25 -1
- package/src/components/revealKinematogram.test.ts +121 -0
- package/src/components/revealKinematogram.ts +293 -0
- package/src/runtime/animationBus.test.ts +18 -1
- package/src/runtime/animationBus.ts +9 -0
- package/src/styles/admin-tokens.scss +4 -22
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -537,13 +537,14 @@ export default defineComponent({
|
|
|
537
537
|
<h2>HEmptyState</h2>
|
|
538
538
|
<div class="col">
|
|
539
539
|
<HEmptyState
|
|
540
|
+
boxed
|
|
540
541
|
title="No items"
|
|
541
542
|
description="Nothing to show here yet."
|
|
542
543
|
v-slots={{ action: () => <HButton size="sm">Create item</HButton> }}
|
|
543
544
|
/>
|
|
544
545
|
<HEmptyState
|
|
545
546
|
title="Nothing found"
|
|
546
|
-
description="
|
|
547
|
+
description="Bare by default — the host (card/modal/panel) provides the surface; pass boxed when the empty state stands on the page alone."
|
|
547
548
|
icon={PackageOpen}
|
|
548
549
|
/>
|
|
549
550
|
<HEmptyState
|
|
@@ -187,6 +187,10 @@ export const HkAdminHeader = defineComponent({
|
|
|
187
187
|
aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
|
|
188
188
|
onClick={onAvatarClick}
|
|
189
189
|
>
|
|
190
|
+
{/* Deliberately NO hover veil here: the trigger opens the user
|
|
191
|
+
* MENU (the avatar edit lives one row down as the Camera
|
|
192
|
+
* item), so a camera overlay would promise click-to-change
|
|
193
|
+
* and lie (user report 2026-09-16). */}
|
|
190
194
|
{props.avatarUrl && !avatarFailed.value ? (
|
|
191
195
|
<img
|
|
192
196
|
src={props.avatarUrl}
|
|
@@ -204,11 +208,6 @@ export const HkAdminHeader = defineComponent({
|
|
|
204
208
|
{props.username?.charAt(0).toUpperCase() || "?"}
|
|
205
209
|
</span>
|
|
206
210
|
)}
|
|
207
|
-
{props.avatarAction === "menu" && (
|
|
208
|
-
<div class="s-admin-header-avatar-veil">
|
|
209
|
-
<Camera size={10} class="s-admin-header-veil-icon" />
|
|
210
|
-
</div>
|
|
211
|
-
)}
|
|
212
211
|
</button>
|
|
213
212
|
{/* WHERE am I — the open view's title, not the nickname. The
|
|
214
213
|
1.5 line-height (in the class) keeps descenders (g, y, p)
|
|
@@ -53,7 +53,7 @@ export const HkAdminTablePage = defineComponent({
|
|
|
53
53
|
) : props.loading && !props.rows.length ? (
|
|
54
54
|
<HSpinner center />
|
|
55
55
|
) : !props.rows.length ? (
|
|
56
|
-
<HEmptyState title={emptyTitle} description={props.emptyDescription} />
|
|
56
|
+
<HEmptyState boxed title={emptyTitle} description={props.emptyDescription} />
|
|
57
57
|
) : (
|
|
58
58
|
<HCard padded={false}>
|
|
59
59
|
<HTable columns={props.columns} rows={props.rows} rowKey={props.rowKey}>
|
|
@@ -104,7 +104,7 @@ describe("HkButton click guard", () => {
|
|
|
104
104
|
|
|
105
105
|
it("clears the loading state back to a clickable button (aria-busy drops off)", () => {
|
|
106
106
|
const busy = mount(h(HkButton, { loading: true }, () => "x"));
|
|
107
|
-
expect(busy.querySelector(".hk-
|
|
107
|
+
expect(busy.querySelector(".hk-btn-ring")).not.toBeNull();
|
|
108
108
|
const idle = mount(h(HkButton, () => "x"));
|
|
109
109
|
const btn = button(idle);
|
|
110
110
|
expect(idle.querySelector(".hk-spinner")).toBeNull();
|
|
@@ -123,7 +123,7 @@ describe("HkButton content slots", () => {
|
|
|
123
123
|
// Loading swaps the icon out for the spinner — never both.
|
|
124
124
|
const busy = mount(h(HkButton, { icon: "close", loading: true }, () => "x"));
|
|
125
125
|
expect(busy.querySelector(".hk-btn-icon")).toBeNull();
|
|
126
|
-
expect(busy.querySelector(".hk-
|
|
126
|
+
expect(busy.querySelector(".hk-btn-ring")).not.toBeNull();
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
it("renders the suffix icon slot", () => {
|
|
@@ -66,7 +66,51 @@ export default defineComponent({
|
|
|
66
66
|
aria-busy={props.loading || undefined}
|
|
67
67
|
onClick={(e) => emit("click", e)}
|
|
68
68
|
>
|
|
69
|
-
{props.loading ?
|
|
69
|
+
{props.loading ? (
|
|
70
|
+
/* Loading ring as inline SVG + SMIL: renders identically in
|
|
71
|
+
WebView2 hosts where CSS-border rings got lost to border-
|
|
72
|
+
color pipelines or where the animation context suspends CSS
|
|
73
|
+
animations in rAF-starved background windows (user report
|
|
74
|
+
2026-09-16: an empty gap where the login ring should be).
|
|
75
|
+
Stroke follows currentcolor; SMIL rotates independently of
|
|
76
|
+
the CSS animation suspension switch. */
|
|
77
|
+
<svg
|
|
78
|
+
class="hk-btn-ring"
|
|
79
|
+
viewBox="0 0 16 16"
|
|
80
|
+
width="14"
|
|
81
|
+
height="14"
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
>
|
|
84
|
+
<circle
|
|
85
|
+
cx="8"
|
|
86
|
+
cy="8"
|
|
87
|
+
r="6.5"
|
|
88
|
+
fill="none"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
stroke-opacity="0.3"
|
|
91
|
+
stroke-width="1.8"
|
|
92
|
+
/>
|
|
93
|
+
<circle
|
|
94
|
+
cx="8"
|
|
95
|
+
cy="8"
|
|
96
|
+
r="6.5"
|
|
97
|
+
fill="none"
|
|
98
|
+
stroke="currentColor"
|
|
99
|
+
stroke-width="1.8"
|
|
100
|
+
stroke-linecap="round"
|
|
101
|
+
stroke-dasharray="30 11"
|
|
102
|
+
>
|
|
103
|
+
<animateTransform
|
|
104
|
+
attributeName="transform"
|
|
105
|
+
type="rotate"
|
|
106
|
+
from="0 8 8"
|
|
107
|
+
to="360 8 8"
|
|
108
|
+
dur="0.7s"
|
|
109
|
+
repeatCount="indefinite"
|
|
110
|
+
/>
|
|
111
|
+
</circle>
|
|
112
|
+
</svg>
|
|
113
|
+
) : null}
|
|
70
114
|
{!props.loading && props.icon ? (
|
|
71
115
|
<span class="hk-btn-icon">
|
|
72
116
|
<HIcon name={props.icon} size={16} />
|
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
justify-content: center;
|
|
6
6
|
padding: var(--space-48, 3rem) var(--space-16, 1rem);
|
|
7
7
|
text-align: center;
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* The component's own surface. Opt-in (`boxed`): the host — card, modal,
|
|
12
|
+
panel — usually already provides a surface, and an always-on box turned
|
|
13
|
+
every in-card empty state into a nested placeholder layer. */
|
|
14
|
+
.hk-empty-state--boxed {
|
|
8
15
|
background: var(--hk-empty-bg, var(--hi-color-surface, rgba(240,244,248,0.95)));
|
|
9
16
|
backdrop-filter: blur(var(--blur-sm, 8px));
|
|
10
17
|
border: 1px solid var(--hi-color-border, rgba(100,100,100,0.06));
|
|
11
18
|
border-radius: var(--radius-md, 0.5rem);
|
|
12
|
-
box-sizing: border-box;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
.hk-empty-icon {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract: the empty state's surface is OPT-IN (`boxed`). The
|
|
3
|
+
* base `.hk-empty-state` class must stay a bare layout block — the four
|
|
4
|
+
* surface declarations (background, blur, border, radius) live only in
|
|
5
|
+
* `.hk-empty-state--boxed`. Pinned because every in-card empty state in
|
|
6
|
+
* the family reads as a second nested placeholder layer the moment the
|
|
7
|
+
* surface creeps back into the base class (hikari #567); a class-name
|
|
8
|
+
* test alone cannot catch that, the compiled contract lives here.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
const SURFACE_PROPERTIES = [
|
|
18
|
+
"background:",
|
|
19
|
+
"backdrop-filter:",
|
|
20
|
+
"border:",
|
|
21
|
+
"border-radius:",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
/** Extract one top-level rule block's body (`{ ... }`) by selector. */
|
|
25
|
+
function ruleBody(css: string, selector: string): string {
|
|
26
|
+
const start = css.indexOf(`${selector} {`);
|
|
27
|
+
expect(start, `rule ${selector} present`).toBeGreaterThanOrEqual(0);
|
|
28
|
+
const open = css.indexOf("{", start);
|
|
29
|
+
const close = css.indexOf("}", open);
|
|
30
|
+
return css.slice(open + 1, close);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("HkEmptyState surface contract", () => {
|
|
34
|
+
const css = readFileSync(join(here, "HkEmptyState.scss"), "utf-8");
|
|
35
|
+
|
|
36
|
+
it("keeps the base class free of surface declarations", () => {
|
|
37
|
+
const base = ruleBody(css, ".hk-empty-state");
|
|
38
|
+
for (const property of SURFACE_PROPERTIES) {
|
|
39
|
+
expect(base).not.toContain(property);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("declares the full surface on the opt-in --boxed rule only", () => {
|
|
44
|
+
const boxed = ruleBody(css, ".hk-empty-state--boxed");
|
|
45
|
+
for (const property of SURFACE_PROPERTIES) {
|
|
46
|
+
expect(boxed).toContain(property);
|
|
47
|
+
}
|
|
48
|
+
// The surface is the ONLY rule allowed to paint: with the --boxed rule
|
|
49
|
+
// (and comments, whose prose may name the properties) removed, no
|
|
50
|
+
// declaration of a surface property may remain anywhere in the sheet.
|
|
51
|
+
// (The sr-only reset's `border: 0` and box-sizing's `border-box` are
|
|
52
|
+
// not surfaces — the assertions match the painted forms.)
|
|
53
|
+
const commentFree = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
54
|
+
const withoutBoxed = commentFree.replace(
|
|
55
|
+
/\.hk-empty-state--boxed\s*\{[^}]*\}/g,
|
|
56
|
+
"",
|
|
57
|
+
);
|
|
58
|
+
expect(withoutBoxed).not.toContain("background:");
|
|
59
|
+
expect(withoutBoxed).not.toContain("backdrop-filter:");
|
|
60
|
+
expect(withoutBoxed).not.toContain("border: 1px");
|
|
61
|
+
expect(withoutBoxed).not.toContain("border-radius:");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -135,4 +135,16 @@ describe("HkEmptyState", () => {
|
|
|
135
135
|
expect(cls).toContain("hk-empty-state--fill");
|
|
136
136
|
expect(cls).not.toContain("hk-empty-state--page");
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
it("does not draw its own surface by default (the host provides it)", () => {
|
|
140
|
+
const c = mount(h(HkEmptyState, { title: "x" }));
|
|
141
|
+
const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
|
|
142
|
+
expect(cls).not.toContain("hk-empty-state--boxed");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("opt-in boxed restores the surface modifier", () => {
|
|
146
|
+
const c = mount(h(HkEmptyState, { title: "x", boxed: true }));
|
|
147
|
+
const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
|
|
148
|
+
expect(cls).toContain("hk-empty-state--boxed");
|
|
149
|
+
});
|
|
138
150
|
});
|
|
@@ -19,6 +19,12 @@ export default defineComponent({
|
|
|
19
19
|
type: String as PropType<"page" | "fill">,
|
|
20
20
|
default: "page",
|
|
21
21
|
},
|
|
22
|
+
/** Draw the component's own surface (background, border, blur). Off by
|
|
23
|
+
* default: most hosts (cards, modals, panels) already provide a
|
|
24
|
+
* surface, and a boxed empty state inside them reads as a second
|
|
25
|
+
* nested placeholder layer. Set this only when the empty state stands
|
|
26
|
+
* directly on the page with no outer frame of its own. */
|
|
27
|
+
boxed: { type: Boolean, default: false },
|
|
22
28
|
},
|
|
23
29
|
setup(props, { slots }) {
|
|
24
30
|
const { t } = useI18n();
|
|
@@ -28,6 +34,7 @@ export default defineComponent({
|
|
|
28
34
|
"hk-empty-state",
|
|
29
35
|
props.fit === "fill" ? "hk-empty-state--fill" : "hk-empty-state--page",
|
|
30
36
|
props.loading && "hk-empty-state--loading",
|
|
37
|
+
props.boxed && "hk-empty-state--boxed",
|
|
31
38
|
]}
|
|
32
39
|
role={props.loading ? "status" : undefined}
|
|
33
40
|
aria-busy={props.loading ? "true" : undefined}
|