@celestia-island/hikari 0.40.14 → 0.40.16

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.40.14",
3
+ "version": "0.40.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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 { HMarkdownRenderer, HModal, useClipboard, type ModalAction, type ModalWidth } from "@celestia-island/hikari";
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 = useClipboard();
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>
@@ -50,10 +50,6 @@
50
50
  filter: brightness(1.1);
51
51
  }
52
52
 
53
- .s-secret-modal-copy[data-copied] {
54
- background: rgb(var(--color-success));
55
- color: rgb(var(--color-on-solid-text, 255 255 255));
56
- }
57
53
 
58
54
  .s-secret-modal-copy:disabled {
59
55
  opacity: 0.5;
@@ -1,6 +1,6 @@
1
- import { defineComponent, ref, watch } from "vue";
2
- import { TriangleAlert as AlertTriangle, Check, Copy } from "lucide-vue-next";
3
- import { HModal, useClipboard, useI18n } from "@celestia-island/hikari";
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
- * The value is a prop: the caller decides how to obtain/discard it (e.g.
14
- * clear it once the modal closes).
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 = useClipboard();
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
- {copied.value ? <Check size={14} /> : <Copy size={14} />}
84
- {copied.value ? t("hikari::secret.copied") : props.copyLabel ?? t("hikari::secret.copy")}
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
- // One full-width pill per row — the delete affordance for
232
- // custom themes OVERLAYS the pill's trailing edge instead
233
- // of reserving a trailing column, so built-in rows carry
234
- // no dead space (a bottom sheet once showed a permanent
235
- // ~40px empty strip right of every row) and every pill
236
- // and highlight shares the exact same width.
237
- <div key={th.id} class="s-theme-item-row" data-custom={th.isCustom || undefined}>
238
- <button
239
- type="button"
240
- class="s-theme-item-btn"
241
- data-active={currentTheme.value === th.id || undefined}
242
- onClick={() => onSelectTheme(th.id)}
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-delete"
253
- title={t("hikari::theme.deleteTheme")}
254
- onClick={() => removeCustomTheme(th.id)}
289
+ class="s-theme-item-btn"
290
+ data-active={currentTheme.value === th.id || undefined}
291
+ onClick={() => onSelectTheme(th.id)}
255
292
  >
256
- <Trash2 size={12} />
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
- </div>
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": "建置",
package/src/index.ts CHANGED
@@ -303,7 +303,7 @@ export { deepMerge, isPlainObject, getPath, setPath, delPath } from "./utils/obj
303
303
  export { HkAdminShell as HAdminShell } from "./components/HkAdminShell";
304
304
  export { HkAdminHeader as HAdminHeader } from "./components/HkAdminHeader";
305
305
  export { HkNavSidebar as HNavSidebar } from "./components/HkNavSidebar";
306
- export { HkThemeToggle as HThemeToggle } from "./components/HkThemeToggle";
306
+ export { HkThemeToggle as HThemeToggle, type ThemeItemScope } from "./components/HkThemeToggle";
307
307
  export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
308
308
  export { default as HAuthMethodList } from "./components/HkAuthMethodList";
309
309
  export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
@@ -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
- document.execCommand("copy");
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(successMessage ?? defaultSuccessMessage?.() ?? "Copied");
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 };