@celestia-island/hikari 0.55.271 → 0.55.273
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 -2
- 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/HkModal.scss +8 -1
- package/src/components/HkScrollContainer.fade.contract.test.ts +59 -14
- package/src/components/HkScrollContainer.scss +54 -14
- package/src/components/HkSettingsDialog.phonesheet.contract.test.ts +62 -0
- package/src/components/HkSettingsDialog.scss +14 -0
- package/src/components/HkTrendChart.test.tsx +163 -0
- package/src/components/HkTrendChart.tsx +72 -176
- package/src/components/trendGeometry.test.ts +290 -0
- package/src/components/trendGeometry.ts +372 -0
- package/src/index.ts +35 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.273",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
|
|
@@ -71,7 +71,6 @@
|
|
|
71
71
|
"@xterm/addon-image": "^0.10.0-beta.301",
|
|
72
72
|
"@xterm/xterm": "^6.1.0-beta.304",
|
|
73
73
|
"dompurify": "^*",
|
|
74
|
-
"echarts": "^*",
|
|
75
74
|
"highlight.js": "^*",
|
|
76
75
|
"lucide-vue-next": "^*",
|
|
77
76
|
"marked": "^*"
|
|
@@ -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
|
},
|
|
@@ -619,7 +619,14 @@
|
|
|
619
619
|
// padding both consume the vars, so one override moves both.
|
|
620
620
|
--hk-scroll-pad-top: 1rem;
|
|
621
621
|
--hk-scroll-pad-right: 1rem;
|
|
622
|
-
|
|
622
|
+
// The sheet docks flush on the bottom edge (bottom: 0 above), which on
|
|
623
|
+
// a gesture-nav phone is INSIDE the system bar's band — the body's
|
|
624
|
+
// last row would sit under the home pill. The footer already clears
|
|
625
|
+
// safe area below; a footer-less body gets the same guarantee through
|
|
626
|
+
// its own bottom gutter (2026-11-02 wowsp phone report). Hosts that
|
|
627
|
+
// paint edge-to-edge content still opt out per surface with
|
|
628
|
+
// --hk-scroll-pad-bottom, the same escape hatch the footer contract has.
|
|
629
|
+
--hk-scroll-pad-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
|
623
630
|
--hk-scroll-pad-left: 1rem;
|
|
624
631
|
}
|
|
625
632
|
|
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Source contract for the scroll-container edge fades (the 2026-
|
|
3
|
-
* revision): each edge owns a CSS var
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Source contract for the scroll-container edge fades (the 2026-11-02
|
|
3
|
+
* revision of the 2026-10 composition): each edge owns a CSS var, the
|
|
4
|
+
* viewport mask is built from the gradient LAYERS listed in
|
|
5
|
+
* --hk-scroll-fade-layers, and the axis rules swap that list —
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* - two-axis containers list the vertical AND horizontal gradients and
|
|
8
|
+
* intersect them, so the fade reaches zero on all four sides and the
|
|
9
|
+
* corners fall off naturally;
|
|
10
|
+
* - single-axis containers list ONE gradient on their scroll axis:
|
|
11
|
+
* a composed mask needs `mask-composite`, and engines without the
|
|
12
|
+
* standard property (Android WebViews below Chromium 120 — Tauri on
|
|
13
|
+
* Android rides the system WebView) fall back to the default ADD
|
|
14
|
+
* composition, whose union of two opaque layers is fully opaque —
|
|
15
|
+
* the fade vanished and the band ended in a hard cut at the screen
|
|
16
|
+
* edge (wowsp phone report 2026-11-02: the settings tab strip sliced
|
|
17
|
+
* its last pill mid-glyph). One layer needs no composite anywhere.
|
|
18
|
+
*
|
|
19
|
+
* The earlier regression this still pins (user report: "the light effect
|
|
20
|
+
* is clipped at the two sides"): the pre-2026-10 sheet wrote ONE
|
|
21
|
+
* `mask-image` per axis in competing cascade, so the last rule won, the
|
|
22
|
+
* other axis never faded. The axis rules now swap a VAR, never the
|
|
23
|
+
* property — the property pair is declared exactly once. Hence:
|
|
24
|
+
* - exactly ONE mask declaration pair lives in the sheet (the base
|
|
12
25
|
* block on the viewport) — a new per-axis `mask-image` rule is drift;
|
|
26
|
+
* - the single-axis axis rules set --hk-scroll-fade-layers to a single
|
|
27
|
+
* gradient and touch no mask property;
|
|
13
28
|
* - all four edge vars are declared (top/right/bottom/left, 0px base);
|
|
14
29
|
* - start/end/both rules exist for BOTH axes;
|
|
15
30
|
* - RTL swaps the horizontal vars instead of rewriting the mask.
|
|
@@ -23,22 +38,52 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
23
38
|
const sheet = readFileSync(join(here, "HkScrollContainer.scss"), "utf-8");
|
|
24
39
|
|
|
25
40
|
describe("scroll fade composition contract", () => {
|
|
26
|
-
it("declares one
|
|
41
|
+
it("declares one mask property pair on the viewport (no per-axis overrides)", () => {
|
|
27
42
|
// Feature 2026-10-31: a `mask-image` is authored as
|
|
28
43
|
// `@include mask-image(...)` (styles/polyfill.scss), so the include —
|
|
29
44
|
// not the raw declaration — is what a sheet may carry. The mixin
|
|
30
45
|
// itself owns the -webkit-/standard pair.
|
|
31
46
|
const declarations = sheet.match(/^\s*@include mask-image\(/gm) ?? [];
|
|
32
|
-
// Exactly THREE pairs live in the sheet, all accounted for: the
|
|
33
|
-
//
|
|
34
|
-
// selection-hint painter (the 2026-10-30 inline
|
|
35
|
-
// else is per-axis mask drift
|
|
47
|
+
// Exactly THREE pairs live in the sheet, all accounted for: the base
|
|
48
|
+
// block on the viewport (fed by --hk-scroll-fade-layers), and one
|
|
49
|
+
// feather pair per selection-hint painter (the 2026-10-30 inline
|
|
50
|
+
// falloff). Anything else is per-axis mask drift — the axis rules
|
|
51
|
+
// must swap the VAR, never re-declare the property (the 2026-10
|
|
52
|
+
// competing-cascade regression).
|
|
36
53
|
expect(declarations.length, "one include per mask pair, no per-axis drift").toBe(3);
|
|
37
54
|
expect(sheet).not.toMatch(/^\s*-webkit-mask-image:/m);
|
|
55
|
+
// The composed default (two layers, four edges) still goes through
|
|
56
|
+
// mask-composite — that pair belongs to the base rule only.
|
|
38
57
|
expect(sheet).toContain("mask-composite: intersect");
|
|
39
58
|
expect(sheet).toContain("-webkit-mask-composite: source-in");
|
|
40
59
|
});
|
|
41
60
|
|
|
61
|
+
it("declares the layer list and swaps it per axis to a single gradient", () => {
|
|
62
|
+
// The base rule lists BOTH gradients (the four-edge composition) in
|
|
63
|
+
// one var the mask property consumes.
|
|
64
|
+
expect(sheet).toMatch(/--hk-scroll-fade-layers:[\s\S]*linear-gradient\([\s\S]*to bottom[\s\S]*linear-gradient\([\s\S]*to right/);
|
|
65
|
+
// Each single-axis rule lists ONE gradient on its scroll axis — the
|
|
66
|
+
// no-composite form old WebViews render instead of an opaque union.
|
|
67
|
+
expect(
|
|
68
|
+
sheet.match(/\[data-axis="horizontal"\][^\n]*\n\s*--hk-scroll-fade-layers: linear-gradient\(\s*to right/),
|
|
69
|
+
"horizontal containers fade with one to-right gradient",
|
|
70
|
+
).toBeTruthy();
|
|
71
|
+
expect(
|
|
72
|
+
sheet.match(/\[data-axis="vertical"\][^\n]*\n\s*--hk-scroll-fade-layers: linear-gradient\(\s*to bottom/),
|
|
73
|
+
"vertical containers fade with one to-bottom gradient",
|
|
74
|
+
).toBeTruthy();
|
|
75
|
+
// The axis rules swap the var only — no composite, no mask property.
|
|
76
|
+
const axisBlocks = sheet.match(
|
|
77
|
+
/\.hk-scroll-container\[data-axis="(?:horizontal|vertical)"\]:is\([^)]*\)[^{]*\{[\s\S]*?\n\}/g,
|
|
78
|
+
) ?? [];
|
|
79
|
+
expect(axisBlocks.length, "both axis swap rules exist").toBe(2);
|
|
80
|
+
for (const block of axisBlocks) {
|
|
81
|
+
expect(block).toContain("--hk-scroll-fade-layers: linear-gradient(");
|
|
82
|
+
expect(block).not.toContain("mask-composite");
|
|
83
|
+
expect(block).not.toContain("mask-image");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
42
87
|
it("declares the four edge vars with zero defaults", () => {
|
|
43
88
|
for (const edge of ["top", "right", "bottom", "left"]) {
|
|
44
89
|
expect(sheet).toContain(`--hk-scroll-fade-${edge}: 0px;`);
|