@celestia-island/hikari 0.40.13 → 0.40.15
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/HkModal.transition-completion.test.tsx +179 -0
- package/src/components/HkModal.tsx +54 -2
- package/src/components/HkProtocolModal.scss +0 -11
- package/src/components/HkProtocolModal.tsx +9 -4
- package/src/components/HkSecretRevealModal.scss +0 -4
- package/src/components/HkSecretRevealModal.tsx +11 -22
- package/src/components/HkThemeToggle.scss +30 -0
- package/src/components/HkThemeToggle.test.ts +88 -0
- package/src/components/HkThemeToggle.tsx +83 -27
- package/src/i18n/locales/ar/platform.json +2 -0
- package/src/i18n/locales/de/platform.json +2 -0
- package/src/i18n/locales/en/platform.json +2 -0
- package/src/i18n/locales/es/platform.json +2 -0
- package/src/i18n/locales/fr/platform.json +2 -0
- package/src/i18n/locales/ja/platform.json +2 -0
- package/src/i18n/locales/ko/platform.json +2 -0
- package/src/i18n/locales/pt/platform.json +2 -0
- package/src/i18n/locales/ru/platform.json +2 -0
- package/src/i18n/locales/zh-Hans/platform.json +2 -0
- package/src/i18n/locales/zh-Hant/platform.json +2 -0
- package/src/runtime/useClipboard.test.ts +66 -0
- package/src/runtime/useClipboard.ts +19 -4
package/package.json
CHANGED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
6
|
+
|
|
7
|
+
import HkModal from "./HkModal";
|
|
8
|
+
|
|
9
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
10
|
+
const containers: HTMLElement[] = [];
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
14
|
+
for (const el of containers.splice(0)) el.remove();
|
|
15
|
+
vi.unstubAllGlobals();
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const here = join(dirname(fileURLToPath(import.meta.url)));
|
|
20
|
+
|
|
21
|
+
/** Freeze rAF entirely: Vue's <Transition> engine double-raf's the
|
|
22
|
+
* leave-from → leave-to class flip before it even arms its
|
|
23
|
+
* transitionend wait, so a frozen rAF reproduces the occluded-webview
|
|
24
|
+
* pathology exactly — the leave can NEVER complete on its own and only
|
|
25
|
+
* the component's watchdog can finalize it. */
|
|
26
|
+
function freezeRaf(): void {
|
|
27
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
28
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mount an open modal wired to an `open` ref we can flip from the test. */
|
|
32
|
+
async function mountOpenModal() {
|
|
33
|
+
const container = document.createElement("div");
|
|
34
|
+
document.body.appendChild(container);
|
|
35
|
+
containers.push(container);
|
|
36
|
+
|
|
37
|
+
const open = ref(true);
|
|
38
|
+
const afterLeaveEvents: number[] = [];
|
|
39
|
+
const Wrapper = defineComponent({
|
|
40
|
+
setup() {
|
|
41
|
+
return () =>
|
|
42
|
+
h(HkModal, {
|
|
43
|
+
modelValue: open.value,
|
|
44
|
+
closable: true,
|
|
45
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
46
|
+
onAfterLeave: () => { afterLeaveEvents.push(1); },
|
|
47
|
+
}, { default: () => h("div", "content") });
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const app = createApp(Wrapper);
|
|
51
|
+
mounts.push(app);
|
|
52
|
+
app.mount(container);
|
|
53
|
+
await nextTick();
|
|
54
|
+
return { open, afterLeaveEvents };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("HkModal leave-completion watchdog", () => {
|
|
58
|
+
// Regression for the field-reported frozen modal (2026-09): with rAF
|
|
59
|
+
// starved, the Transition leave can never complete on its own and the
|
|
60
|
+
// panel used to stay over the page forever — undismissable, only a
|
|
61
|
+
// full reload escaped it. The watchdog must finalize within its
|
|
62
|
+
// budget, unmount both surface layers, and emit afterLeave exactly
|
|
63
|
+
// the way a real transition would.
|
|
64
|
+
it("force-finalizes a stalled leave within the watchdog budget", async () => {
|
|
65
|
+
vi.useFakeTimers();
|
|
66
|
+
freezeRaf();
|
|
67
|
+
const { open, afterLeaveEvents } = await mountOpenModal();
|
|
68
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
69
|
+
|
|
70
|
+
open.value = false;
|
|
71
|
+
await nextTick();
|
|
72
|
+
// Just inside the budget nothing else can have completed the leave.
|
|
73
|
+
await vi.advanceTimersByTimeAsync(590);
|
|
74
|
+
await nextTick();
|
|
75
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
76
|
+
|
|
77
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
78
|
+
await nextTick();
|
|
79
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
80
|
+
expect(document.querySelector(".hk-modal-overlay")).toBeNull();
|
|
81
|
+
expect(afterLeaveEvents).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The finalize flag must reset on reopen: without the reset a second
|
|
85
|
+
// close after a forced first finalize would be a no-op and the modal
|
|
86
|
+
// would freeze open forever instead.
|
|
87
|
+
// Vue resolves an OPEN-INTERRUPTED leave without a cancelled flag: a
|
|
88
|
+
// reopen patching over a still-live leave fires the old leave's
|
|
89
|
+
// onAfterLeave. The finalize must bail out while the surface is open
|
|
90
|
+
// again, or the stale completion kills the freshly reopened modal
|
|
91
|
+
// (unregisters its handle, unmounts the panel, emits a spurious
|
|
92
|
+
// afterLeave).
|
|
93
|
+
it("ignores a stale leave completion firing during reopen", async () => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
freezeRaf();
|
|
96
|
+
const { open, afterLeaveEvents } = await mountOpenModal();
|
|
97
|
+
|
|
98
|
+
open.value = false;
|
|
99
|
+
await nextTick();
|
|
100
|
+
await vi.advanceTimersByTimeAsync(50); // leave live, nothing finalized
|
|
101
|
+
open.value = true; // reopen patches over the live leave
|
|
102
|
+
await nextTick();
|
|
103
|
+
await vi.advanceTimersByTimeAsync(700); // past the watchdog budget
|
|
104
|
+
await nextTick();
|
|
105
|
+
// The reopened modal must survive both the stale onAfterLeave and
|
|
106
|
+
// the (disarmed) watchdog from the aborted close.
|
|
107
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
108
|
+
expect(afterLeaveEvents).toHaveLength(0);
|
|
109
|
+
|
|
110
|
+
// And the surface still closes normally afterwards.
|
|
111
|
+
open.value = false;
|
|
112
|
+
await nextTick();
|
|
113
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
114
|
+
await nextTick();
|
|
115
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
116
|
+
expect(afterLeaveEvents).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("re-arms across open/close cycles after a forced finalize", async () => {
|
|
120
|
+
vi.useFakeTimers();
|
|
121
|
+
freezeRaf();
|
|
122
|
+
const { open, afterLeaveEvents } = await mountOpenModal();
|
|
123
|
+
|
|
124
|
+
open.value = false;
|
|
125
|
+
await nextTick();
|
|
126
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
127
|
+
await nextTick();
|
|
128
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
129
|
+
expect(afterLeaveEvents).toHaveLength(1);
|
|
130
|
+
|
|
131
|
+
open.value = true;
|
|
132
|
+
await nextTick();
|
|
133
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
134
|
+
|
|
135
|
+
open.value = false;
|
|
136
|
+
await nextTick();
|
|
137
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
138
|
+
await nextTick();
|
|
139
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
140
|
+
expect(afterLeaveEvents).toHaveLength(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// On a healthy surface (real rAF, no transitionend needed — happy-dom
|
|
144
|
+
// reports no CSS transitions so Vue completes instantly) the real
|
|
145
|
+
// path owns finalization and the watchdog is a silent no-op: exactly
|
|
146
|
+
// one afterLeave per close, no duplicates from the pending timer.
|
|
147
|
+
it("leaves normal completion to the Transition engine without double-emitting", async () => {
|
|
148
|
+
vi.useFakeTimers();
|
|
149
|
+
const { open, afterLeaveEvents } = await mountOpenModal();
|
|
150
|
+
|
|
151
|
+
open.value = false;
|
|
152
|
+
await nextTick();
|
|
153
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
154
|
+
await nextTick();
|
|
155
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
156
|
+
expect(afterLeaveEvents).toHaveLength(1);
|
|
157
|
+
|
|
158
|
+
open.value = true;
|
|
159
|
+
await nextTick();
|
|
160
|
+
open.value = false;
|
|
161
|
+
await nextTick();
|
|
162
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
163
|
+
await nextTick();
|
|
164
|
+
expect(afterLeaveEvents).toHaveLength(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Contract pin: the watchdog must stay armed on the close path and
|
|
168
|
+
// its budget must stay bigger than any themed CSS leave
|
|
169
|
+
// (--hk-modal-duration defaults to 0.25s; a theme may raise it). A
|
|
170
|
+
// refactor that drops the arming — or shrinks the budget under the
|
|
171
|
+
// CSS timing — silently re-opens the frozen-modal failure mode.
|
|
172
|
+
it("pins the watchdog wiring and budget in the source", () => {
|
|
173
|
+
const src = readFileSync(join(here, "HkModal.tsx"), "utf-8");
|
|
174
|
+
expect(src).toContain("const LEAVE_WATCHDOG_MS = 600;");
|
|
175
|
+
expect(src).toContain("if (shouldRender.value) armLeaveWatchdog();");
|
|
176
|
+
expect(src).toContain("disarmLeaveWatchdog();");
|
|
177
|
+
expect(src).toContain("onAfterLeaveFinalize();");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -194,6 +194,41 @@ export default defineComponent({
|
|
|
194
194
|
let previouslyFocused: HTMLElement | null = null;
|
|
195
195
|
let unmounted = false;
|
|
196
196
|
|
|
197
|
+
// ── Leave-completion watchdog ─────────────────────────────────────
|
|
198
|
+
// The close path hands unmounting to <Transition>'s leave, whose
|
|
199
|
+
// engine is rAF-driven: it double-raf's the leave-from → leave-to
|
|
200
|
+
// class flip and only THEN arms its transitionend wait. In an
|
|
201
|
+
// occluded/backgrounded webview rAF can starve for the whole leave
|
|
202
|
+
// window, the classes freeze in leave-from/leave-active, and the
|
|
203
|
+
// modal stays over the page forever — undismissable, only a full
|
|
204
|
+
// reload escaped it (field-reported 2026-09). The watchdog bounds
|
|
205
|
+
// the wait: if the leave has not finalized within a budget larger
|
|
206
|
+
// than any themed CSS leave (--hk-modal-duration defaults to 0.25s,
|
|
207
|
+
// themes may raise it), onAfterLeaveFinalize runs the exact same
|
|
208
|
+
// finalization the Transition would have. Normal closes disarm it;
|
|
209
|
+
// late or stale completions (post-watchdog real onAfterLeave, or
|
|
210
|
+
// the open-interrupted leave Vue resolves without a cancelled flag)
|
|
211
|
+
// are no-ops through the finalize and modelValue guards.
|
|
212
|
+
const LEAVE_WATCHDOG_MS = 600;
|
|
213
|
+
let leaveWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
214
|
+
let leaveFinalized = false;
|
|
215
|
+
|
|
216
|
+
function armLeaveWatchdog(): void {
|
|
217
|
+
disarmLeaveWatchdog();
|
|
218
|
+
leaveWatchdog = setTimeout(() => {
|
|
219
|
+
leaveWatchdog = null;
|
|
220
|
+
if (unmounted || props.modelValue || !shouldRender.value) return;
|
|
221
|
+
onAfterLeaveFinalize();
|
|
222
|
+
}, LEAVE_WATCHDOG_MS);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function disarmLeaveWatchdog(): void {
|
|
226
|
+
if (leaveWatchdog !== null) {
|
|
227
|
+
clearTimeout(leaveWatchdog);
|
|
228
|
+
leaveWatchdog = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
198
233
|
const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
|
|
199
234
|
const resolvedWidth = computed(() => resolveModalWidth(props.width));
|
|
@@ -275,7 +310,15 @@ export default defineComponent({
|
|
|
275
310
|
}
|
|
276
311
|
}
|
|
277
312
|
|
|
278
|
-
function
|
|
313
|
+
function onAfterLeaveFinalize() {
|
|
314
|
+
// Finalizing while the surface is OPEN means this is a stale
|
|
315
|
+
// completion: Vue fires the interrupted leave's onAfterLeave (no
|
|
316
|
+
// cancelled flag) when a reopen patches over a still-live leave,
|
|
317
|
+
// and the watchdog callback separately guards on props.modelValue.
|
|
318
|
+
// Finalizing either of those would tear down the reopened modal.
|
|
319
|
+
if (unmounted || props.modelValue || leaveFinalized) return;
|
|
320
|
+
leaveFinalized = true;
|
|
321
|
+
disarmLeaveWatchdog();
|
|
279
322
|
if (handle.value) {
|
|
280
323
|
manager.unregister(handle.value.id);
|
|
281
324
|
handle.value = null;
|
|
@@ -580,6 +623,8 @@ export default defineComponent({
|
|
|
580
623
|
if (handle.value) {
|
|
581
624
|
manager.unregister(handle.value.id);
|
|
582
625
|
}
|
|
626
|
+
leaveFinalized = false;
|
|
627
|
+
disarmLeaveWatchdog();
|
|
583
628
|
shouldRender.value = true;
|
|
584
629
|
// Windows always block, so the kind alone lists this layer in
|
|
585
630
|
// the modal-stack breadcrumb on every form factor.
|
|
@@ -594,6 +639,12 @@ export default defineComponent({
|
|
|
594
639
|
// (e.g. immediate), clean up now.
|
|
595
640
|
overlay.close();
|
|
596
641
|
backGuard.release();
|
|
642
|
+
// Bound the Transition leave: if rAF starvation froze the
|
|
643
|
+
// class flip, the watchdog finalizes in the watchdog's place
|
|
644
|
+
// (see LEAVE_WATCHDOG_MS above). Only an actually-mounted
|
|
645
|
+
// surface can stall — a never-opened modal has nothing to
|
|
646
|
+
// finalize.
|
|
647
|
+
if (shouldRender.value) armLeaveWatchdog();
|
|
597
648
|
}
|
|
598
649
|
},
|
|
599
650
|
{ immediate: true },
|
|
@@ -626,6 +677,7 @@ export default defineComponent({
|
|
|
626
677
|
|
|
627
678
|
onBeforeUnmount(() => {
|
|
628
679
|
unmounted = true;
|
|
680
|
+
disarmLeaveWatchdog();
|
|
629
681
|
detachBodyScrollbar();
|
|
630
682
|
teardownWindowed();
|
|
631
683
|
teardownAutoFollow();
|
|
@@ -709,7 +761,7 @@ export default defineComponent({
|
|
|
709
761
|
}}
|
|
710
762
|
onAfterLeave={() => {
|
|
711
763
|
contentHooks.onAfterLeave();
|
|
712
|
-
|
|
764
|
+
onAfterLeaveFinalize();
|
|
713
765
|
}}
|
|
714
766
|
>
|
|
715
767
|
{props.modelValue && (
|
|
@@ -2,17 +2,6 @@
|
|
|
2
2
|
position: relative;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
.s-protocol-modal-copied {
|
|
6
|
-
position: absolute;
|
|
7
|
-
top: -0.25rem;
|
|
8
|
-
right: 0;
|
|
9
|
-
margin: 0;
|
|
10
|
-
padding: var(--space-4, 0.25rem) var(--space-8, 0.5rem);
|
|
11
|
-
border-radius: var(--radius-sm, 6px);
|
|
12
|
-
background: rgb(var(--color-success) / 15%);
|
|
13
|
-
color: rgb(var(--color-success));
|
|
14
|
-
font-size: var(--text-xs, 0.75rem);
|
|
15
|
-
}
|
|
16
5
|
|
|
17
6
|
/* With `bodyHeight` set the body becomes its own scroll region (nested
|
|
18
7
|
* inside HkModal's body scroller) — overlay chrome only. */
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
HMarkdownRenderer,
|
|
4
|
+
HModal,
|
|
5
|
+
useClipboardWithToast,
|
|
6
|
+
useToast,
|
|
7
|
+
type ModalAction,
|
|
8
|
+
type ModalWidth,
|
|
9
|
+
} from "@celestia-island/hikari";
|
|
3
10
|
|
|
4
11
|
import { useI18n } from "../i18n/context";
|
|
5
12
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
@@ -40,8 +47,7 @@ export const HkProtocolModal = defineComponent({
|
|
|
40
47
|
},
|
|
41
48
|
setup(props, { emit }) {
|
|
42
49
|
const { t } = useI18n();
|
|
43
|
-
const clipboard =
|
|
44
|
-
const copied = computed(() => clipboard.copied.value);
|
|
50
|
+
const clipboard = useClipboardWithToast(useToast());
|
|
45
51
|
|
|
46
52
|
const footerActions = computed<ModalAction[]>(() => [
|
|
47
53
|
{
|
|
@@ -103,7 +109,6 @@ export const HkProtocolModal = defineComponent({
|
|
|
103
109
|
class="s-protocol-modal"
|
|
104
110
|
style={props.bodyHeight ? { maxHeight: props.bodyHeight } : undefined}
|
|
105
111
|
>
|
|
106
|
-
{copied.value && <p class="s-protocol-modal-copied">{t("hikari::protocol.copied", "Copied")}</p>}
|
|
107
112
|
<HMarkdownRenderer content={props.content} />
|
|
108
113
|
</div>
|
|
109
114
|
</HModal>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { defineComponent
|
|
2
|
-
import { TriangleAlert as AlertTriangle,
|
|
3
|
-
import { HModal,
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
import { TriangleAlert as AlertTriangle, Copy } from "lucide-vue-next";
|
|
3
|
+
import { HModal, useClipboardWithToast, useI18n, useToast } from "@celestia-island/hikari";
|
|
4
4
|
|
|
5
5
|
import "./HkSecretRevealModal.scss";
|
|
6
6
|
|
|
@@ -9,9 +9,10 @@ import "./HkSecretRevealModal.scss";
|
|
|
9
9
|
* HkSecretRevealModal — show-once secret / API-key reveal panel.
|
|
10
10
|
*
|
|
11
11
|
* Displays a one-time secret (API key, token, password) with a "shown
|
|
12
|
-
* only once" notice and a copy button. `copied` fires on successful copy
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* only once" notice and a copy button. `copied` fires on successful copy;
|
|
13
|
+
* the copy itself is confirmed via a localized toast (the button label
|
|
14
|
+
* stays stable). The value is a prop: the caller decides how to
|
|
15
|
+
* obtain/discard it (e.g. clear it once the modal closes).
|
|
15
16
|
*/
|
|
16
17
|
export const HkSecretRevealModal = defineComponent({
|
|
17
18
|
name: "HkSecretRevealModal",
|
|
@@ -33,23 +34,12 @@ export const HkSecretRevealModal = defineComponent({
|
|
|
33
34
|
},
|
|
34
35
|
setup(props, { emit }) {
|
|
35
36
|
const { t } = useI18n();
|
|
36
|
-
const clipboard =
|
|
37
|
-
const copied = ref(false);
|
|
38
|
-
|
|
39
|
-
watch(
|
|
40
|
-
() => props.modelValue,
|
|
41
|
-
(open) => {
|
|
42
|
-
if (open) copied.value = false;
|
|
43
|
-
},
|
|
44
|
-
);
|
|
37
|
+
const clipboard = useClipboardWithToast(useToast());
|
|
45
38
|
|
|
46
39
|
async function handleCopy() {
|
|
47
40
|
if (!props.value) return;
|
|
48
41
|
const ok = await clipboard.copy(props.value);
|
|
49
|
-
if (ok)
|
|
50
|
-
copied.value = true;
|
|
51
|
-
emit("copied");
|
|
52
|
-
}
|
|
42
|
+
if (ok) emit("copied");
|
|
53
43
|
}
|
|
54
44
|
|
|
55
45
|
return () => (
|
|
@@ -76,12 +66,11 @@ export const HkSecretRevealModal = defineComponent({
|
|
|
76
66
|
<button
|
|
77
67
|
type="button"
|
|
78
68
|
class="s-secret-modal-copy"
|
|
79
|
-
data-copied={copied.value || undefined}
|
|
80
69
|
onClick={() => void handleCopy()}
|
|
81
70
|
disabled={!props.value}
|
|
82
71
|
>
|
|
83
|
-
|
|
84
|
-
{
|
|
72
|
+
<Copy size={14} />
|
|
73
|
+
{props.copyLabel ?? t("hikari::secret.copy")}
|
|
85
74
|
</button>
|
|
86
75
|
</div>
|
|
87
76
|
</div>
|
|
@@ -222,3 +222,33 @@
|
|
|
222
222
|
margin-right: 2.75rem;
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
+
|
|
226
|
+
/* ── Host-provided row anatomy (item-leading / item-trailing) ───────────
|
|
227
|
+
* When the host renders the item-leading slot, EVERY row carries the
|
|
228
|
+
* fixed icon cell (not just the active row's check), so names align
|
|
229
|
+
* across the list; the cell keeps the unified menu-item icon metrics.
|
|
230
|
+
*/
|
|
231
|
+
.s-theme-item-lead {
|
|
232
|
+
display: inline-flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
justify-content: center;
|
|
235
|
+
gap: 2px;
|
|
236
|
+
color: inherit;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* When the host renders the item-trailing slot, every row reserves a
|
|
240
|
+
* REAL trailing column (the slot owns it — the built-in custom-delete
|
|
241
|
+
* overlay is suppressed), so trailing affordances align across rows
|
|
242
|
+
* instead of floating over the pill's edge. The custom-name clearance
|
|
243
|
+
* for the overlay delete no longer applies in this mode. */
|
|
244
|
+
.s-theme-item-trailing {
|
|
245
|
+
display: inline-flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
gap: var(--space-4, 0.25rem);
|
|
248
|
+
flex-shrink: 0;
|
|
249
|
+
margin-inline-start: var(--space-4, 0.25rem);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.s-theme-item-row[data-trailing="slot"] .s-theme-item-name {
|
|
253
|
+
margin-right: 0;
|
|
254
|
+
}
|
|
@@ -229,3 +229,91 @@ describe("HkThemeToggle color-mode group", () => {
|
|
|
229
229
|
expect(activeBtn?.querySelector(".s-theme-item-check")).toBeTruthy();
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
describe("HkThemeToggle item slots", () => {
|
|
234
|
+
function mountSlotted(
|
|
235
|
+
lead: (scope: { id: string }) => unknown,
|
|
236
|
+
trail: (scope: { id: string }) => unknown,
|
|
237
|
+
): HTMLElement {
|
|
238
|
+
const container = document.createElement("div");
|
|
239
|
+
document.body.appendChild(container);
|
|
240
|
+
const app = createApp({
|
|
241
|
+
render: () =>
|
|
242
|
+
h(HkThemeToggle, {
|
|
243
|
+
externalCustomize: true,
|
|
244
|
+
}, {
|
|
245
|
+
"item-leading": lead,
|
|
246
|
+
"item-trailing": trail,
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
249
|
+
app.mount(container);
|
|
250
|
+
mounts.push({ app, container });
|
|
251
|
+
return container;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
it("renders the leading slot on every row in place of the selected-check cell", async () => {
|
|
255
|
+
const seen: string[] = [];
|
|
256
|
+
const container = mountSlotted(
|
|
257
|
+
(scope) => { seen.push(scope.id); return h("i", { class: "lead-mark" }); },
|
|
258
|
+
() => null,
|
|
259
|
+
);
|
|
260
|
+
await settle();
|
|
261
|
+
openMenu(container);
|
|
262
|
+
await settle();
|
|
263
|
+
|
|
264
|
+
const rows = [...document.body.querySelectorAll<HTMLElement>(".s-theme-menu .s-theme-item-row")];
|
|
265
|
+
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
266
|
+
// Every row — active or not — carries the slot cell; no check cell is
|
|
267
|
+
// rendered alongside (the slot owns the leading zone).
|
|
268
|
+
for (const row of rows) {
|
|
269
|
+
expect(row.querySelector(".s-theme-item-lead .lead-mark")).toBeTruthy();
|
|
270
|
+
expect(row.querySelector(".s-theme-item-check")).toBeNull();
|
|
271
|
+
}
|
|
272
|
+
expect(seen.length).toBe(rows.length);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("reserves the trailing column and suppresses the built-in delete overlay", async () => {
|
|
276
|
+
useTheme().addCustomTheme(anyPresetTokens());
|
|
277
|
+
const container = mountSlotted(
|
|
278
|
+
() => null,
|
|
279
|
+
(scope) => h("i", { class: "trail-mark", "data-id": scope.id }),
|
|
280
|
+
);
|
|
281
|
+
await settle();
|
|
282
|
+
openMenu(container);
|
|
283
|
+
await settle();
|
|
284
|
+
|
|
285
|
+
const rows = [...document.body.querySelectorAll<HTMLElement>(".s-theme-menu .s-theme-item-row")];
|
|
286
|
+
expect(rows.length).toBeGreaterThanOrEqual(3);
|
|
287
|
+
let customRows = 0;
|
|
288
|
+
for (const row of rows) {
|
|
289
|
+
expect(row.hasAttribute("data-trailing")).toBe(true);
|
|
290
|
+
expect(row.getAttribute("data-trailing")).toBe("slot");
|
|
291
|
+
expect(row.querySelector(".s-theme-item-trailing .trail-mark")).toBeTruthy();
|
|
292
|
+
// The slot owns the trailing zone: the built-in overlay is gone
|
|
293
|
+
// even on custom rows.
|
|
294
|
+
expect(row.querySelector(".s-theme-item-delete")).toBeNull();
|
|
295
|
+
if (row.hasAttribute("data-custom")) customRows += 1;
|
|
296
|
+
}
|
|
297
|
+
expect(customRows).toBe(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("scopes each row with its resolved preset definition", async () => {
|
|
301
|
+
const presets: Array<unknown> = [];
|
|
302
|
+
const container = mountSlotted(
|
|
303
|
+
(scope) => { presets.push((scope as { preset?: unknown }).preset); return null; },
|
|
304
|
+
() => null,
|
|
305
|
+
);
|
|
306
|
+
await settle();
|
|
307
|
+
openMenu(container);
|
|
308
|
+
await settle();
|
|
309
|
+
|
|
310
|
+
// Built-in rows resolve to their preset table entry (tokens present).
|
|
311
|
+
expect(presets.length).toBeGreaterThanOrEqual(2);
|
|
312
|
+
for (const preset of presets) {
|
|
313
|
+
const p = preset as { dark?: { primary?: unknown }; light?: { primary?: unknown } } | undefined;
|
|
314
|
+
expect(p).toBeTruthy();
|
|
315
|
+
expect(p!.dark?.primary).toBeTruthy();
|
|
316
|
+
expect(p!.light?.primary).toBeTruthy();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -4,10 +4,13 @@ import {
|
|
|
4
4
|
HDivider,
|
|
5
5
|
HPopover,
|
|
6
6
|
solarAltitude,
|
|
7
|
+
themePresets,
|
|
7
8
|
useI18n,
|
|
8
9
|
useTheme,
|
|
10
|
+
type CustomThemePreset,
|
|
9
11
|
type PopupPlacement,
|
|
10
12
|
type ThemeId,
|
|
13
|
+
type ThemePreset,
|
|
11
14
|
} from "@celestia-island/hikari";
|
|
12
15
|
|
|
13
16
|
import { HColorSchemeDialog, type HCustomTheme } from "./HkColorSchemeDialog";
|
|
@@ -19,6 +22,18 @@ import "./HkThemeToggle.scss";
|
|
|
19
22
|
* refresh) on every altitude tick. */
|
|
20
23
|
const AUTO_MERGE_KEYS = ["light", "dark"];
|
|
21
24
|
|
|
25
|
+
/** Scoped payload handed to the `item-leading` / `item-trailing` slots:
|
|
26
|
+
* one object per theme row, carrying enough identity for the host to
|
|
27
|
+
* render swatches or affordances without re-deriving the preset. */
|
|
28
|
+
export interface ThemeItemScope {
|
|
29
|
+
id: ThemeId;
|
|
30
|
+
name: string;
|
|
31
|
+
isCustom: boolean;
|
|
32
|
+
/** The row's full definition — the built-in preset table entry for
|
|
33
|
+
* stock/registered themes, the user's stored scheme for customs. */
|
|
34
|
+
preset: ThemePreset | CustomThemePreset | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
/**
|
|
23
38
|
* HkThemeToggle — light/dark/auto theme control over hikari's theme engine.
|
|
24
39
|
*
|
|
@@ -28,6 +43,15 @@ const AUTO_MERGE_KEYS = ["light", "dark"];
|
|
|
28
43
|
* color-mode group and preset/custom theme selection (custom themes are
|
|
29
44
|
* removable), and opens HColorSchemeDialog to create a new custom scheme.
|
|
30
45
|
*
|
|
46
|
+
* Theme rows are host-customizable through two scoped slots (both render
|
|
47
|
+
* in the popover AND the mobile bottom sheet — same DOM):
|
|
48
|
+
* · `item-leading` — replaces the selected-check cell; renders for
|
|
49
|
+
* every row in the fixed icon cell so names align regardless of the
|
|
50
|
+
* active row. Scope: `ThemeItemScope` (id/name/isCustom/preset).
|
|
51
|
+
* · `item-trailing` — reserves a trailing column on every row and owns
|
|
52
|
+
* it entirely; the built-in custom-delete overlay is suppressed, so
|
|
53
|
+
* the host renders delete/edit itself where it wants them.
|
|
54
|
+
*
|
|
31
55
|
* Color-mode group: the unified HTabs strip in segmented (radiogroup)
|
|
32
56
|
* working mode (Auto | Light | Dark) — same pill chrome as every other
|
|
33
57
|
* group. In AUTO mode the Light/Dark halves merge into the strip's
|
|
@@ -58,7 +82,15 @@ export const HkThemeToggle = defineComponent({
|
|
|
58
82
|
},
|
|
59
83
|
setup(props, { emit, slots }) {
|
|
60
84
|
const { t } = useI18n();
|
|
61
|
-
const { currentTheme, currentMode, effectiveMode, geo, setTheme, setMode, toggleMode, allThemeList, addCustomTheme, removeCustomTheme } = useTheme();
|
|
85
|
+
const { currentTheme, currentMode, effectiveMode, geo, setTheme, setMode, toggleMode, allThemeList, addCustomTheme, removeCustomTheme, customThemes } = useTheme();
|
|
86
|
+
|
|
87
|
+
/** Resolve a row's full definition for the item slots: the live
|
|
88
|
+
* preset table first (stock + registered brand themes), then the
|
|
89
|
+
* user's stored custom schemes. */
|
|
90
|
+
function presetOf(id: ThemeId): ThemePreset | CustomThemePreset | undefined {
|
|
91
|
+
if (id in themePresets) return themePresets[id as keyof typeof themePresets];
|
|
92
|
+
return customThemes.value.find((c) => c.id === id);
|
|
93
|
+
}
|
|
62
94
|
|
|
63
95
|
const menuOpen = ref(false);
|
|
64
96
|
const triggerRef = ref<HTMLElement | null>(null);
|
|
@@ -227,37 +259,61 @@ export const HkThemeToggle = defineComponent({
|
|
|
227
259
|
<HDivider spacing="md" />
|
|
228
260
|
|
|
229
261
|
<div class="s-theme-menu-label">{t("hikari::theme.themes")}</div>
|
|
230
|
-
{allThemeList.value.map((th) =>
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
262
|
+
{allThemeList.value.map((th) => {
|
|
263
|
+
// Host-customizable row anatomy. `item-leading` replaces the
|
|
264
|
+
// selected-check cell and renders for EVERY row (fixed icon
|
|
265
|
+
// cell, so names stay aligned); `item-trailing` reserves a
|
|
266
|
+
// real trailing column for every row and takes full
|
|
267
|
+
// ownership of the affordances there — the built-in custom
|
|
268
|
+
// delete overlay is suppressed in that mode, so the host
|
|
269
|
+
// renders delete (and anything else) itself. Without the
|
|
270
|
+
// slots the rows behave exactly as before: check only on
|
|
271
|
+
// the active row, delete overlaid on custom rows only.
|
|
272
|
+
const scope: ThemeItemScope = {
|
|
273
|
+
id: th.id,
|
|
274
|
+
name: th.name,
|
|
275
|
+
isCustom: th.isCustom,
|
|
276
|
+
preset: presetOf(th.id),
|
|
277
|
+
};
|
|
278
|
+
const leadingSlot = slots["item-leading"];
|
|
279
|
+
const trailingSlot = slots["item-trailing"];
|
|
280
|
+
return (
|
|
281
|
+
<div
|
|
282
|
+
key={th.id}
|
|
283
|
+
class="s-theme-item-row"
|
|
284
|
+
data-custom={th.isCustom || undefined}
|
|
285
|
+
data-trailing={trailingSlot ? "slot" : undefined}
|
|
243
286
|
>
|
|
244
|
-
{currentTheme.value === th.id && (
|
|
245
|
-
<span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
|
|
246
|
-
)}
|
|
247
|
-
<span class="s-theme-item-name">{th.name}</span>
|
|
248
|
-
</button>
|
|
249
|
-
{th.isCustom && (
|
|
250
287
|
<button
|
|
251
288
|
type="button"
|
|
252
|
-
class="s-theme-item-
|
|
253
|
-
|
|
254
|
-
onClick={() =>
|
|
289
|
+
class="s-theme-item-btn"
|
|
290
|
+
data-active={currentTheme.value === th.id || undefined}
|
|
291
|
+
onClick={() => onSelectTheme(th.id)}
|
|
255
292
|
>
|
|
256
|
-
|
|
293
|
+
{leadingSlot ? (
|
|
294
|
+
<span class="hk-menu-item-icon s-theme-item-lead">
|
|
295
|
+
{leadingSlot(scope)}
|
|
296
|
+
</span>
|
|
297
|
+
) : currentTheme.value === th.id ? (
|
|
298
|
+
<span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
|
|
299
|
+
) : null}
|
|
300
|
+
<span class="s-theme-item-name">{th.name}</span>
|
|
257
301
|
</button>
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
302
|
+
{trailingSlot ? (
|
|
303
|
+
<span class="s-theme-item-trailing">{trailingSlot(scope)}</span>
|
|
304
|
+
) : th.isCustom ? (
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
class="s-theme-item-delete"
|
|
308
|
+
title={t("hikari::theme.deleteTheme")}
|
|
309
|
+
onClick={() => removeCustomTheme(th.id)}
|
|
310
|
+
>
|
|
311
|
+
<Trash2 size={12} />
|
|
312
|
+
</button>
|
|
313
|
+
) : null}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
})}
|
|
261
317
|
|
|
262
318
|
<button
|
|
263
319
|
type="button"
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "رفض",
|
|
11
11
|
"hikari::protocol.copy": "نسخ",
|
|
12
12
|
"hikari::protocol.copied": "تم النسخ",
|
|
13
|
+
"hikari::clipboard.copied": "تم النسخ",
|
|
14
|
+
"hikari::clipboard.copyFailed": "فشل النسخ",
|
|
13
15
|
"hikari::about.title": "حول",
|
|
14
16
|
"hikari::about.version": "الإصدار",
|
|
15
17
|
"hikari::about.buildHash": "البناء",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "Ablehnen",
|
|
11
11
|
"hikari::protocol.copy": "Kopieren",
|
|
12
12
|
"hikari::protocol.copied": "Kopiert",
|
|
13
|
+
"hikari::clipboard.copied": "Kopiert",
|
|
14
|
+
"hikari::clipboard.copyFailed": "Kopieren fehlgeschlagen",
|
|
13
15
|
"hikari::about.title": "Über",
|
|
14
16
|
"hikari::about.version": "Version",
|
|
15
17
|
"hikari::about.buildHash": "Build",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "Decline",
|
|
11
11
|
"hikari::protocol.copy": "Copy",
|
|
12
12
|
"hikari::protocol.copied": "Copied",
|
|
13
|
+
"hikari::clipboard.copied": "Copied",
|
|
14
|
+
"hikari::clipboard.copyFailed": "Copy failed",
|
|
13
15
|
"hikari::about.title": "About",
|
|
14
16
|
"hikari::about.version": "Version",
|
|
15
17
|
"hikari::about.buildHash": "Build",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "Rechazar",
|
|
11
11
|
"hikari::protocol.copy": "Copiar",
|
|
12
12
|
"hikari::protocol.copied": "Copiado",
|
|
13
|
+
"hikari::clipboard.copied": "Copiado",
|
|
14
|
+
"hikari::clipboard.copyFailed": "Error al copiar",
|
|
13
15
|
"hikari::about.title": "Acerca de",
|
|
14
16
|
"hikari::about.version": "Versión",
|
|
15
17
|
"hikari::about.buildHash": "Compilación",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "Refuser",
|
|
11
11
|
"hikari::protocol.copy": "Copier",
|
|
12
12
|
"hikari::protocol.copied": "Copié",
|
|
13
|
+
"hikari::clipboard.copied": "Copié",
|
|
14
|
+
"hikari::clipboard.copyFailed": "Échec de la copie",
|
|
13
15
|
"hikari::about.title": "À propos",
|
|
14
16
|
"hikari::about.version": "Version",
|
|
15
17
|
"hikari::about.buildHash": "Build",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "拒否",
|
|
11
11
|
"hikari::protocol.copy": "コピー",
|
|
12
12
|
"hikari::protocol.copied": "コピーしました",
|
|
13
|
+
"hikari::clipboard.copied": "コピーしました",
|
|
14
|
+
"hikari::clipboard.copyFailed": "コピーに失敗しました",
|
|
13
15
|
"hikari::about.title": "このアプリについて",
|
|
14
16
|
"hikari::about.version": "バージョン",
|
|
15
17
|
"hikari::about.buildHash": "ビルド",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "거부",
|
|
11
11
|
"hikari::protocol.copy": "복사",
|
|
12
12
|
"hikari::protocol.copied": "복사됨",
|
|
13
|
+
"hikari::clipboard.copied": "복사됨",
|
|
14
|
+
"hikari::clipboard.copyFailed": "복사 실패",
|
|
13
15
|
"hikari::about.title": "정보",
|
|
14
16
|
"hikari::about.version": "버전",
|
|
15
17
|
"hikari::about.buildHash": "빌드",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "Recusar",
|
|
11
11
|
"hikari::protocol.copy": "Copiar",
|
|
12
12
|
"hikari::protocol.copied": "Copiado",
|
|
13
|
+
"hikari::clipboard.copied": "Copiado",
|
|
14
|
+
"hikari::clipboard.copyFailed": "Falha ao copiar",
|
|
13
15
|
"hikari::about.title": "Sobre",
|
|
14
16
|
"hikari::about.version": "Versão",
|
|
15
17
|
"hikari::about.buildHash": "Build",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "Отклонить",
|
|
11
11
|
"hikari::protocol.copy": "Копировать",
|
|
12
12
|
"hikari::protocol.copied": "Скопировано",
|
|
13
|
+
"hikari::clipboard.copied": "Скопировано",
|
|
14
|
+
"hikari::clipboard.copyFailed": "Не удалось скопировать",
|
|
13
15
|
"hikari::about.title": "О программе",
|
|
14
16
|
"hikari::about.version": "Версия",
|
|
15
17
|
"hikari::about.buildHash": "Сборка",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "拒绝",
|
|
11
11
|
"hikari::protocol.copy": "复制",
|
|
12
12
|
"hikari::protocol.copied": "已复制",
|
|
13
|
+
"hikari::clipboard.copied": "已复制",
|
|
14
|
+
"hikari::clipboard.copyFailed": "复制失败",
|
|
13
15
|
"hikari::about.title": "关于",
|
|
14
16
|
"hikari::about.version": "版本",
|
|
15
17
|
"hikari::about.buildHash": "构建",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"hikari::protocol.decline": "拒絕",
|
|
11
11
|
"hikari::protocol.copy": "複製",
|
|
12
12
|
"hikari::protocol.copied": "已複製",
|
|
13
|
+
"hikari::clipboard.copied": "已複製",
|
|
14
|
+
"hikari::clipboard.copyFailed": "複製失敗",
|
|
13
15
|
"hikari::about.title": "關於",
|
|
14
16
|
"hikari::about.version": "版本",
|
|
15
17
|
"hikari::about.buildHash": "建置",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { useClipboardWithToast } from "./useClipboard";
|
|
4
|
+
|
|
5
|
+
// `navigator.clipboard` does not exist under happy-dom — install a
|
|
6
|
+
// controllable stub and restore whatever was there before.
|
|
7
|
+
let writeText: ReturnType<typeof vi.fn>;
|
|
8
|
+
const originalClipboard = navigator.clipboard;
|
|
9
|
+
const originalExecCommand = document.execCommand;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
writeText = vi.fn();
|
|
13
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
14
|
+
value: { writeText },
|
|
15
|
+
configurable: true,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
21
|
+
value: originalClipboard,
|
|
22
|
+
configurable: true,
|
|
23
|
+
});
|
|
24
|
+
document.execCommand = originalExecCommand;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("useClipboardWithToast", () => {
|
|
28
|
+
it("toasts the localized default message and resolves true on success", async () => {
|
|
29
|
+
const toast = { success: vi.fn(), error: vi.fn() };
|
|
30
|
+
const { copy } = useClipboardWithToast(toast);
|
|
31
|
+
|
|
32
|
+
const ok = await copy("secret");
|
|
33
|
+
|
|
34
|
+
expect(ok).toBe(true);
|
|
35
|
+
expect(writeText).toHaveBeenCalledWith("secret");
|
|
36
|
+
expect(toast.success).toHaveBeenCalledWith("Copied");
|
|
37
|
+
expect(toast.error).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("prefers the per-call message, then the call-site default thunk", async () => {
|
|
41
|
+
const toast = { success: vi.fn(), error: vi.fn() };
|
|
42
|
+
const { copy } = useClipboardWithToast(toast, () => "site default");
|
|
43
|
+
|
|
44
|
+
await copy("a", "per call");
|
|
45
|
+
await copy("b");
|
|
46
|
+
|
|
47
|
+
expect(toast.success).toHaveBeenNthCalledWith(1, "per call");
|
|
48
|
+
expect(toast.success).toHaveBeenNthCalledWith(2, "site default");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("toasts the localized failure message when the clipboard rejects", async () => {
|
|
52
|
+
writeText.mockRejectedValue(new Error("denied"));
|
|
53
|
+
// The base composable falls back to a textarea + execCommand path;
|
|
54
|
+
// force that fallback to fail too so the error toast fires.
|
|
55
|
+
document.execCommand = vi.fn(() => false) as unknown as typeof document.execCommand;
|
|
56
|
+
|
|
57
|
+
const toast = { success: vi.fn(), error: vi.fn() };
|
|
58
|
+
const { copy } = useClipboardWithToast(toast);
|
|
59
|
+
|
|
60
|
+
const ok = await copy("secret");
|
|
61
|
+
|
|
62
|
+
expect(ok).toBe(false);
|
|
63
|
+
expect(toast.error).toHaveBeenCalledWith("Copy failed");
|
|
64
|
+
expect(toast.success).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ref } from "vue";
|
|
2
2
|
|
|
3
|
+
import { useI18n } from "../i18n/context";
|
|
4
|
+
|
|
3
5
|
export function useClipboard() {
|
|
4
6
|
const copied = ref(false);
|
|
5
7
|
|
|
@@ -19,7 +21,10 @@ export function useClipboard() {
|
|
|
19
21
|
document.body.appendChild(textarea);
|
|
20
22
|
textarea.select();
|
|
21
23
|
try {
|
|
22
|
-
|
|
24
|
+
// execCommand reports failure by RETURNING false (it only throws
|
|
25
|
+
// on unsupported commands) — treating that as success surfaced
|
|
26
|
+
// "Copied" toasts for writes that never landed.
|
|
27
|
+
if (!document.execCommand("copy")) return false;
|
|
23
28
|
copied.value = true;
|
|
24
29
|
setTimeout(() => {
|
|
25
30
|
copied.value = false;
|
|
@@ -44,21 +49,31 @@ export function useClipboard() {
|
|
|
44
49
|
* `execCommand` outside secure contexts) and adds toast feedback;
|
|
45
50
|
* `copied` reflects the 2s copied state. The `toast` argument matches
|
|
46
51
|
* hikari's `useToast()` surface (`success` / `error` message pushers).
|
|
52
|
+
*
|
|
53
|
+
* Default toast messages come from hikari's own i18n
|
|
54
|
+
* (`hikari::clipboard.copied` / `hikari::clipboard.copyFailed`) so a
|
|
55
|
+
* bare `useClipboardWithToast(useToast())` is already localized; pass
|
|
56
|
+
* thunks to override per call site. `copy` resolves to whether the
|
|
57
|
+
* clipboard write succeeded.
|
|
47
58
|
*/
|
|
48
59
|
export function useClipboardWithToast(
|
|
49
60
|
toast: { success: (msg: string) => void; error: (msg: string) => void },
|
|
50
61
|
defaultSuccessMessage?: () => string,
|
|
51
62
|
defaultErrorMessage?: () => string,
|
|
52
63
|
) {
|
|
64
|
+
const { t } = useI18n();
|
|
53
65
|
const { copy: baseCopy, copied } = useClipboard();
|
|
54
66
|
|
|
55
|
-
async function copy(text: string, successMessage?: string) {
|
|
67
|
+
async function copy(text: string, successMessage?: string): Promise<boolean> {
|
|
56
68
|
const ok = await baseCopy(text);
|
|
57
69
|
if (ok) {
|
|
58
|
-
toast.success(
|
|
70
|
+
toast.success(
|
|
71
|
+
successMessage ?? defaultSuccessMessage?.() ?? t("hikari::clipboard.copied", "Copied"),
|
|
72
|
+
);
|
|
59
73
|
} else {
|
|
60
|
-
toast.error(defaultErrorMessage?.() ?? "Copy failed");
|
|
74
|
+
toast.error(defaultErrorMessage?.() ?? t("hikari::clipboard.copyFailed", "Copy failed"));
|
|
61
75
|
}
|
|
76
|
+
return ok;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
return { copy, copied };
|