@celestia-island/hikari 0.38.2 → 0.40.0
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 +25 -5
- package/src/components/HkEmptyState.scss +42 -0
- package/src/components/HkEmptyState.test.tsx +118 -0
- package/src/components/HkEmptyState.tsx +65 -27
- package/src/components/HkTitleBar.scss +128 -0
- package/src/components/HkTitleBar.tsx +132 -0
- package/src/index.ts +1 -0
- package/src/styles/theme/channels.scss +162 -0
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// house rule: no SFCs in the tree). Render-only showcase of every exported
|
|
3
3
|
// component; state is local and throwaway.
|
|
4
4
|
import { computed, defineComponent, ref } from "vue";
|
|
5
|
+
import { PackageOpen } from "lucide-vue-next";
|
|
5
6
|
import { HIKARI_FONT_MONO } from "./theme/fontContext";
|
|
6
7
|
import {
|
|
7
8
|
HButton, HIconButton, HTooltip, HBadge, HTag, HIcon, HSpinner,
|
|
@@ -360,11 +361,30 @@ export default defineComponent({
|
|
|
360
361
|
|
|
361
362
|
<section>
|
|
362
363
|
<h2>HEmptyState</h2>
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
364
|
+
<div class="col">
|
|
365
|
+
<HEmptyState
|
|
366
|
+
title="No items"
|
|
367
|
+
description="Nothing to show here yet."
|
|
368
|
+
v-slots={{ action: () => <HButton size="sm">Create item</HButton> }}
|
|
369
|
+
/>
|
|
370
|
+
<HEmptyState
|
|
371
|
+
title="Nothing found"
|
|
372
|
+
description="Search returned no matches — try a different query."
|
|
373
|
+
icon={PackageOpen}
|
|
374
|
+
/>
|
|
375
|
+
<HEmptyState
|
|
376
|
+
title="Custom icon slot"
|
|
377
|
+
v-slots={{ icon: () => <HIcon name="inbox" size={48} /> }}
|
|
378
|
+
/>
|
|
379
|
+
<HEmptyState loading />
|
|
380
|
+
<div style="height:220px">
|
|
381
|
+
<HEmptyState
|
|
382
|
+
title="Panel empty"
|
|
383
|
+
description={'fit="fill" stretches the card to its host container.'}
|
|
384
|
+
fit="fill"
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
368
388
|
</section>
|
|
369
389
|
|
|
370
390
|
<section>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
backdrop-filter: blur(8px);
|
|
10
10
|
border: 1px solid var(--hi-color-border, rgba(0, 0, 0, 0.06));
|
|
11
11
|
border-radius: var(--hi-radius-md, 0.5rem);
|
|
12
|
+
box-sizing: border-box;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
.hk-empty-icon {
|
|
@@ -40,3 +41,44 @@
|
|
|
40
41
|
.hk-empty-action {
|
|
41
42
|
margin-top: var(--hi-space-4, 0.25rem);
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
/* fit="page" (default): full width on mobile — the page supplies the outer
|
|
46
|
+
padding — and centered. */
|
|
47
|
+
.hk-empty-state--page {
|
|
48
|
+
width: 100%;
|
|
49
|
+
margin-inline: auto;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Desktop: the card must not span the page — cap around 30vw with a sane
|
|
53
|
+
floor/cap (320px … 560px) so tiny and 4K windows stay usable. */
|
|
54
|
+
@media (min-width: 48rem) {
|
|
55
|
+
.hk-empty-state--page {
|
|
56
|
+
max-width: clamp(20rem, 30vw, 35rem);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* fit="fill": stretch to the host container (panels, split views). */
|
|
61
|
+
.hk-empty-state--fill {
|
|
62
|
+
width: 100%;
|
|
63
|
+
height: 100%;
|
|
64
|
+
min-height: 100%;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Loading variant keeps a comfortable spinner area in short hosts. */
|
|
68
|
+
.hk-empty-state--loading {
|
|
69
|
+
min-height: 10rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Visually hidden but announced: gives the loading status region text
|
|
73
|
+
content so screen readers have something to say when it appears. */
|
|
74
|
+
.hk-empty-sr-only {
|
|
75
|
+
position: absolute;
|
|
76
|
+
width: 1px;
|
|
77
|
+
height: 1px;
|
|
78
|
+
padding: 0;
|
|
79
|
+
margin: -1px;
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
clip: rect(0, 0, 0, 0);
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
border: 0;
|
|
84
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkEmptyState from "./HkEmptyState";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/* Functional icon stub rendering a node with a distinguishing class so the
|
|
25
|
+
tests can assert the icon prop component actually mounted. */
|
|
26
|
+
const StubIcon = defineComponent({
|
|
27
|
+
name: "StubIcon",
|
|
28
|
+
setup() {
|
|
29
|
+
return () => h("i", { class: "stub-empty-icon", "data-stub-icon": "true" });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("HkEmptyState", () => {
|
|
34
|
+
it("renders title and description", () => {
|
|
35
|
+
const c = mount(
|
|
36
|
+
h(HkEmptyState, { title: "No items", description: "Nothing here yet." }),
|
|
37
|
+
);
|
|
38
|
+
expect((c.querySelector(".hk-empty-title") as HTMLElement).textContent).toBe(
|
|
39
|
+
"No items",
|
|
40
|
+
);
|
|
41
|
+
expect((c.querySelector(".hk-empty-desc") as HTMLElement).textContent).toBe(
|
|
42
|
+
"Nothing here yet.",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders action slot content inside .hk-empty-action", () => {
|
|
47
|
+
const c = mount(
|
|
48
|
+
h(
|
|
49
|
+
HkEmptyState,
|
|
50
|
+
{ title: "No items" },
|
|
51
|
+
{ action: () => h("button", { class: "stub-action" }, "Retry") },
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
const btn = c.querySelector(".hk-empty-action .stub-action") as HTMLElement;
|
|
55
|
+
expect(btn).not.toBeNull();
|
|
56
|
+
expect(btn.textContent).toBe("Retry");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders the icon prop component in the icon well", () => {
|
|
60
|
+
const c = mount(h(HkEmptyState, { title: "No items", icon: StubIcon }));
|
|
61
|
+
expect(c.querySelector(".hk-empty-icon .stub-empty-icon")).not.toBeNull();
|
|
62
|
+
// The prop icon replaces the default inline inbox svg.
|
|
63
|
+
expect(c.querySelector(".hk-empty-icon svg polyline")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("prefers the icon slot over the icon prop", () => {
|
|
67
|
+
const c = mount(
|
|
68
|
+
h(
|
|
69
|
+
HkEmptyState,
|
|
70
|
+
{ title: "No items", icon: StubIcon },
|
|
71
|
+
{ icon: () => h("b", { class: "slot-icon" }) },
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
expect(c.querySelector(".hk-empty-icon .slot-icon")).not.toBeNull();
|
|
75
|
+
expect(c.querySelector(".stub-empty-icon")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders the default inline svg when no icon prop and no slot", () => {
|
|
79
|
+
const c = mount(h(HkEmptyState, { title: "No items" }));
|
|
80
|
+
expect(c.querySelector(".hk-empty-icon svg polyline")).not.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("loading variant renders only a spinner with status semantics", () => {
|
|
84
|
+
const c = mount(
|
|
85
|
+
h(
|
|
86
|
+
HkEmptyState,
|
|
87
|
+
{ title: "No items", description: "desc", loading: true },
|
|
88
|
+
{ action: () => h("button", "x") },
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const root = c.querySelector(".hk-empty-state") as HTMLElement;
|
|
92
|
+
expect(root.className).toContain("hk-empty-state--loading");
|
|
93
|
+
expect(root.getAttribute("role")).toBe("status");
|
|
94
|
+
expect(root.getAttribute("aria-busy")).toBe("true");
|
|
95
|
+
expect(c.querySelector(".hk-spinner")).not.toBeNull();
|
|
96
|
+
// The status region must carry text content for screen readers.
|
|
97
|
+
const sr = c.querySelector(".hk-empty-sr-only");
|
|
98
|
+
expect(sr).not.toBeNull();
|
|
99
|
+
expect((sr as HTMLElement).textContent).toBe("Loading");
|
|
100
|
+
expect(c.querySelector(".hk-empty-title")).toBeNull();
|
|
101
|
+
expect(c.querySelector(".hk-empty-desc")).toBeNull();
|
|
102
|
+
expect(c.querySelector(".hk-empty-action")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("defaults to the page fit modifier", () => {
|
|
106
|
+
const c = mount(h(HkEmptyState, { title: "x" }));
|
|
107
|
+
const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
|
|
108
|
+
expect(cls).toContain("hk-empty-state--page");
|
|
109
|
+
expect(cls).not.toContain("hk-empty-state--fill");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("maps fit=fill to the fill modifier", () => {
|
|
113
|
+
const c = mount(h(HkEmptyState, { title: "x", fit: "fill" }));
|
|
114
|
+
const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
|
|
115
|
+
expect(cls).toContain("hk-empty-state--fill");
|
|
116
|
+
expect(cls).not.toContain("hk-empty-state--page");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,40 +1,78 @@
|
|
|
1
|
-
import { defineComponent } from "vue";
|
|
1
|
+
import { defineComponent, h, type Component, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
+
import { useI18n } from "../i18n/context";
|
|
4
|
+
import HSpinner from "./HkSpinner";
|
|
3
5
|
import "./HkEmptyState.scss";
|
|
4
6
|
|
|
5
7
|
export default defineComponent({
|
|
6
8
|
name: "HkEmptyState",
|
|
7
9
|
props: {
|
|
8
|
-
|
|
10
|
+
/** Heading text. Optional — the loading variant renders without a title. */
|
|
11
|
+
title: { type: String, default: undefined },
|
|
9
12
|
description: { type: String, default: undefined },
|
|
13
|
+
/** Icon component (lucide-vue-next style); rendered via h() at size 32. */
|
|
14
|
+
icon: { type: Object as PropType<Component>, default: undefined },
|
|
15
|
+
/** Loading variant: only a centered spinner, exposed as a status region. */
|
|
16
|
+
loading: { type: Boolean, default: false },
|
|
17
|
+
/** page = bounded, centered card on desktop; fill = stretch to the host. */
|
|
18
|
+
fit: {
|
|
19
|
+
type: String as PropType<"page" | "fill">,
|
|
20
|
+
default: "page",
|
|
21
|
+
},
|
|
10
22
|
},
|
|
11
23
|
setup(props, { slots }) {
|
|
24
|
+
const { t } = useI18n();
|
|
12
25
|
return () => (
|
|
13
|
-
<div
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
<div
|
|
27
|
+
class={[
|
|
28
|
+
"hk-empty-state",
|
|
29
|
+
props.fit === "fill" ? "hk-empty-state--fill" : "hk-empty-state--page",
|
|
30
|
+
props.loading && "hk-empty-state--loading",
|
|
31
|
+
]}
|
|
32
|
+
role={props.loading ? "status" : undefined}
|
|
33
|
+
aria-busy={props.loading ? "true" : undefined}
|
|
34
|
+
>
|
|
35
|
+
{props.loading ? (
|
|
36
|
+
<>
|
|
37
|
+
{/* The status region needs text content, or screen readers
|
|
38
|
+
announce an empty live region when the spinner appears. */}
|
|
39
|
+
<span class="hk-empty-sr-only">
|
|
40
|
+
{t("hikari::emptyState.loading", "Loading")}
|
|
41
|
+
</span>
|
|
42
|
+
<HSpinner center />
|
|
43
|
+
</>
|
|
44
|
+
) : (
|
|
45
|
+
<>
|
|
46
|
+
<div class="hk-empty-icon">
|
|
47
|
+
{slots.icon ? (
|
|
48
|
+
slots.icon()
|
|
49
|
+
) : props.icon ? (
|
|
50
|
+
h(props.icon, { size: 32, "aria-hidden": true })
|
|
51
|
+
) : (
|
|
52
|
+
<svg
|
|
53
|
+
viewBox="0 0 24 24"
|
|
54
|
+
fill="none"
|
|
55
|
+
stroke="currentColor"
|
|
56
|
+
stroke-width="1.5"
|
|
57
|
+
stroke-linecap="round"
|
|
58
|
+
stroke-linejoin="round"
|
|
59
|
+
width="48"
|
|
60
|
+
height="48"
|
|
61
|
+
>
|
|
62
|
+
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
|
63
|
+
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
64
|
+
</svg>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
{props.title ? <p class="hk-empty-title">{props.title}</p> : null}
|
|
68
|
+
{props.description ? (
|
|
69
|
+
<p class="hk-empty-desc">{props.description}</p>
|
|
70
|
+
) : null}
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
{!props.loading && (
|
|
74
|
+
<div class="hk-empty-action">{slots.action?.()}</div>
|
|
75
|
+
)}
|
|
38
76
|
</div>
|
|
39
77
|
);
|
|
40
78
|
},
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// HkTitleBar — hybrid-shell caption bar.
|
|
2
|
+
//
|
|
3
|
+
// Colors ride the theme channels (--color-*) so the bar follows whatever
|
|
4
|
+
// preset/mode the host app is in. Drag is pure CSS app-region (Tauri and
|
|
5
|
+
// Electron both honor it); interactive children opt out with no-drag.
|
|
6
|
+
// Text selection is chrome-disabled across the whole bar.
|
|
7
|
+
|
|
8
|
+
.hk-titlebar {
|
|
9
|
+
--hk-tb-height: 32px;
|
|
10
|
+
--hk-tb-bg: color-mix(in srgb, rgb(var(--color-surface, 240 244 248)) 80%, transparent);
|
|
11
|
+
--hk-tb-border: rgb(var(--color-border, 100 100 100) / 0.25);
|
|
12
|
+
--hk-tb-fg: rgb(var(--color-text-secondary, 102 102 102));
|
|
13
|
+
--hk-tb-fg-strong: rgb(var(--color-text, 30 30 30));
|
|
14
|
+
--hk-tb-hover: rgb(var(--color-primary, 122 162 247) / 0.12);
|
|
15
|
+
--hk-tb-active: rgb(var(--color-primary, 122 162 247) / 0.2);
|
|
16
|
+
--hk-tb-focus: rgb(var(--color-primary, 122 162 247) / 0.6);
|
|
17
|
+
--hk-tb-close-hover: #e81123;
|
|
18
|
+
--hk-tb-close-active: #f1707a;
|
|
19
|
+
--hk-tb-radius-close: 0 8px 0 0;
|
|
20
|
+
|
|
21
|
+
position: fixed;
|
|
22
|
+
top: 0;
|
|
23
|
+
left: 0;
|
|
24
|
+
right: 0;
|
|
25
|
+
height: var(--hk-tb-height);
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
z-index: var(--z-titlebar, 100001);
|
|
29
|
+
user-select: none;
|
|
30
|
+
-webkit-user-select: none;
|
|
31
|
+
background: var(--hk-tb-bg);
|
|
32
|
+
backdrop-filter: blur(16px);
|
|
33
|
+
border-bottom: 1px solid var(--hk-tb-border);
|
|
34
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
35
|
+
-webkit-app-region: drag;
|
|
36
|
+
app-region: drag;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.hk-titlebar-title {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 8px;
|
|
43
|
+
padding-left: 12px;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
cursor: default;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.hk-titlebar-icon {
|
|
49
|
+
width: 16px;
|
|
50
|
+
height: 16px;
|
|
51
|
+
display: block;
|
|
52
|
+
pointer-events: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.hk-titlebar-title-text {
|
|
56
|
+
font-size: 11px;
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
letter-spacing: 0.04em;
|
|
59
|
+
color: var(--hk-tb-fg-strong);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.hk-titlebar-subtitle {
|
|
63
|
+
font-size: 10px;
|
|
64
|
+
color: var(--hk-tb-fg);
|
|
65
|
+
opacity: 0.7;
|
|
66
|
+
padding-left: 8px;
|
|
67
|
+
white-space: nowrap;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.hk-titlebar-spacer {
|
|
71
|
+
flex: 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.hk-titlebar-actions {
|
|
75
|
+
-webkit-app-region: no-drag;
|
|
76
|
+
app-region: no-drag;
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
height: 100%;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.hk-titlebar-btn {
|
|
83
|
+
width: 46px;
|
|
84
|
+
height: var(--hk-tb-height);
|
|
85
|
+
border: none;
|
|
86
|
+
background: transparent;
|
|
87
|
+
color: var(--hk-tb-fg);
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
transition: background-color 0.12s ease, color 0.12s ease;
|
|
93
|
+
outline: none;
|
|
94
|
+
padding: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.hk-titlebar-btn:hover {
|
|
98
|
+
background: var(--hk-tb-hover);
|
|
99
|
+
color: var(--hk-tb-fg-strong);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.hk-titlebar-btn:active {
|
|
103
|
+
background: var(--hk-tb-active);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Only the rightmost (close) button carries the window's top-right
|
|
107
|
+
corner radius; the other captions highlight as plain rectangles. */
|
|
108
|
+
.hk-titlebar-btn-close {
|
|
109
|
+
border-radius: 0 8px 0 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.hk-titlebar-btn-close:hover {
|
|
113
|
+
background: var(--hk-tb-close-hover);
|
|
114
|
+
color: #fff;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.hk-titlebar-btn-close:active {
|
|
118
|
+
background: var(--hk-tb-close-active);
|
|
119
|
+
color: #fff;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.hk-titlebar-btn:focus-visible {
|
|
123
|
+
outline: 2px solid var(--hk-tb-focus);
|
|
124
|
+
outline-offset: -2px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Host apps offset their own root container below the bar:
|
|
128
|
+
#app { position: absolute; top: var(--hk-tb-height); … } */
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkTitleBar.scss";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HkTitleBar — hybrid-shell window caption bar.
|
|
7
|
+
*
|
|
8
|
+
* A pure visual + event component: it renders the bar (app icon, title,
|
|
9
|
+
* subtitle, draggable surface, right-side caption buttons) and EMITS
|
|
10
|
+
* `minimize` / `toggle-maximize` / `close` / custom-button clicks. It
|
|
11
|
+
* never touches a shell API itself — the host (Tauri, Electron, …) wires
|
|
12
|
+
* those events to its own window controls, keeping hikari dependency-free.
|
|
13
|
+
*
|
|
14
|
+
* Dragging is pure CSS (`app-region: drag` on the bar, `no-drag` on the
|
|
15
|
+
* interactive children) — supported by Tauri (WebView2/WKWebView/WebKitGTK)
|
|
16
|
+
* and Electron alike; no JS drag logic needed.
|
|
17
|
+
*
|
|
18
|
+
* Text selection is disabled across the bar (captions are chrome, not
|
|
19
|
+
* content).
|
|
20
|
+
*
|
|
21
|
+
* Buttons are selective: `showMinimize` / `showMaximize` / `showClose`
|
|
22
|
+
* (defaults true/true/true) and `customActions` render extra icon buttons
|
|
23
|
+
* to the LEFT of minimize, each emitting `action` with its id.
|
|
24
|
+
*
|
|
25
|
+
* The maximized state is data-driven: the host passes `maximized` and the
|
|
26
|
+
* component swaps the maximize/restore glyph — no shell probing inside.
|
|
27
|
+
*/
|
|
28
|
+
export default defineComponent({
|
|
29
|
+
name: "HkTitleBar",
|
|
30
|
+
props: {
|
|
31
|
+
title: { type: String, default: "" },
|
|
32
|
+
subtitle: { type: String, default: "" },
|
|
33
|
+
/** App icon rendered left of the title (img src or inline node). */
|
|
34
|
+
icon: { type: String, default: "" },
|
|
35
|
+
maximized: { type: Boolean, default: false },
|
|
36
|
+
showMinimize: { type: Boolean, default: true },
|
|
37
|
+
showMaximize: { type: Boolean, default: true },
|
|
38
|
+
showClose: { type: Boolean, default: true },
|
|
39
|
+
/** Extra icon buttons rendered left of minimize. */
|
|
40
|
+
customActions: {
|
|
41
|
+
type: Array as () => { id: string; label: string; icon?: unknown }[],
|
|
42
|
+
default: () => [],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
emits: {
|
|
46
|
+
minimize: () => true,
|
|
47
|
+
"toggle-maximize": () => true,
|
|
48
|
+
close: () => true,
|
|
49
|
+
/** A custom action button was clicked (id from `customActions`). */
|
|
50
|
+
action: (_id: string) => true,
|
|
51
|
+
},
|
|
52
|
+
setup(props, { emit, slots }) {
|
|
53
|
+
return () => (
|
|
54
|
+
<div class="hk-titlebar" data-drag-region>
|
|
55
|
+
{slots.left?.() ?? (
|
|
56
|
+
<span class="hk-titlebar-title">
|
|
57
|
+
{props.icon && <img class="hk-titlebar-icon" src={props.icon} alt="" />}
|
|
58
|
+
<span class="hk-titlebar-title-text">{props.title}</span>
|
|
59
|
+
{props.subtitle && (
|
|
60
|
+
<span class="hk-titlebar-subtitle">{props.subtitle}</span>
|
|
61
|
+
)}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
<span class="hk-titlebar-spacer" />
|
|
65
|
+
<div class="hk-titlebar-actions">
|
|
66
|
+
{slots.actions?.()}
|
|
67
|
+
{props.customActions.map((a) => (
|
|
68
|
+
<button
|
|
69
|
+
key={a.id}
|
|
70
|
+
type="button"
|
|
71
|
+
class="hk-titlebar-btn"
|
|
72
|
+
title={a.label}
|
|
73
|
+
aria-label={a.label}
|
|
74
|
+
onClick={(e: MouseEvent) => {
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
emit("action", a.id);
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{a.icon}
|
|
80
|
+
</button>
|
|
81
|
+
))}
|
|
82
|
+
{props.showMinimize && (
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
class="hk-titlebar-btn"
|
|
86
|
+
title="Minimize"
|
|
87
|
+
aria-label="Minimize"
|
|
88
|
+
onClick={(e: MouseEvent) => {
|
|
89
|
+
e.stopPropagation();
|
|
90
|
+
emit("minimize");
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M5 12h14" /></svg>
|
|
94
|
+
</button>
|
|
95
|
+
)}
|
|
96
|
+
{props.showMaximize && (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class="hk-titlebar-btn"
|
|
100
|
+
title={props.maximized ? "Restore" : "Maximize"}
|
|
101
|
+
aria-label={props.maximized ? "Restore" : "Maximize"}
|
|
102
|
+
onClick={(e: MouseEvent) => {
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
emit("toggle-maximize");
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{props.maximized ? (
|
|
108
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="8" width="13" height="13" rx="2" /><path d="M8 8V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-3" /></svg>
|
|
109
|
+
) : (
|
|
110
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="4" y="4" width="16" height="16" rx="2" /></svg>
|
|
111
|
+
)}
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
114
|
+
{props.showClose && (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
class="hk-titlebar-btn hk-titlebar-btn-close"
|
|
118
|
+
title="Close"
|
|
119
|
+
aria-label="Close"
|
|
120
|
+
onClick={(e: MouseEvent) => {
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
emit("close");
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -79,6 +79,7 @@ export { default as HWindowedItem } from "./components/HkWindowedItem";
|
|
|
79
79
|
export { default as HDateTimePicker } from "./components/HkDateTimePicker";
|
|
80
80
|
export { default as HDatePicker } from "./components/HkDatePicker";
|
|
81
81
|
export { default as HTimeline } from "./components/HkTimeline";
|
|
82
|
+
export { default as HTitleBar } from "./components/HkTitleBar";
|
|
82
83
|
export { default as HStepFlow } from "./components/HkStepFlow";
|
|
83
84
|
export type { StepFlowSlotProps } from "./components/HkStepFlow";
|
|
84
85
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Vendored from packages/vue/src/tokens.scss so npm registry installs can
|
|
2
|
+
// resolve it — the exports map only exposes ./styles/*, and consumers that
|
|
3
|
+
// build their own shell (non-chest webuis) need the --color-* channel
|
|
4
|
+
// defaults + resolved --hi-* aliases at CSS level, before the runtime
|
|
5
|
+
// (initTheme) injects inline overrides. Edit the upstream file, then
|
|
6
|
+
// re-copy; do not hand-edit here.
|
|
7
|
+
|
|
8
|
+
/* ── Syntax highlighting palette family: --code-* ───────────────────
|
|
9
|
+
* Token names consumed by hikari's highlighted-code surfaces (the
|
|
10
|
+
* HkToolBlock syntax layer and its JSON viewer, see HkToolBlock.scss).
|
|
11
|
+
* The VALUES are intentionally NOT defined here: the family is owned by
|
|
12
|
+
* the host theme, which maps each token to mode-aware colors. The
|
|
13
|
+
* canonical mapping (dark = One Dark, light = GitHub Light) lives in the
|
|
14
|
+
* celestia webui theme.scss; components reference the tokens with their
|
|
15
|
+
* original One Dark values as fallbacks so standalone consumers render
|
|
16
|
+
* unchanged. Extend this family only by adding a --code-* token with a
|
|
17
|
+
* fallback equal to the current visual.
|
|
18
|
+
* --code-keyword hljs-keyword / selector-tag (purple)
|
|
19
|
+
* --code-builtin hljs-built_in (purple → blue in light)
|
|
20
|
+
* --code-string hljs-string (green)
|
|
21
|
+
* --code-number hljs-number / literal / attr (orange → blue in light)
|
|
22
|
+
* --code-comment hljs-comment / doctag (grey, italic)
|
|
23
|
+
* --code-function hljs-title.function_ (blue)
|
|
24
|
+
* --code-type hljs-type / class title (yellow → green in light)
|
|
25
|
+
* --code-variable hljs-variable / template-var (red → orange in light)
|
|
26
|
+
*/
|
|
27
|
+
/* hikari color aliases — fallback values for theme tokens.
|
|
28
|
+
These CSS variables are used by hikari components (HkButton, HkCard,
|
|
29
|
+
HkInput, HkSelect, etc.) and are expected to be overridden by
|
|
30
|
+
consuming apps via initTheme() or custom theme CSS. */
|
|
31
|
+
:root {
|
|
32
|
+
--c-primary-faint: rgb(var(--color-primary, 122 162 247) / 5%);
|
|
33
|
+
--c-primary-subtle: rgb(var(--color-primary, 122 162 247) / 8%);
|
|
34
|
+
--c-primary-dim: rgb(var(--color-primary, 122 162 247) / 10%);
|
|
35
|
+
--c-primary-light: rgb(var(--color-primary, 122 162 247) / 15%);
|
|
36
|
+
--c-primary-overlay: rgb(var(--color-primary, 122 162 247) / 15%);
|
|
37
|
+
--c-primary-medium: rgb(var(--color-primary, 122 162 247) / 25%);
|
|
38
|
+
--c-primary-strong: rgb(var(--color-primary, 122 162 247) / 40%);
|
|
39
|
+
--c-primary-intense: rgb(var(--color-primary, 122 162 247) / 40%);
|
|
40
|
+
--c-error-dim: rgb(var(--color-error, 247 118 142) / 10%);
|
|
41
|
+
--c-error-overlay: rgb(var(--color-error, 247 118 142) / 20%);
|
|
42
|
+
--c-error-strong: rgb(var(--color-error, 247 118 142) / 50%);
|
|
43
|
+
--shadow-elevated: 0 8px 32px rgb(0 0 0 / 15%);
|
|
44
|
+
--shadow-dropdown: 0 4px 24px rgb(0 0 0 / 12%), 0 0 0 1px rgb(255 255 255 / 4%);
|
|
45
|
+
--shadow-button: 0 4px 14px rgb(var(--color-primary, 122 162 247) / 35%);
|
|
46
|
+
--shadow-button-danger: 0 4px 14px rgb(var(--color-error, 247 118 142) / 35%);
|
|
47
|
+
--shadow-focus: 0 0 0 3px rgb(var(--color-primary, 122 162 247) / 12%);
|
|
48
|
+
--shadow-focus-error: 0 0 0 3px rgb(var(--color-error, 247 118 142) / 12%);
|
|
49
|
+
--border-input: rgb(var(--color-border, 100 100 100) / 15%);
|
|
50
|
+
--border-faint: rgb(var(--color-border, 100 100 100) / 10%);
|
|
51
|
+
--border-subtle: rgb(var(--color-border, 100 100 100) / 12%);
|
|
52
|
+
|
|
53
|
+
/* Icon-button state bridge — HkIconButtonVars references this vocabulary
|
|
54
|
+
but nothing ever defined it, so every var() without an in-file fallback
|
|
55
|
+
was invalid at computed-value time: non-ghost variants had no active
|
|
56
|
+
background, focus rings and glow vanished, and one missing item even
|
|
57
|
+
invalidated the whole transition shorthand (all icon buttons lost their
|
|
58
|
+
hover/press animation). Defined once here as live expressions over the
|
|
59
|
+
theme triplets so they track whichever palette is active; the -dark
|
|
60
|
+
variants are the pressed-step of their base slot. */
|
|
61
|
+
--hi-icon-color: rgb(var(--color-text, 30 30 30));
|
|
62
|
+
--hi-icon-size-xs: 0.75rem;
|
|
63
|
+
--hi-icon-size-sm: 1rem;
|
|
64
|
+
--hi-icon-size-md: 1.25rem;
|
|
65
|
+
--hi-icon-size-lg: 1.5rem;
|
|
66
|
+
--hi-duration-instant: var(--duration-instant, 0.1s);
|
|
67
|
+
--hi-border-color-focus: rgb(var(--color-primary, 122 162 247));
|
|
68
|
+
--hi-glow-color: transparent;
|
|
69
|
+
--hi-bg-surface-dark: rgb(var(--color-muted, 108 108 108) / 12%);
|
|
70
|
+
--hi-color-primary-dark: color-mix(in srgb, rgb(var(--color-primary, 122 162 247)) 82%, #000);
|
|
71
|
+
--hi-color-secondary-dark: color-mix(in srgb, rgb(var(--color-secondary, 81 154 115)) 82%, #000);
|
|
72
|
+
--hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 200 50 50)) 82%, #000);
|
|
73
|
+
--hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 0 150 0)) 82%, #000);
|
|
74
|
+
--hi-color-text-on-secondary: rgb(var(--color-on-solid, 255 255 255));
|
|
75
|
+
--hi-color-text-on-danger: rgb(var(--color-on-solid, 255 255 255));
|
|
76
|
+
--hi-color-text-on-success: rgb(var(--color-on-solid, 255 255 255));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Static default light theme — renders correctly even without initTheme().
|
|
80
|
+
initTheme() overrides these at runtime via inline styles on
|
|
81
|
+
documentElement, which beat stylesheet :root declarations. */
|
|
82
|
+
:root {
|
|
83
|
+
--color-primary: 122 162 247;
|
|
84
|
+
--color-on-primary: 255 255 255;
|
|
85
|
+
--color-on-solid: 255 255 255;
|
|
86
|
+
--color-on-solid-text: 255 255 255;
|
|
87
|
+
--color-on-solid-icon: 255 255 255;
|
|
88
|
+
--color-secondary: 81 154 115;
|
|
89
|
+
--color-accent: 247 181 0;
|
|
90
|
+
--color-text: 30 30 30;
|
|
91
|
+
--color-text-secondary: 102 102 102;
|
|
92
|
+
--color-text-tertiary: 140 140 140;
|
|
93
|
+
--color-muted: 108 108 108;
|
|
94
|
+
--color-border: 100 100 100;
|
|
95
|
+
--color-focused-border: 122 162 247;
|
|
96
|
+
--color-background: 214 236 240;
|
|
97
|
+
--color-surface: 240 244 248;
|
|
98
|
+
--color-success: 14 184 64;
|
|
99
|
+
--color-error: 247 118 142;
|
|
100
|
+
--color-warning: 238 214 119;
|
|
101
|
+
--color-info: 106 190 214;
|
|
102
|
+
|
|
103
|
+
--hi-color-primary: rgb(var(--color-primary));
|
|
104
|
+
--hi-color-secondary: rgb(var(--color-secondary));
|
|
105
|
+
--hi-color-surface: rgb(var(--color-surface));
|
|
106
|
+
--hi-color-background: rgb(var(--color-background));
|
|
107
|
+
--hi-color-border: rgb(var(--color-border));
|
|
108
|
+
--hi-color-text-primary: rgb(var(--color-text));
|
|
109
|
+
--hi-color-text-secondary: rgb(var(--color-text-secondary));
|
|
110
|
+
--hi-color-muted: rgb(var(--color-muted));
|
|
111
|
+
--hi-color-on-primary: rgb(var(--color-on-primary));
|
|
112
|
+
/* Deprecated in-tree: luminance-computed contrast, kept only for external
|
|
113
|
+
consumers. In-tree components use the on-solid slots instead. */
|
|
114
|
+
--hi-color-text-on-primary: rgb(var(--color-on-primary));
|
|
115
|
+
--hi-color-text-on-solid: rgb(var(--color-on-solid-text, 255 255 255));
|
|
116
|
+
--hi-color-icon-on-solid: rgb(var(--color-on-solid-icon, 255 255 255));
|
|
117
|
+
--hi-color-success: rgb(var(--color-success));
|
|
118
|
+
--hi-color-error: rgb(var(--color-error));
|
|
119
|
+
--hi-color-warning: rgb(var(--color-warning));
|
|
120
|
+
--hi-color-info: rgb(var(--color-info));
|
|
121
|
+
|
|
122
|
+
/* Design tokens consumed by hikari components. These used to live only in
|
|
123
|
+
plana-ui's token set; without them the background/backdrop declarations
|
|
124
|
+
of fields and surfaces resolve to nothing. Values align with plana-ui. */
|
|
125
|
+
--space-2: 0.125rem;
|
|
126
|
+
--space-4: 0.25rem;
|
|
127
|
+
--space-6: 0.375rem;
|
|
128
|
+
--space-8: 0.5rem;
|
|
129
|
+
--space-10: 0.625rem;
|
|
130
|
+
--space-12: 0.75rem;
|
|
131
|
+
--space-14: 0.875rem;
|
|
132
|
+
--space-16: 1rem;
|
|
133
|
+
--space-20: 1.25rem;
|
|
134
|
+
--space-24: 1.5rem;
|
|
135
|
+
--space-28: 1.75rem;
|
|
136
|
+
--space-32: 2rem;
|
|
137
|
+
--space-40: 2.5rem;
|
|
138
|
+
--text-2xs: 0.625rem;
|
|
139
|
+
--text-xs: 0.75rem;
|
|
140
|
+
--text-sm: 0.8125rem;
|
|
141
|
+
--text-base: 0.875rem;
|
|
142
|
+
--text-md: 1rem;
|
|
143
|
+
--text-lg: 1.125rem;
|
|
144
|
+
--radius-sm: 4px;
|
|
145
|
+
--radius-md: 8px;
|
|
146
|
+
--opacity-half: 0.92;
|
|
147
|
+
--blur-sm: 8px;
|
|
148
|
+
--blur-md: 16px;
|
|
149
|
+
--blur-lg: 24px;
|
|
150
|
+
--duration-fast: 0.15s;
|
|
151
|
+
--duration-short: 0.15s;
|
|
152
|
+
--duration-normal: 0.3s;
|
|
153
|
+
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
154
|
+
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
|
|
155
|
+
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
|
|
156
|
+
--s-footer-height: 3rem;
|
|
157
|
+
--z-header: 30;
|
|
158
|
+
--z-sidebar: 40;
|
|
159
|
+
/* Apple-style stack + CJK UI tail — keep in sync with packages/vue/src/theme/fontContext.ts */
|
|
160
|
+
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
|
161
|
+
--font-reading: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
|
162
|
+
}
|