@celestia-island/hikari 0.55.113 → 0.55.115
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/HkBadge.scss +5 -0
- package/src/components/HkCopyBadge.scss +20 -0
- package/src/components/HkCopyBadge.test.tsx +172 -0
- package/src/components/HkCopyBadge.tsx +140 -0
- package/src/components/HkModalBreadcrumb.test.tsx +76 -4
- package/src/components/HkModalBreadcrumb.tsx +52 -3
- package/src/i18n/locales/ar/platform.json +1 -0
- package/src/i18n/locales/de/platform.json +1 -0
- package/src/i18n/locales/en/platform.json +1 -0
- package/src/i18n/locales/es/platform.json +1 -0
- package/src/i18n/locales/fr/platform.json +1 -0
- package/src/i18n/locales/ja/platform.json +1 -0
- package/src/i18n/locales/ko/platform.json +1 -0
- package/src/i18n/locales/pt/platform.json +1 -0
- package/src/i18n/locales/ru/platform.json +1 -0
- package/src/i18n/locales/zh-Hans/platform.json +1 -0
- package/src/i18n/locales/zh-Hant/platform.json +1 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
color: var(--hk-badge-text);
|
|
28
28
|
line-height: 1.4;
|
|
29
29
|
white-space: nowrap;
|
|
30
|
+
/* Tags are labels, not prose: dragging across a row of pills must never
|
|
31
|
+
start a text selection (the workspace tag contract makes HkCopyBadge
|
|
32
|
+
the copy path instead). */
|
|
33
|
+
user-select: none;
|
|
34
|
+
-webkit-user-select: none;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/* ---------- size ---------- */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* HkCopyBadge — the interactive tag face. The badge itself stays pure;
|
|
3
|
+
* this sheet only adds the interactivity contract: pointer cursor and a
|
|
4
|
+
* pressed/keyboard-focus affordance that keeps the HkBadge geometry.
|
|
5
|
+
* (Text selection is banned on every badge at the `.hk-badge` base —
|
|
6
|
+
* tags are labels, not prose.)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.hk-badge.hk-copy-badge {
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
|
|
12
|
+
&:active {
|
|
13
|
+
filter: brightness(0.92);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&:focus-visible {
|
|
17
|
+
outline: 2px solid rgb(var(--color-primary) / 60%);
|
|
18
|
+
outline-offset: 1px;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h, defineComponent } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import HkCopyBadge from "./HkCopyBadge";
|
|
8
|
+
import { useToast } from "../runtime/useToast";
|
|
9
|
+
|
|
10
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
11
|
+
|
|
12
|
+
function mount(renderNode: () => ReturnType<typeof h>) {
|
|
13
|
+
const container = document.createElement("div");
|
|
14
|
+
document.body.appendChild(container);
|
|
15
|
+
const app = createApp({ render: renderNode });
|
|
16
|
+
app.mount(container);
|
|
17
|
+
mounts.push({ app, container });
|
|
18
|
+
return container;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
23
|
+
app.unmount();
|
|
24
|
+
container.remove();
|
|
25
|
+
}
|
|
26
|
+
useToast().toasts.splice(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// `navigator.clipboard` does not exist under happy-dom — install a
|
|
30
|
+
// controllable stub and restore whatever was there before.
|
|
31
|
+
let writeText: ReturnType<typeof vi.fn>;
|
|
32
|
+
const originalClipboard = navigator.clipboard;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
writeText = vi.fn().mockResolvedValue(undefined);
|
|
36
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
37
|
+
value: { writeText },
|
|
38
|
+
configurable: true,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
44
|
+
value: originalClipboard,
|
|
45
|
+
configurable: true,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("HkCopyBadge", () => {
|
|
50
|
+
it("renders a HkBadge inside the tooltip wrapper with button semantics", () => {
|
|
51
|
+
const c = mount(() => h(HkCopyBadge, { variant: "muted", mono: true }, () => "#d.132"));
|
|
52
|
+
// The badge keeps its HkBadge identity (variant class, mono flag)…
|
|
53
|
+
const badge = c.querySelector(".hk-badge");
|
|
54
|
+
expect(badge).not.toBeNull();
|
|
55
|
+
expect(badge!.classList.contains("hk-badge-muted")).toBe(true);
|
|
56
|
+
expect(badge!.hasAttribute("data-mono")).toBe(true);
|
|
57
|
+
expect(badge!.textContent).toBe("#d.132");
|
|
58
|
+
// …wrapped by the house tooltip, with button semantics on the badge.
|
|
59
|
+
expect(c.querySelector(".hk-tooltip-wrapper")).not.toBeNull();
|
|
60
|
+
expect(badge!.getAttribute("role")).toBe("button");
|
|
61
|
+
expect(badge!.getAttribute("tabindex")).toBe("0");
|
|
62
|
+
expect(badge!.classList.contains("hk-copy-badge")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("copies the rendered text on click and toasts the localized default", async () => {
|
|
66
|
+
const c = mount(() => h(HkCopyBadge, null, () => "#demiurge.132"));
|
|
67
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
|
|
71
|
+
expect(writeText).toHaveBeenCalledWith("#demiurge.132");
|
|
72
|
+
const success = useToast().toasts.find((t) => t.type === "success");
|
|
73
|
+
expect(success?.messages.some((m) => m.text === "Copied")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("prefers the value prop over the rendered label as the copy payload", async () => {
|
|
77
|
+
const c = mount(() =>
|
|
78
|
+
h(HkCopyBadge, { value: "demiurge.132" }, () => "#d.132"),
|
|
79
|
+
);
|
|
80
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
81
|
+
await Promise.resolve();
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
|
|
84
|
+
expect(writeText).toHaveBeenCalledWith("demiurge.132");
|
|
85
|
+
expect(writeText).not.toHaveBeenCalledWith("#d.132");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("stops the click from reaching clickable ancestors", async () => {
|
|
89
|
+
const rowClick = vi.fn();
|
|
90
|
+
const Row = defineComponent({
|
|
91
|
+
setup() {
|
|
92
|
+
return () =>
|
|
93
|
+
h("div", { onClick: rowClick }, h(HkCopyBadge, null, () => "#d.132"));
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const c = mount(() => h(Row));
|
|
97
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
|
|
100
|
+
expect(writeText).toHaveBeenCalled();
|
|
101
|
+
expect(rowClick).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("activates on Enter and Space but not other keys", async () => {
|
|
105
|
+
const c = mount(() => h(HkCopyBadge, null, () => "x"));
|
|
106
|
+
const badge = c.querySelector(".hk-badge") as HTMLElement;
|
|
107
|
+
|
|
108
|
+
badge.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
109
|
+
await Promise.resolve();
|
|
110
|
+
badge.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
expect(writeText).toHaveBeenCalledTimes(2);
|
|
113
|
+
|
|
114
|
+
badge.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
expect(writeText).toHaveBeenCalledTimes(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("swaps the click behavior when `action` is set", async () => {
|
|
120
|
+
const action = vi.fn();
|
|
121
|
+
const c = mount(() => h(HkCopyBadge, { action, tooltip: "Open details" }, () => "#d.132"));
|
|
122
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
123
|
+
await Promise.resolve();
|
|
124
|
+
|
|
125
|
+
expect(action).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(writeText).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders the custom tooltip text", () => {
|
|
130
|
+
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
131
|
+
const content = c.parentElement!.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
132
|
+
expect(content?.textContent).toBe("Open details");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("shows the localized click-to-copy tooltip by default", () => {
|
|
136
|
+
const c = mount(() => h(HkCopyBadge, null, () => "#d.132"));
|
|
137
|
+
const content = c.parentElement!.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
138
|
+
expect(content?.textContent).toBe("Click to copy");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("renders the inert badge face when disabled", () => {
|
|
142
|
+
const c = mount(() => h(HkCopyBadge, { disabled: true }, () => "#d.132"));
|
|
143
|
+
const badge = c.querySelector(".hk-badge")!;
|
|
144
|
+
expect(badge.getAttribute("role")).toBeNull();
|
|
145
|
+
expect(badge.getAttribute("tabindex")).toBeNull();
|
|
146
|
+
expect(badge.classList.contains("hk-copy-badge")).toBe(false);
|
|
147
|
+
// No tooltip wrapper at all.
|
|
148
|
+
expect(c.querySelector(".hk-tooltip-wrapper")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("keeps the workspace tag contract: no badge text selection, ever", () => {
|
|
152
|
+
// Source contract on the HkBadge base sheet — every tag in every host
|
|
153
|
+
// inherits the no-selection rule, copyable or not.
|
|
154
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
155
|
+
const scss = readFileSync(join(here, "HkBadge.scss"), "utf-8");
|
|
156
|
+
expect(scss).toMatch(/user-select:\s*none/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("lands consumer class on the badge element, not the tooltip wrapper", () => {
|
|
160
|
+
const c = mount(() => h(HkCopyBadge, { class: "s-kanban-card-tag" }, () => "#tag"));
|
|
161
|
+
const badge = c.querySelector(".hk-badge")!;
|
|
162
|
+
expect(badge.classList.contains("s-kanban-card-tag")).toBe(true);
|
|
163
|
+
expect(badge.classList.contains("hk-copy-badge")).toBe(true);
|
|
164
|
+
const wrapper = c.querySelector(".hk-tooltip-wrapper")!;
|
|
165
|
+
expect(wrapper.classList.contains("s-kanban-card-tag")).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps consumer class when disabled", () => {
|
|
169
|
+
const c = mount(() => h(HkCopyBadge, { disabled: true, class: "x-pill" }, () => "#tag"));
|
|
170
|
+
expect(c.querySelector(".hk-badge")!.classList.contains("x-pill")).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import HBadge, { type BadgeVariant } from "./HkBadge";
|
|
4
|
+
import HTooltip from "./HkTooltip";
|
|
5
|
+
import { useI18n } from "../i18n/context";
|
|
6
|
+
import { useClipboardWithToast } from "../runtime/useClipboard";
|
|
7
|
+
import { useToast } from "../runtime/useToast";
|
|
8
|
+
import "./HkCopyBadge.scss";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HkCopyBadge — the house standard for interactive TAGS: a HkBadge that
|
|
12
|
+
* never selects text and instead copies on click.
|
|
13
|
+
*
|
|
14
|
+
* The workspace-wide tag contract (user direction 2026-10-05) is that a
|
|
15
|
+
* label pill is not prose: text selection is always off (that half lives
|
|
16
|
+
* on `.hk-badge` itself), hover shows a "click to copy" tooltip, and a
|
|
17
|
+
* click runs the shared clipboard-with-toast path so every host confirms
|
|
18
|
+
* with the same localized toast. Rendered content is the default copy
|
|
19
|
+
* payload; pass `value` when the meaningful string differs from the
|
|
20
|
+
* label (a `#short` display id backed by a longer id, say).
|
|
21
|
+
*
|
|
22
|
+
* `action` swaps the click behavior outright (open a panel, jump …).
|
|
23
|
+
* That is only legitimate when the tooltip no longer claims "copy" —
|
|
24
|
+
* pass `tooltip` alongside, or the component warns in dev. `disabled`
|
|
25
|
+
* drops the interactivity entirely (no tooltip, no pointer cursor, no
|
|
26
|
+
* button semantics) and renders the plain badge face.
|
|
27
|
+
*
|
|
28
|
+
* A11y: the tag is a real button (role, tab order, Enter/Space), and the
|
|
29
|
+
* click is stopped from reaching clickable ancestors — a tag inside a
|
|
30
|
+
* selectable row copies without selecting the row. Consumer attrs
|
|
31
|
+
* (class/style/data-*) land on the badge element itself, so a call site
|
|
32
|
+
* skinning the pill keeps working; the tooltip wrapper stays clean.
|
|
33
|
+
*/
|
|
34
|
+
export default defineComponent({
|
|
35
|
+
name: "HkCopyBadge",
|
|
36
|
+
inheritAttrs: false,
|
|
37
|
+
props: {
|
|
38
|
+
/** What a click copies. Default: the rendered text content. */
|
|
39
|
+
value: { type: String, default: undefined },
|
|
40
|
+
/** Tooltip text. Default: the localized "click to copy". */
|
|
41
|
+
tooltip: { type: String, default: undefined },
|
|
42
|
+
/** Swap the click behavior (must come with a custom `tooltip`). */
|
|
43
|
+
action: { type: Function as PropType<() => void>, default: undefined },
|
|
44
|
+
/** Render the inert badge face: no tooltip, no click, no cursor. */
|
|
45
|
+
disabled: { type: Boolean, default: false },
|
|
46
|
+
// ── HkBadge passthrough ──────────────────────────────────────────
|
|
47
|
+
variant: { type: String as PropType<BadgeVariant>, default: "default" },
|
|
48
|
+
dot: { type: Boolean, default: false },
|
|
49
|
+
size: { type: String as PropType<"sm" | "md">, default: "md" },
|
|
50
|
+
mono: { type: Boolean, default: false },
|
|
51
|
+
uppercase: { type: Boolean, default: false },
|
|
52
|
+
pill: { type: Boolean, default: true },
|
|
53
|
+
color: { type: String, default: undefined },
|
|
54
|
+
bgColor: { type: String, default: undefined },
|
|
55
|
+
borderColor: { type: String, default: undefined },
|
|
56
|
+
},
|
|
57
|
+
setup(props, { slots, attrs }) {
|
|
58
|
+
const { t } = useI18n();
|
|
59
|
+
const clipboard = useClipboardWithToast(useToast());
|
|
60
|
+
|
|
61
|
+
// The badge root: HkBadge renders a single span, so the instance's
|
|
62
|
+
// $el carries the rendered label — the default copy payload.
|
|
63
|
+
const badgeRef = ref<InstanceType<typeof HBadge> | null>(null);
|
|
64
|
+
|
|
65
|
+
const tooltipText = computed(() =>
|
|
66
|
+
props.tooltip ?? t("hikari::clipboard.clickToCopy", "Click to copy"),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (import.meta.env?.DEV && props.action && !props.tooltip) {
|
|
70
|
+
console.warn(
|
|
71
|
+
"[HkCopyBadge] `action` replaces the copy behavior — pass `tooltip` " +
|
|
72
|
+
"so the hover hint stops claiming click-to-copy.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function copyPayload(): string {
|
|
77
|
+
if (props.value !== undefined) return props.value;
|
|
78
|
+
const el = badgeRef.value?.$el as HTMLElement | undefined;
|
|
79
|
+
return el?.textContent?.trim() ?? "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function activate() {
|
|
83
|
+
if (props.disabled) return;
|
|
84
|
+
if (props.action) {
|
|
85
|
+
props.action();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const text = copyPayload();
|
|
89
|
+
if (!text) return;
|
|
90
|
+
void clipboard.copy(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return () => {
|
|
94
|
+
// Everything the call site put on HkCopyBadge belongs to the pill
|
|
95
|
+
// itself; only the interactive contract below is ours to add.
|
|
96
|
+
const { class: attrClass, style: attrStyle, ...restAttrs } = attrs;
|
|
97
|
+
|
|
98
|
+
const badge = (
|
|
99
|
+
<HBadge
|
|
100
|
+
ref={badgeRef}
|
|
101
|
+
{...restAttrs}
|
|
102
|
+
class={props.disabled ? attrClass : ["hk-copy-badge", attrClass]}
|
|
103
|
+
style={attrStyle}
|
|
104
|
+
variant={props.variant}
|
|
105
|
+
dot={props.dot}
|
|
106
|
+
size={props.size}
|
|
107
|
+
mono={props.mono}
|
|
108
|
+
uppercase={props.uppercase}
|
|
109
|
+
pill={props.pill}
|
|
110
|
+
color={props.color}
|
|
111
|
+
bgColor={props.bgColor}
|
|
112
|
+
borderColor={props.borderColor}
|
|
113
|
+
{...(props.disabled
|
|
114
|
+
? {}
|
|
115
|
+
: {
|
|
116
|
+
role: "button",
|
|
117
|
+
tabindex: 0,
|
|
118
|
+
onClick: (e: MouseEvent) => {
|
|
119
|
+
// A tag inside a clickable row/card copies itself
|
|
120
|
+
// without triggering the row.
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
activate();
|
|
123
|
+
},
|
|
124
|
+
onKeydown: (e: KeyboardEvent) => {
|
|
125
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
activate();
|
|
129
|
+
},
|
|
130
|
+
})}
|
|
131
|
+
>
|
|
132
|
+
{slots.default?.()}
|
|
133
|
+
</HBadge>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (props.disabled) return badge;
|
|
137
|
+
return <HTooltip text={tooltipText.value}>{badge}</HTooltip>;
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
@@ -640,7 +640,9 @@ describe("HkModalBreadcrumb overflow fold", () => {
|
|
|
640
640
|
// The strip is teleported into the host's zoomed root: the header's
|
|
641
641
|
// rect is VISUAL px while the inline top it writes is LOCAL px, so the
|
|
642
642
|
// height is divided by the zoom. Forgetting the division drifts the
|
|
643
|
-
// strip (zoom − 1) · height / 2 down its window.
|
|
643
|
+
// strip (zoom − 1) · height / 2 down its window. The strip's OWN height
|
|
644
|
+
// rides the same conversion (clearance branch), so it is mocked here
|
|
645
|
+
// too: 88 visual px = 44 local at zoom 2.
|
|
644
646
|
const app = document.createElement("div");
|
|
645
647
|
app.id = "app";
|
|
646
648
|
app.style.top = "10px";
|
|
@@ -665,21 +667,91 @@ describe("HkModalBreadcrumb overflow fold", () => {
|
|
|
665
667
|
const rect = (height: number): DOMRect =>
|
|
666
668
|
({ x: 0, y: 0, width: 200, height, top: 0, left: 0, right: 200, bottom: height, toJSON: () => ({}) }) as DOMRect;
|
|
667
669
|
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
|
668
|
-
|
|
670
|
+
if (this === header) return rect(96);
|
|
671
|
+
if (this.classList.contains("hk-modal-breadcrumb")) return rect(88);
|
|
672
|
+
return rect(0);
|
|
669
673
|
});
|
|
670
674
|
try {
|
|
671
675
|
setViewport(1200);
|
|
672
676
|
manager.register("modal", true, "第一层");
|
|
673
677
|
manager.register("modal", true, "第二层");
|
|
674
678
|
await mountStrip();
|
|
675
|
-
// 10 (app top, local) + (96 visual / 2
|
|
676
|
-
|
|
679
|
+
// 10 (app top, local) + the lower of: header centre (96 visual / 2
|
|
680
|
+
// zoom) / 2 = 24, and the clearance 12 + (88 visual / 2 zoom) / 2 =
|
|
681
|
+
// 34 — the clearance sits lower and wins.
|
|
682
|
+
expect(strip()!.style.top).toBe("44px");
|
|
677
683
|
} finally {
|
|
678
684
|
vi.restoreAllMocks();
|
|
679
685
|
app.remove();
|
|
680
686
|
}
|
|
681
687
|
});
|
|
682
688
|
|
|
689
|
+
it("keeps daylight between the app's top edge and the strip", async () => {
|
|
690
|
+
// Centred on a standard short header, the strip's own upper half used
|
|
691
|
+
// to kiss the very top edge (2026-10-05 user report): a ~24px centre
|
|
692
|
+
// line is less than the strip's half height. The top is now the LOWER
|
|
693
|
+
// of "header centre" and the topGap clearance; the boxless test DOM
|
|
694
|
+
// exercises the rule on the two layout fallbacks (48px header, 43px
|
|
695
|
+
// strip). The app top is deliberately non-zero (5px) so NO expectation
|
|
696
|
+
// coincides with the pre-resync initial ref — each leg proves resyncTop
|
|
697
|
+
// actually wrote: 5 + max(48/2, gap + 43/2).
|
|
698
|
+
const app = document.createElement("div");
|
|
699
|
+
app.id = "app";
|
|
700
|
+
app.style.top = "5px";
|
|
701
|
+
document.body.appendChild(app);
|
|
702
|
+
const unmountAll = () => {
|
|
703
|
+
for (const a of mounts.splice(0)) a.unmount();
|
|
704
|
+
};
|
|
705
|
+
try {
|
|
706
|
+
setViewport(1200);
|
|
707
|
+
manager.register("modal", true, "第一层");
|
|
708
|
+
manager.register("modal", true, "第二层");
|
|
709
|
+
await mountStrip();
|
|
710
|
+
expect(strip()!.style.top).toBe("38.5px");
|
|
711
|
+
unmountAll();
|
|
712
|
+
|
|
713
|
+
// A host pinning the legacy, header-centred position passes 0:
|
|
714
|
+
// 5 + max(24, 0 + 21.5) — the centre branch wins again.
|
|
715
|
+
await mountStrip({ topGap: 0 });
|
|
716
|
+
expect(strip()!.style.top).toBe("29px");
|
|
717
|
+
unmountAll();
|
|
718
|
+
|
|
719
|
+
// And a host wanting more air simply raises the gap.
|
|
720
|
+
await mountStrip({ topGap: 40 });
|
|
721
|
+
expect(strip()!.style.top).toBe("66.5px");
|
|
722
|
+
unmountAll();
|
|
723
|
+
|
|
724
|
+
// A NaN gap is host junk: it falls back to the default instead of
|
|
725
|
+
// poisoning Math.max into `top: NaNpx` (which the browser drops).
|
|
726
|
+
await mountStrip({ topGap: Number.NaN });
|
|
727
|
+
expect(strip()!.style.top).toBe("38.5px");
|
|
728
|
+
} finally {
|
|
729
|
+
unmountAll();
|
|
730
|
+
app.remove();
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("paints the fallback position before any resync can run", async () => {
|
|
735
|
+
// A host whose app root id never resolves leaves resyncTop at its
|
|
736
|
+
// early-return, so the strip paints the INITIAL ref: that line must
|
|
737
|
+
// already encode the clearance rule on the two layout fallbacks
|
|
738
|
+
// (48px header, 43px strip), not a legacy literal. Every other
|
|
739
|
+
// style.top assertion lives in tests that create #app, where the
|
|
740
|
+
// pre-mount resync overwrites the init before the first render
|
|
741
|
+
// (mutation round M5: reverting the init to a bare 24 passed
|
|
742
|
+
// everything else green).
|
|
743
|
+
setViewport(1200);
|
|
744
|
+
manager.register("modal", true, "第一层");
|
|
745
|
+
manager.register("modal", true, "第二层");
|
|
746
|
+
await mountStrip({ appRootId: "no-such-root" });
|
|
747
|
+
expect(strip()!.style.top).toBe("33.5px");
|
|
748
|
+
for (const a of mounts.splice(0)) a.unmount();
|
|
749
|
+
|
|
750
|
+
// The init follows the prop the same way the resync does.
|
|
751
|
+
await mountStrip({ appRootId: "no-such-root", topGap: 0 });
|
|
752
|
+
expect(strip()!.style.top).toBe("21.5px");
|
|
753
|
+
});
|
|
754
|
+
|
|
683
755
|
it("writes the viewport fence in the strip's own px", async () => {
|
|
684
756
|
// A vw-authored cap would be scaled by a host root zoom at paint, so the
|
|
685
757
|
// fence is re-derived from the live viewport (minus the shared gutter)
|
|
@@ -36,6 +36,13 @@ const GAP_PX = 8;
|
|
|
36
36
|
const SIDE_PADDING_PX = 32;
|
|
37
37
|
const BORDER_PX = 1;
|
|
38
38
|
const MORE_PX = 24;
|
|
39
|
+
/** The strip's own height for the moments no box can be measured (first
|
|
40
|
+
* resync, unit DOM): the stylesheet declares 12px vertical padding, a
|
|
41
|
+
* ~17px label line and a 1px border — 12·2 + 17 + 1·2 ≈ 43. Models the
|
|
42
|
+
* UNFOLDED strip; a folded one is ~50px (the 24px trigger line) — the
|
|
43
|
+
* fallback only serves boxless DOMs, a real browser measures the live
|
|
44
|
+
* nav and the slow tick re-derives from it. */
|
|
45
|
+
const STRIP_HEIGHT_PX = 43;
|
|
39
46
|
|
|
40
47
|
interface Crumb {
|
|
41
48
|
/** Popup-registry id this crumb can navigate back to. */
|
|
@@ -66,12 +73,33 @@ export default defineComponent({
|
|
|
66
73
|
* hidden-layers menu.
|
|
67
74
|
*/
|
|
68
75
|
maxLabelUnits: { type: Number, default: 10 },
|
|
76
|
+
/**
|
|
77
|
+
* Least daylight, in px, between the app's top edge and the strip's own
|
|
78
|
+
* top edge. The strip is centred on the app header, and on the standard
|
|
79
|
+
* short header that centre line sits ~24px from the top — less than the
|
|
80
|
+
* strip's half height, so its upper half used to kiss the very top edge
|
|
81
|
+
* (2026-10-05 user report). The top is now the lower of "centred on the
|
|
82
|
+
* header" and "this clearance"; a header whose centre already sits at
|
|
83
|
+
* least this far down keeps the strip centred on it. Authored in the
|
|
84
|
+
* strip's LOCAL px like every length here — a host root zoom scales it
|
|
85
|
+
* at paint, so it can only over-deliver daylight. Below 8 the entrance
|
|
86
|
+
* keyframe's own 8px slide transiently pokes back above the app top.
|
|
87
|
+
*/
|
|
88
|
+
topGap: { type: Number, default: 12 },
|
|
69
89
|
},
|
|
70
90
|
setup(props) {
|
|
71
91
|
const manager = usePopupManager();
|
|
72
92
|
const { t } = useI18n();
|
|
73
93
|
const { isMobile } = useBreakpoint();
|
|
74
94
|
|
|
95
|
+
/** Sanitized once: a host passing NaN would otherwise poison both the
|
|
96
|
+
* pre-resync ref and every resync (Math.max(x, NaN) is NaN, and the
|
|
97
|
+
* browser drops `top: NaNpx`, parking the strip at its static
|
|
98
|
+
* position). A NEGATIVE gap stays the host's choice: it lowers the
|
|
99
|
+
* clearance candidate below the centre line, which then wins — i.e.
|
|
100
|
+
* "I don't care about daylight". */
|
|
101
|
+
const topGapPx = computed(() => (Number.isFinite(props.topGap) ? props.topGap : 12));
|
|
102
|
+
|
|
75
103
|
/** Which entries the strip navigates. Windows (modal/drawer) always;
|
|
76
104
|
* dropdown-kind surfaces only while they BLOCK like a window — the
|
|
77
105
|
* mobile bottom sheet a menu/popover becomes when viewport space
|
|
@@ -375,7 +403,10 @@ export default defineComponent({
|
|
|
375
403
|
});
|
|
376
404
|
onBeforeUnmount(() => document.removeEventListener("keydown", onSurfaceKeydown));
|
|
377
405
|
|
|
378
|
-
|
|
406
|
+
/** Pre-resync paint position: the clearance rule evaluated on the two
|
|
407
|
+
* layout fallbacks (48px header, 43px strip). The first nextTick
|
|
408
|
+
* resync replaces it with measured numbers. */
|
|
409
|
+
const topPx = ref(topGapPx.value + STRIP_HEIGHT_PX / 2);
|
|
379
410
|
function resyncTop() {
|
|
380
411
|
const app = document.getElementById(props.appRootId);
|
|
381
412
|
if (!app) return;
|
|
@@ -397,7 +428,20 @@ export default defineComponent({
|
|
|
397
428
|
const headerH = header
|
|
398
429
|
? header.getBoundingClientRect().height / z
|
|
399
430
|
: props.headerFallbackHeight;
|
|
400
|
-
|
|
431
|
+
// The strip's own height comes back in the same VISUAL px the
|
|
432
|
+
// header's rect does (it teleports into the same zoomed subtree), so
|
|
433
|
+
// it divides by the zoom read at the strip itself. No box (first
|
|
434
|
+
// resync, unit DOM) falls back to the stylesheet-declared height.
|
|
435
|
+
let stripH = STRIP_HEIGHT_PX;
|
|
436
|
+
if (navRef.value) {
|
|
437
|
+
const navH = navRef.value.getBoundingClientRect().height;
|
|
438
|
+
if (navH > 0) stripH = navH / ancestorZoom(navRef.value);
|
|
439
|
+
}
|
|
440
|
+
// Centre on the header, but keep the promised daylight: whichever
|
|
441
|
+
// candidate parks the strip LOWER wins, so a centre line near the
|
|
442
|
+
// viewport top can no longer push the strip's upper half against
|
|
443
|
+
// (or past) the top edge.
|
|
444
|
+
topPx.value = appTop + Math.max(headerH / 2, topGapPx.value + stripH / 2);
|
|
401
445
|
}
|
|
402
446
|
|
|
403
447
|
/** Viewport fence, in the strip's OWN px. The stylesheet cap is
|
|
@@ -463,6 +507,9 @@ export default defineComponent({
|
|
|
463
507
|
if (!handle) handle = scheduleEvery(onSlowTick, 1000);
|
|
464
508
|
window.addEventListener("resize", onViewportChange);
|
|
465
509
|
void nextTick(() => {
|
|
510
|
+
// The strip's own box exists only now: re-derive the top with
|
|
511
|
+
// a measured height (the pre-mount resync ran on fallbacks).
|
|
512
|
+
resyncBox();
|
|
466
513
|
measureStrip();
|
|
467
514
|
observeClone();
|
|
468
515
|
});
|
|
@@ -495,7 +542,9 @@ export default defineComponent({
|
|
|
495
542
|
if (!visible.value) return;
|
|
496
543
|
// Synchronous first measure: the collapsed shape is committed in the
|
|
497
544
|
// same task as the mount, so the strip never paints one frame wide
|
|
498
|
-
// enough to spill before folding.
|
|
545
|
+
// enough to spill before folding. The top is re-derived with the
|
|
546
|
+
// strip's now-measurable height for the same reason.
|
|
547
|
+
resyncBox();
|
|
499
548
|
measureStrip();
|
|
500
549
|
observeClone();
|
|
501
550
|
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "نسخ",
|
|
12
12
|
"hikari::protocol.copied": "تم النسخ",
|
|
13
13
|
"hikari::clipboard.copied": "تم النسخ",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "انقر للنسخ",
|
|
14
15
|
"hikari::clipboard.copyFailed": "فشل النسخ",
|
|
15
16
|
"hikari::about.title": "حول",
|
|
16
17
|
"hikari::about.version": "الإصدار",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "Kopieren",
|
|
12
12
|
"hikari::protocol.copied": "Kopiert",
|
|
13
13
|
"hikari::clipboard.copied": "Kopiert",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "Klicken zum Kopieren",
|
|
14
15
|
"hikari::clipboard.copyFailed": "Kopieren fehlgeschlagen",
|
|
15
16
|
"hikari::about.title": "Über",
|
|
16
17
|
"hikari::about.version": "Version",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "Copy",
|
|
12
12
|
"hikari::protocol.copied": "Copied",
|
|
13
13
|
"hikari::clipboard.copied": "Copied",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "Click to copy",
|
|
14
15
|
"hikari::clipboard.copyFailed": "Copy failed",
|
|
15
16
|
"hikari::about.title": "About",
|
|
16
17
|
"hikari::about.version": "Version",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "Copiar",
|
|
12
12
|
"hikari::protocol.copied": "Copiado",
|
|
13
13
|
"hikari::clipboard.copied": "Copiado",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "Haz clic para copiar",
|
|
14
15
|
"hikari::clipboard.copyFailed": "Error al copiar",
|
|
15
16
|
"hikari::about.title": "Acerca de",
|
|
16
17
|
"hikari::about.version": "Versión",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "Copier",
|
|
12
12
|
"hikari::protocol.copied": "Copié",
|
|
13
13
|
"hikari::clipboard.copied": "Copié",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "Cliquez pour copier",
|
|
14
15
|
"hikari::clipboard.copyFailed": "Échec de la copie",
|
|
15
16
|
"hikari::about.title": "À propos",
|
|
16
17
|
"hikari::about.version": "Version",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "コピー",
|
|
12
12
|
"hikari::protocol.copied": "コピーしました",
|
|
13
13
|
"hikari::clipboard.copied": "コピーしました",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "クリックでコピー",
|
|
14
15
|
"hikari::clipboard.copyFailed": "コピーに失敗しました",
|
|
15
16
|
"hikari::about.title": "このアプリについて",
|
|
16
17
|
"hikari::about.version": "バージョン",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "복사",
|
|
12
12
|
"hikari::protocol.copied": "복사됨",
|
|
13
13
|
"hikari::clipboard.copied": "복사됨",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "클릭하여 복사",
|
|
14
15
|
"hikari::clipboard.copyFailed": "복사 실패",
|
|
15
16
|
"hikari::about.title": "정보",
|
|
16
17
|
"hikari::about.version": "버전",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "Copiar",
|
|
12
12
|
"hikari::protocol.copied": "Copiado",
|
|
13
13
|
"hikari::clipboard.copied": "Copiado",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "Clique para copiar",
|
|
14
15
|
"hikari::clipboard.copyFailed": "Falha ao copiar",
|
|
15
16
|
"hikari::about.title": "Sobre",
|
|
16
17
|
"hikari::about.version": "Versão",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hikari::protocol.copy": "Копировать",
|
|
12
12
|
"hikari::protocol.copied": "Скопировано",
|
|
13
13
|
"hikari::clipboard.copied": "Скопировано",
|
|
14
|
+
"hikari::clipboard.clickToCopy": "Нажмите, чтобы скопировать",
|
|
14
15
|
"hikari::clipboard.copyFailed": "Не удалось скопировать",
|
|
15
16
|
"hikari::about.title": "О программе",
|
|
16
17
|
"hikari::about.version": "Версия",
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { default as HColorPicker } from "./components/HkColorPicker";
|
|
|
16
16
|
export { HColorSchemeDialog, type HCustomTheme } from "./components/HkColorSchemeDialog";
|
|
17
17
|
export { HkColorSchemeEditor } from "./components/HkColorSchemeEditor";
|
|
18
18
|
export { default as HConfirmDialog } from "./components/HkConfirmDialog";
|
|
19
|
+
export { default as HCopyBadge } from "./components/HkCopyBadge";
|
|
19
20
|
export { default as HCrossfade } from "./components/HkCrossfade";
|
|
20
21
|
export { default as HDivider } from "./components/HkDivider";
|
|
21
22
|
export {
|
|
@@ -684,6 +685,7 @@ export { default as HkButton } from "./components/HkButton";
|
|
|
684
685
|
export { default as HkCard } from "./components/HkCard";
|
|
685
686
|
export { default as HkCheckbox } from "./components/HkCheckbox";
|
|
686
687
|
export { default as HkColorPicker } from "./components/HkColorPicker";
|
|
688
|
+
export { default as HkCopyBadge } from "./components/HkCopyBadge";
|
|
687
689
|
export { default as HkConfirmDialog } from "./components/HkConfirmDialog";
|
|
688
690
|
export { default as HkCrossfade } from "./components/HkCrossfade";
|
|
689
691
|
export { default as HkDivider } from "./components/HkDivider";
|