@celestia-island/hikari 0.55.272 → 0.55.274
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/HkCardList.rowactions-touch.test.tsx +77 -0
- package/src/components/HkCardList.rowactions.test.tsx +148 -0
- package/src/components/HkCardList.scss +103 -0
- package/src/components/HkCardList.tsx +137 -1
- package/src/components/HkPersistentToast.scss +4 -0
- package/src/components/HkPersistentToast.test.tsx +9 -0
- package/src/components/HkPersistentToast.tsx +4 -2
- package/src/index.ts +1 -0
- package/src/plugins/boot-recovery.d.mts +29 -0
- package/src/plugins/boot-recovery.mjs +287 -12
- package/src/plugins/boot-recovery.test.ts +337 -6
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkCardList, HkListRow } from "./HkCardList";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Touch-first clients never get hover tooltips, so the option-control
|
|
8
|
+
* array swaps its inline icon lane for ONE trigger that opens the same
|
|
9
|
+
* actions as a named menu. The media query is stubbed here (this file
|
|
10
|
+
* runs in its own module graph — vitest isolates test files — so the
|
|
11
|
+
* shared useMediaQuery registry sees the touch-first answer).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
vi.spyOn(window, "matchMedia").mockImplementation(
|
|
16
|
+
(query: string): MediaQueryList => ({
|
|
17
|
+
matches: query.includes("hover: none") || query.includes("pointer: coarse"),
|
|
18
|
+
media: query,
|
|
19
|
+
onchange: null,
|
|
20
|
+
addEventListener: () => {},
|
|
21
|
+
removeEventListener: () => {},
|
|
22
|
+
addListener: () => {},
|
|
23
|
+
removeListener: () => {},
|
|
24
|
+
dispatchEvent: () => false,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
while (mounts.length) {
|
|
33
|
+
const { app, container } = mounts.pop()!;
|
|
34
|
+
app.unmount();
|
|
35
|
+
container.remove();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const icon = { render: () => null };
|
|
40
|
+
|
|
41
|
+
it("swaps the inline lane for a named upward menu trigger", async () => {
|
|
42
|
+
const picks: string[] = [];
|
|
43
|
+
const container = document.createElement("div");
|
|
44
|
+
document.body.appendChild(container);
|
|
45
|
+
const app = createApp({
|
|
46
|
+
render: () =>
|
|
47
|
+
h(HkCardList, null, {
|
|
48
|
+
default: () => [
|
|
49
|
+
h(HkListRow, {
|
|
50
|
+
title: "row",
|
|
51
|
+
actionsLabel: "row actions",
|
|
52
|
+
actionsMenuLabel: "more actions",
|
|
53
|
+
actions: [
|
|
54
|
+
{ name: "reinstall", icon, onSelect: () => picks.push("reinstall") },
|
|
55
|
+
{ name: "uninstall", danger: true, onSelect: () => picks.push("uninstall") },
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
mounts.push({ app, container });
|
|
62
|
+
app.mount(container);
|
|
63
|
+
|
|
64
|
+
const lane = container.querySelector(".hk-list-row-actions") as HTMLElement;
|
|
65
|
+
expect(lane.classList.contains("hk-list-row-actions--touch")).toBe(true);
|
|
66
|
+
// No inline icon buttons — the names live in the menu on this client.
|
|
67
|
+
expect(lane.querySelector(".hk-list-row-actions__btn")).toBeNull();
|
|
68
|
+
const trigger = lane.querySelector(".hk-list-row-actions__trigger") as HTMLButtonElement;
|
|
69
|
+
expect(trigger).toBeTruthy();
|
|
70
|
+
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
|
71
|
+
expect(trigger.getAttribute("aria-label")).toBe("more actions");
|
|
72
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
73
|
+
|
|
74
|
+
trigger.click();
|
|
75
|
+
await nextTick();
|
|
76
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
77
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick, type Component } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkCardList, HkListRow, hkListRowMenuItems, type HkListRowAction } from "./HkCardList";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkListRow's option-control array contract (pointer-client half):
|
|
8
|
+
*
|
|
9
|
+
* - persistent mode keeps the lane in the flow, one aria-labelled icon
|
|
10
|
+
* button per action;
|
|
11
|
+
* - overlay mode (the default) parks the lane out of the flow and only
|
|
12
|
+
* the touch reveal flips [data-revealed] (pointer devices reveal via
|
|
13
|
+
* CSS :hover/:focus-within, which happy-dom cannot exercise);
|
|
14
|
+
* - every action's NAME is its accessible identity — the aria-label and
|
|
15
|
+
* the menu model label (desktop tooltip / touch menu row);
|
|
16
|
+
* - the menu model maps the declared actions one-to-one.
|
|
17
|
+
*
|
|
18
|
+
* (Repo test convention: raw createApp + document queries; happy-dom
|
|
19
|
+
* has no layout engine.)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
while (mounts.length) {
|
|
26
|
+
const { app, container } = mounts.pop()!;
|
|
27
|
+
app.unmount();
|
|
28
|
+
container.remove();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function mount(render: () => ReturnType<typeof h>): HTMLElement {
|
|
33
|
+
const container = document.createElement("div");
|
|
34
|
+
document.body.appendChild(container);
|
|
35
|
+
const app = createApp({ render });
|
|
36
|
+
mounts.push({ app, container });
|
|
37
|
+
app.mount(container);
|
|
38
|
+
return container;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function actionsOf(selects: Array<() => void>, over: Partial<HkListRowAction> = {}): HkListRowAction[] {
|
|
42
|
+
return selects.map((onSelect, i) => ({ name: "action " + (i + 1), onSelect, ...over }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** pointerType cannot ride the Event constructor in every engine —
|
|
46
|
+
* inject it on a plain bubbling event. */
|
|
47
|
+
function pointerDown(target: EventTarget, pointerType: string): void {
|
|
48
|
+
const ev = new Event("pointerdown", { bubbles: true, cancelable: true });
|
|
49
|
+
Object.defineProperty(ev, "pointerType", { value: pointerType });
|
|
50
|
+
target.dispatchEvent(ev);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mountRow(props: Record<string, unknown>, trailing?: () => unknown): HTMLElement {
|
|
54
|
+
return mount(() =>
|
|
55
|
+
h(HkCardList, null, {
|
|
56
|
+
default: () => [
|
|
57
|
+
h(HkListRow, { title: "row", ...props }, trailing ? { trailing } : undefined),
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("HkListRow actions — persistent mode", () => {
|
|
64
|
+
it("renders one named button per action and reports selection", () => {
|
|
65
|
+
const picks: string[] = [];
|
|
66
|
+
const container = mountRow({
|
|
67
|
+
actions: [
|
|
68
|
+
{ name: "reinstall", onSelect: () => picks.push("reinstall"), icon: { render: () => null } as Component },
|
|
69
|
+
{ name: "uninstall", onSelect: () => picks.push("uninstall"), danger: true },
|
|
70
|
+
{ name: "disabled one", onSelect: () => picks.push("no"), disabled: true },
|
|
71
|
+
],
|
|
72
|
+
actionsMode: "persistent",
|
|
73
|
+
actionsLabel: "row actions",
|
|
74
|
+
});
|
|
75
|
+
const lane = container.querySelector(".hk-list-row-actions") as HTMLElement;
|
|
76
|
+
expect(lane).toBeTruthy();
|
|
77
|
+
expect(lane.classList.contains("hk-list-row-actions--persistent")).toBe(true);
|
|
78
|
+
expect(lane.getAttribute("role")).toBe("toolbar");
|
|
79
|
+
expect(lane.getAttribute("aria-label")).toBe("row actions");
|
|
80
|
+
const buttons = Array.from(lane.querySelectorAll<HTMLButtonElement>(".hk-list-row-actions__btn"));
|
|
81
|
+
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
|
|
82
|
+
"reinstall",
|
|
83
|
+
"uninstall",
|
|
84
|
+
"disabled one",
|
|
85
|
+
]);
|
|
86
|
+
expect(buttons[1].hasAttribute("data-danger")).toBe(true);
|
|
87
|
+
expect(buttons[2].disabled).toBe(true);
|
|
88
|
+
buttons[0].click();
|
|
89
|
+
buttons[1].click();
|
|
90
|
+
buttons[2].click();
|
|
91
|
+
expect(picks).toEqual(["reinstall", "uninstall"]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("HkListRow actions — overlay mode", () => {
|
|
96
|
+
it("parks the lane out of the flow until a non-mouse pointer engages it", async () => {
|
|
97
|
+
const container = mountRow({
|
|
98
|
+
actions: actionsOf([() => {}]),
|
|
99
|
+
actionsMode: "overlay",
|
|
100
|
+
});
|
|
101
|
+
const lane = () => container.querySelector(".hk-list-row-actions") as HTMLElement;
|
|
102
|
+
expect(lane().classList.contains("hk-list-row-actions--overlay")).toBe(true);
|
|
103
|
+
expect(lane().getAttribute("data-revealed")).toBeNull();
|
|
104
|
+
const row = container.querySelector(".hk-list-row") as HTMLElement;
|
|
105
|
+
|
|
106
|
+
// Mouse presses never reveal — pointer devices reveal through CSS
|
|
107
|
+
// :hover/:focus-within, so the JS path stays silent for them.
|
|
108
|
+
pointerDown(row, "mouse");
|
|
109
|
+
await nextTick();
|
|
110
|
+
expect(lane().getAttribute("data-revealed")).toBeNull();
|
|
111
|
+
|
|
112
|
+
// A touch tap inside the row reveals the lane…
|
|
113
|
+
pointerDown(row, "touch");
|
|
114
|
+
await nextTick();
|
|
115
|
+
expect(lane().getAttribute("data-revealed")).not.toBeNull();
|
|
116
|
+
|
|
117
|
+
// …and a tap anywhere else dismisses it again.
|
|
118
|
+
pointerDown(document.body, "touch");
|
|
119
|
+
await nextTick();
|
|
120
|
+
expect(lane().getAttribute("data-revealed")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps the overlay lane focusable for keyboard users", () => {
|
|
124
|
+
// Hiding is opacity/pointer-events only: visibility:hidden would
|
|
125
|
+
// drop the buttons out of the tab order and strand keyboard users.
|
|
126
|
+
const container = mountRow({
|
|
127
|
+
actions: actionsOf([() => {}]),
|
|
128
|
+
actionsMode: "overlay",
|
|
129
|
+
});
|
|
130
|
+
const btn = container.querySelector(".hk-list-row-actions__btn") as HTMLButtonElement;
|
|
131
|
+
expect(btn).toBeTruthy();
|
|
132
|
+
expect(btn.disabled).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("hkListRowMenuItems", () => {
|
|
137
|
+
it("maps every declared action onto its menu row, keyed by name", () => {
|
|
138
|
+
const icon = { render: () => null } as Component;
|
|
139
|
+
const items = hkListRowMenuItems([
|
|
140
|
+
{ name: "reinstall", onSelect: () => {}, icon },
|
|
141
|
+
{ name: "uninstall", onSelect: () => {}, danger: true, disabled: true },
|
|
142
|
+
]);
|
|
143
|
+
expect(items).toEqual([
|
|
144
|
+
{ key: "reinstall", label: "reinstall", icon, danger: undefined, disabled: undefined },
|
|
145
|
+
{ key: "uninstall", label: "uninstall", icon: undefined, danger: true, disabled: true },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -66,3 +66,106 @@
|
|
|
66
66
|
align-items: center;
|
|
67
67
|
gap: var(--space-8, 0.5rem);
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/* ── Option-control array (HkListRow's `actions`) ───────────────────────
|
|
71
|
+
* The row's last lane: named icon buttons in one of two layouts (see
|
|
72
|
+
* HkListRowActionsMode). "persistent" keeps the lane in the flow — it
|
|
73
|
+
* costs the row its width and stays visible. "overlay" (the default)
|
|
74
|
+
* parks the lane OUT of the flow: pinned to the row's right edge,
|
|
75
|
+
* invisible and inert until engaged — hover / keyboard focus on pointer
|
|
76
|
+
* devices (CSS below), tap on touch (the [data-revealed] state) — and
|
|
77
|
+
* then temporarily covers the trailing content, so a dense list never
|
|
78
|
+
* pays for actions it is not using. Touch-first clients swap the lane
|
|
79
|
+
* for one trigger that opens the same actions as a named, upward menu
|
|
80
|
+
* (tooltips never fire there, so names must live in the menu). */
|
|
81
|
+
.hk-list-row {
|
|
82
|
+
position: relative;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.hk-list-row-actions {
|
|
86
|
+
display: inline-flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
gap: var(--space-4, 0.25rem);
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
|
|
91
|
+
/* In the flow (persistent + touch): pin to the right edge when the
|
|
92
|
+
row carries no trailing slot of its own. */
|
|
93
|
+
&--persistent,
|
|
94
|
+
&--touch {
|
|
95
|
+
margin-left: auto;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Parked overlay: absolute over the row's right content. Kept
|
|
99
|
+
focusable for keyboard users — opacity alone hides it visually
|
|
100
|
+
while :focus-within reveals it (visibility:hidden would drop it
|
|
101
|
+
out of the tab order entirely). */
|
|
102
|
+
&--overlay {
|
|
103
|
+
position: absolute;
|
|
104
|
+
right: var(--space-8, 0.5rem);
|
|
105
|
+
top: 50%;
|
|
106
|
+
transform: translateY(-50%);
|
|
107
|
+
padding: 0.125rem;
|
|
108
|
+
border-radius: var(--radius-sm, 0.375rem);
|
|
109
|
+
background: rgb(var(--color-surface, 255 255 255));
|
|
110
|
+
box-shadow: 0 2px 10px rgb(var(--color-border, 100 100 100) / 22%);
|
|
111
|
+
opacity: 0;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
transition: opacity var(--duration-fast, 0.15s) ease;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
&--overlay[data-revealed],
|
|
117
|
+
&--overlay:focus-within {
|
|
118
|
+
opacity: 1;
|
|
119
|
+
pointer-events: auto;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Pointer devices: hovering the row reveals the parked overlay. */
|
|
124
|
+
@media (hover: hover) {
|
|
125
|
+
.hk-list-row:hover > .hk-list-row-actions--overlay {
|
|
126
|
+
opacity: 1;
|
|
127
|
+
pointer-events: auto;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.hk-list-row-actions__btn,
|
|
132
|
+
.hk-list-row-actions__trigger {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
width: 1.625rem;
|
|
137
|
+
height: 1.625rem;
|
|
138
|
+
padding: 0;
|
|
139
|
+
border: 1px solid transparent;
|
|
140
|
+
border-radius: var(--radius-xs, 0.25rem);
|
|
141
|
+
background: transparent;
|
|
142
|
+
color: rgb(var(--color-muted, 108 108 108));
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
transition:
|
|
145
|
+
background-color var(--duration-fast, 0.15s) ease,
|
|
146
|
+
color var(--duration-fast, 0.15s) ease;
|
|
147
|
+
|
|
148
|
+
&:hover:not(:disabled) {
|
|
149
|
+
background: rgb(var(--color-text, 30 30 30) / 6%);
|
|
150
|
+
color: rgb(var(--color-text, 30 30 30));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
&:focus-visible {
|
|
154
|
+
outline: 2px solid rgb(var(--color-primary, 122 162 247));
|
|
155
|
+
outline-offset: -2px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
&:disabled {
|
|
159
|
+
opacity: 0.45;
|
|
160
|
+
cursor: default;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
&[data-danger] {
|
|
164
|
+
color: rgb(var(--color-error, 247 118 142) / 90%);
|
|
165
|
+
|
|
166
|
+
&:hover:not(:disabled) {
|
|
167
|
+
background: rgb(var(--color-error, 247 118 142) / 10%);
|
|
168
|
+
color: rgb(var(--color-error, 247 118 142));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { Comment, defineComponent, type Component, type PropType, type VNode } from "vue";
|
|
1
|
+
import { Comment, computed, defineComponent, h, onBeforeUnmount, onMounted, ref, type Component, type PropType, type VNode } from "vue";
|
|
2
|
+
import { MoreHorizontal } from "lucide-vue-next";
|
|
2
3
|
|
|
3
4
|
import HkCard from "./HkCard";
|
|
4
5
|
import { HkIconChip } from "./HkIconChip";
|
|
6
|
+
import HkMenu from "./HkMenu";
|
|
7
|
+
import type { HkMenuItem } from "./HkMenu";
|
|
8
|
+
import HkTooltip from "./HkTooltip";
|
|
9
|
+
import { useMediaQuery } from "../runtime/useMediaQuery";
|
|
5
10
|
import type { StatTone } from "./HkStatCard";
|
|
6
11
|
import "./HkCardList.scss";
|
|
7
12
|
|
|
@@ -57,6 +62,39 @@ export const HkCardList = defineComponent({
|
|
|
57
62
|
},
|
|
58
63
|
});
|
|
59
64
|
|
|
65
|
+
/** One named action of an HkListRow's option-control array (the
|
|
66
|
+
* `actions` prop). `name` is REQUIRED: it is the button's accessible
|
|
67
|
+
* name and its only visible identity — the pointer tooltip's text on
|
|
68
|
+
* hover devices, and the touch/narrow menu's row label where tooltips
|
|
69
|
+
* never fire. */
|
|
70
|
+
export interface HkListRowAction {
|
|
71
|
+
name: string;
|
|
72
|
+
icon?: Component;
|
|
73
|
+
danger?: boolean;
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
onSelect: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The option-control array's layout contract (see `actionsMode`):
|
|
79
|
+
* "overlay" — the array costs the row no width; it stays hidden until
|
|
80
|
+
* engaged (hover / keyboard focus on pointer devices, tap on touch)
|
|
81
|
+
* and then temporarily covers the row's right content. "persistent" —
|
|
82
|
+
* the array takes the last column and stays visible. */
|
|
83
|
+
export type HkListRowActionsMode = "overlay" | "persistent";
|
|
84
|
+
|
|
85
|
+
/** The touch trigger's menu model: one HkMenuItem per named action.
|
|
86
|
+
* Keys are the action NAMES (unique per row by contract), so the
|
|
87
|
+
* menu's select payload maps straight back onto `onSelect`. */
|
|
88
|
+
export function hkListRowMenuItems(actions: readonly HkListRowAction[]): HkMenuItem[] {
|
|
89
|
+
return actions.map((a) => ({
|
|
90
|
+
key: a.name,
|
|
91
|
+
label: a.name,
|
|
92
|
+
icon: a.icon,
|
|
93
|
+
danger: a.danger,
|
|
94
|
+
disabled: a.disabled,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
/**
|
|
61
99
|
* HkListRow — one entry of an HkCardList: an optional tone-tinted icon
|
|
62
100
|
* chip, a main column (title / subtitle / free-form default slot), and a
|
|
@@ -76,11 +114,48 @@ export const HkListRow = defineComponent({
|
|
|
76
114
|
subtitle: { type: String, default: undefined },
|
|
77
115
|
/** Whole-row click (drill-through). */
|
|
78
116
|
clickable: { type: Boolean, default: false },
|
|
117
|
+
/** The row's option-control array: named icon actions rendered as
|
|
118
|
+
* the row's last lane. Omit for rows without row-level actions. */
|
|
119
|
+
actions: { type: Array as PropType<HkListRowAction[]>, default: undefined },
|
|
120
|
+
/** See HkListRowActionsMode. Default "overlay": the array is parked
|
|
121
|
+
* out of the flow and only covers the right content while engaged. */
|
|
122
|
+
actionsMode: { type: String as PropType<HkListRowActionsMode>, default: "overlay" },
|
|
123
|
+
/** Accessible name of the action lane (role="toolbar"). */
|
|
124
|
+
actionsLabel: { type: String, default: undefined },
|
|
125
|
+
/** Accessible name / menu title of the touch trigger. Falls back to
|
|
126
|
+
* `actionsLabel`. */
|
|
127
|
+
actionsMenuLabel: { type: String, default: undefined },
|
|
79
128
|
},
|
|
80
129
|
emits: { click: (_e: MouseEvent | KeyboardEvent) => true },
|
|
81
130
|
setup(props, { emit, slots }) {
|
|
131
|
+
// Touch-first clients get the named menu (tooltips never fire there);
|
|
132
|
+
// pointer clients get the inline icon lane whose names ride tooltips.
|
|
133
|
+
const touchFirst = useMediaQuery("(hover: none), (pointer: coarse)");
|
|
134
|
+
/** Touch-driven overlay reveal. Pointer devices reveal through CSS
|
|
135
|
+
* :hover/:focus-within — the common path stays declarative. */
|
|
136
|
+
const revealed = ref(false);
|
|
137
|
+
const menuOpen = ref(false);
|
|
138
|
+
const rowEl = ref<HTMLElement | null>(null);
|
|
139
|
+
const triggerEl = ref<HTMLElement | null>(null);
|
|
140
|
+
const hasActions = () => (props.actions?.length ?? 0) > 0;
|
|
141
|
+
/** A tap inside the row reveals the overlay lane; a tap anywhere
|
|
142
|
+
* else dismisses it (capture-phase document listener). */
|
|
143
|
+
function onRowPointerDown(e: PointerEvent) {
|
|
144
|
+
if (!hasActions() || e.pointerType === "mouse") return;
|
|
145
|
+
revealed.value = true;
|
|
146
|
+
}
|
|
147
|
+
function onDocumentPointerDown(e: PointerEvent) {
|
|
148
|
+
if (!revealed.value) return;
|
|
149
|
+
if (rowEl.value?.contains(e.target as Node)) return;
|
|
150
|
+
revealed.value = false;
|
|
151
|
+
}
|
|
152
|
+
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown, true));
|
|
153
|
+
onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown, true));
|
|
154
|
+
const menuItems = computed<HkMenuItem[]>(() => hkListRowMenuItems(props.actions ?? []));
|
|
82
155
|
return () => (
|
|
83
156
|
<div
|
|
157
|
+
ref={rowEl}
|
|
158
|
+
onPointerdown={onRowPointerDown}
|
|
84
159
|
class={["hk-list-row", props.clickable && "hk-list-row-clickable"]}
|
|
85
160
|
role="listitem"
|
|
86
161
|
tabindex={props.clickable ? 0 : undefined}
|
|
@@ -107,6 +182,67 @@ export const HkListRow = defineComponent({
|
|
|
107
182
|
{slots.default?.()}
|
|
108
183
|
</div>
|
|
109
184
|
{slots.trailing ? <div class="hk-list-row-trailing">{slots.trailing()}</div> : null}
|
|
185
|
+
{hasActions() && (
|
|
186
|
+
<div
|
|
187
|
+
class={[
|
|
188
|
+
"hk-list-row-actions",
|
|
189
|
+
touchFirst.value
|
|
190
|
+
? "hk-list-row-actions--touch"
|
|
191
|
+
: props.actionsMode === "persistent"
|
|
192
|
+
? "hk-list-row-actions--persistent"
|
|
193
|
+
: "hk-list-row-actions--overlay",
|
|
194
|
+
]}
|
|
195
|
+
data-revealed={revealed.value ? "" : undefined}
|
|
196
|
+
role={touchFirst.value ? undefined : "toolbar"}
|
|
197
|
+
aria-label={touchFirst.value ? undefined : props.actionsLabel}
|
|
198
|
+
>
|
|
199
|
+
{touchFirst.value ? (
|
|
200
|
+
<button
|
|
201
|
+
ref={triggerEl}
|
|
202
|
+
type="button"
|
|
203
|
+
class="hk-list-row-actions__trigger"
|
|
204
|
+
aria-haspopup="menu"
|
|
205
|
+
aria-expanded={menuOpen.value}
|
|
206
|
+
aria-label={props.actionsMenuLabel ?? props.actionsLabel}
|
|
207
|
+
onClick={() => (menuOpen.value = !menuOpen.value)}
|
|
208
|
+
>
|
|
209
|
+
<MoreHorizontal size={14} />
|
|
210
|
+
</button>
|
|
211
|
+
) : (
|
|
212
|
+
(props.actions ?? []).map((a) => (
|
|
213
|
+
<HkTooltip text={a.name} placement="top">
|
|
214
|
+
{() => (
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
class="hk-list-row-actions__btn"
|
|
218
|
+
data-danger={a.danger || undefined}
|
|
219
|
+
disabled={a.disabled}
|
|
220
|
+
aria-label={a.name}
|
|
221
|
+
onClick={() => a.onSelect()}
|
|
222
|
+
>
|
|
223
|
+
{a.icon ? h(a.icon, { size: 14 }) : null}
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
</HkTooltip>
|
|
227
|
+
))
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
{menuOpen.value && (
|
|
232
|
+
<HkMenu
|
|
233
|
+
variant="popup"
|
|
234
|
+
items={menuItems.value}
|
|
235
|
+
open
|
|
236
|
+
anchorRef={triggerEl.value}
|
|
237
|
+
placement="top-end"
|
|
238
|
+
title={props.actionsMenuLabel ?? props.actionsLabel ?? ""}
|
|
239
|
+
onSelect={(key: string) => {
|
|
240
|
+
menuOpen.value = false;
|
|
241
|
+
(props.actions ?? []).find((a) => a.name === key)?.onSelect();
|
|
242
|
+
}}
|
|
243
|
+
onUpdate:open={(v: boolean) => (menuOpen.value = v)}
|
|
244
|
+
/>
|
|
245
|
+
)}
|
|
110
246
|
</div>
|
|
111
247
|
);
|
|
112
248
|
},
|
|
@@ -65,6 +65,10 @@ button.hk-persistent-toast {
|
|
|
65
65
|
color: rgb(var(--color-muted));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
.hk-persistent-toast--success .hk-persistent-toast__icon {
|
|
69
|
+
color: rgb(var(--color-success));
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
.hk-persistent-toast__label {
|
|
69
73
|
overflow: hidden;
|
|
70
74
|
text-overflow: ellipsis;
|
|
@@ -111,6 +111,15 @@ describe("HkPersistentToast inert default", () => {
|
|
|
111
111
|
expect(container.querySelector(".hk-persistent-toast__spinner")).not.toBeNull();
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
it("renders a static icon for the success tone", () => {
|
|
115
|
+
const { container } = mount({ props: { tone: "success", label: "Saved" } });
|
|
116
|
+
const c = chip(container);
|
|
117
|
+
expect(c.classList.contains("hk-persistent-toast--success")).toBe(true);
|
|
118
|
+
// Success renders a static lucide icon like the other non-loading tones.
|
|
119
|
+
expect(container.querySelector(".hk-persistent-toast__spinner")).toBeNull();
|
|
120
|
+
expect(c.querySelector("svg")).not.toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
114
123
|
it("renders the ✕ sibling button only when dismissible", () => {
|
|
115
124
|
const onDismiss = vi.fn();
|
|
116
125
|
const { container } = mount({ props: { label: "x", dismissible: true }, onDismiss });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AlertTriangle, Info, X } from "lucide-vue-next";
|
|
1
|
+
import { AlertTriangle, Check, Info, X } from "lucide-vue-next";
|
|
2
2
|
import {
|
|
3
3
|
computed,
|
|
4
4
|
defineComponent,
|
|
@@ -15,7 +15,7 @@ import { useI18n } from "../i18n/context";
|
|
|
15
15
|
import "./HkPersistentToast.scss";
|
|
16
16
|
import { ensureOverlayRoot } from "../overlay/overlayRoot";
|
|
17
17
|
|
|
18
|
-
export type PersistentToastTone = "loading" | "info" | "warning" | "error";
|
|
18
|
+
export type PersistentToastTone = "loading" | "info" | "warning" | "error" | "success";
|
|
19
19
|
|
|
20
20
|
/** Viewport gutter the detail card clamps into (mirror of
|
|
21
21
|
* --viewport-gutter's desktop value; the card is chrome-adjacent and
|
|
@@ -251,6 +251,8 @@ export default defineComponent({
|
|
|
251
251
|
<span class="hk-persistent-toast__spinner" aria-hidden="true" />
|
|
252
252
|
) : props.tone === "error" || props.tone === "warning" ? (
|
|
253
253
|
<AlertTriangle size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
|
|
254
|
+
) : props.tone === "success" ? (
|
|
255
|
+
<Check size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
|
|
254
256
|
) : (
|
|
255
257
|
<Info size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
|
|
256
258
|
)}
|
package/src/index.ts
CHANGED
|
@@ -938,6 +938,7 @@ export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
|
|
|
938
938
|
export { HkIconChip as HkIconChip } from "./components/HkIconChip";
|
|
939
939
|
export { HkIconChip as HIconChip } from "./components/HkIconChip";
|
|
940
940
|
export { HkCardList, HkListRow } from "./components/HkCardList";
|
|
941
|
+
export type { HkListRowAction, HkListRowActionsMode } from "./components/HkCardList";
|
|
941
942
|
export { HkCardList as HCardList, HkListRow as HListRow } from "./components/HkCardList";
|
|
942
943
|
export { HkSectionHeader, type SectionHeaderVariant } from "./components/HkSectionHeader";
|
|
943
944
|
export { HkSectionHeader as HSectionHeader } from "./components/HkSectionHeader";
|
|
@@ -64,6 +64,28 @@ export interface BootRecoveryCopyOptions {
|
|
|
64
64
|
message?: string;
|
|
65
65
|
/** Retry button label. */
|
|
66
66
|
retry?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Host translations of the panel copy, keyed by BCP 47-ish locale tags
|
|
69
|
+
* (`"en-US"`, `"zh-CN"`, `"zh-TW"`, …). Keys are normalised at config
|
|
70
|
+
* time (trimmed, lowercased, `_` → `-`); insertion order is preserved and
|
|
71
|
+
* breaks ties when several keys share a primary subtag (`zh-CN` listed
|
|
72
|
+
* before `zh-TW` means a `zh-HK` request resolves to the zh-CN entry).
|
|
73
|
+
* Every entry must carry non-empty `message` and `retry` strings —
|
|
74
|
+
* anything else fails the build loudly, as does two keys normalising to
|
|
75
|
+
* the same tag. Omitting the table (or passing it empty) keeps the
|
|
76
|
+
* single-copy script — byte-identical to the pre-locale implementation
|
|
77
|
+
* apart from the panel stylesheet, so a host that hashes the script into
|
|
78
|
+
* its CSP only needs to refresh the hash it already owns.
|
|
79
|
+
*/
|
|
80
|
+
locales?: Record<string, { message: string; retry: string }>;
|
|
81
|
+
/**
|
|
82
|
+
* `localStorage` key that holds the host's saved UI locale tag (WoWSP
|
|
83
|
+
* uses `"wowsp-ui-locale"`). Consulted FIRST — the user's explicit
|
|
84
|
+
* in-app choice outranks the shell's OS preference and the navigator
|
|
85
|
+
* list. Only meaningful together with `locales`: configuring it without
|
|
86
|
+
* a table fails at config time.
|
|
87
|
+
*/
|
|
88
|
+
storageKey?: string;
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
/** Options for {@link bootRecoveryPlugin} / {@link bootRecoveryScript}. */
|
|
@@ -88,6 +110,13 @@ export interface BootRecoveryPluginOptions {
|
|
|
88
110
|
deadlineMs?: number;
|
|
89
111
|
/** Absolute http(s) app origin (default `http://tauri.localhost`). */
|
|
90
112
|
canonicalOrigin?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Name of the `window` global carrying the shell's OS locale preference
|
|
115
|
+
* (`{ locale: string }`). Default `"__CELESTIA_OS_PREFS__"` — the
|
|
116
|
+
* boot-chrome family global; an explicit empty string `""` disables the
|
|
117
|
+
* OS-preference source. Only embedded when `copy.locales` is configured.
|
|
118
|
+
*/
|
|
119
|
+
osPrefsGlobal?: string;
|
|
91
120
|
/**
|
|
92
121
|
* Custom mangled-origin predicate. It runs in the PAGE, so it is embedded
|
|
93
122
|
* by source text: pass a self-contained ES2017-safe function expression or
|