@celestia-island/hikari 0.4.4 → 0.4.5

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.
Files changed (152) hide show
  1. package/README.md +1 -1
  2. package/package.json +16 -7
  3. package/src/DemoApp.vue +1 -1
  4. package/src/components/HkAdminHeader.tsx +205 -0
  5. package/src/components/HkAdminShell.tsx +85 -0
  6. package/src/components/HkAdminTablePage.tsx +70 -0
  7. package/src/components/HkAuthCard.tsx +36 -0
  8. package/src/components/HkAuthSubmitButton.scss +12 -0
  9. package/src/components/HkAuthSubmitButton.tsx +94 -0
  10. package/src/components/HkAvatar.scss +93 -0
  11. package/src/components/HkAvatar.tsx +1 -1
  12. package/src/components/HkBreadcrumb.scss +624 -0
  13. package/src/components/HkBreadcrumb.tsx +1 -1
  14. package/src/components/HkCard.scss +1 -1
  15. package/src/components/HkCheckbox.tsx +2 -1
  16. package/src/components/HkCollapse.scss +50 -0
  17. package/src/components/HkCollapse.tsx +1 -1
  18. package/src/components/HkColorSchemeDialog.scss +16 -0
  19. package/src/components/HkColorSchemeDialog.tsx +237 -0
  20. package/src/components/HkConnectionInfo.ts +80 -0
  21. package/src/components/HkConnectionStatus.tsx +133 -0
  22. package/src/components/HkCountdownDigit.tsx +97 -0
  23. package/src/components/HkDateTimePicker.tsx +7 -1
  24. package/src/components/HkErrorBoundary.tsx +4 -1
  25. package/src/components/HkIcon.scss +123 -0
  26. package/src/components/HkIcon.tsx +7 -23
  27. package/src/components/HkIconButton.scss +170 -0
  28. package/src/components/HkIconButton.tsx +2 -2
  29. package/src/components/HkIconButtonVars.scss +176 -0
  30. package/src/components/HkInput.scss +22 -3
  31. package/src/components/HkInput.tsx +41 -1
  32. package/src/components/HkKeywordSearchModal.tsx +10 -7
  33. package/src/components/HkLocalePicker.tsx +113 -0
  34. package/src/components/HkMediaControlBar.tsx +14 -7
  35. package/src/components/HkMinimap.tsx +10 -2
  36. package/src/components/HkModal.tsx +2 -23
  37. package/src/components/HkNavItem.tsx +2 -1
  38. package/src/components/HkNavSidebar.tsx +99 -0
  39. package/src/components/HkPageHeader.tsx +40 -0
  40. package/src/components/HkPasswordInput.scss +14 -12
  41. package/src/components/HkPasswordInput.test.ts +242 -0
  42. package/src/components/HkPasswordInput.tsx +236 -41
  43. package/src/components/HkPopupSelect.scss +3 -3
  44. package/src/components/HkPopupSelect.tsx +2 -1
  45. package/src/components/HkSearchInput.tsx +2 -2
  46. package/src/components/HkSecretRevealModal.scss +61 -0
  47. package/src/components/HkSecretRevealModal.tsx +91 -0
  48. package/src/components/HkSelect.scss +4 -4
  49. package/src/components/HkSelect.tsx +2 -0
  50. package/src/components/HkSelectionGrid.tsx +2 -1
  51. package/src/components/HkStatusBar.tsx +235 -0
  52. package/src/components/HkTag.tsx +2 -1
  53. package/src/components/HkThemeToggle.scss +130 -0
  54. package/src/components/HkThemeToggle.tsx +160 -0
  55. package/src/components/HkTimeline.tsx +2 -1
  56. package/src/components/HkToast.tsx +4 -1
  57. package/src/components/HkTrendChart.tsx +27 -16
  58. package/src/components/HkZoomToolbar.tsx +4 -1
  59. package/src/components/_hikari-vars.scss +4 -0
  60. package/src/composables/highlightLanguages.ts +109 -0
  61. package/src/composables/iconRegistry.ts +127 -0
  62. package/src/composables/messaging/browserTransport.ts +96 -0
  63. package/src/composables/messaging/index.ts +204 -0
  64. package/src/composables/messaging/messaging.test.ts +256 -0
  65. package/src/composables/messaging/toastTransport.ts +26 -0
  66. package/src/composables/messaging/types.ts +115 -0
  67. package/src/composables/useActionBar.ts +66 -0
  68. package/src/composables/useConnectionProbe.ts +126 -0
  69. package/src/composables/useDeferredTransition.ts +16 -0
  70. package/src/composables/useEngineHealth.ts +69 -0
  71. package/src/composables/useHighlight.ts +124 -0
  72. package/src/composables/usePageTitle.ts +37 -0
  73. package/src/composables/useReducedMotion.ts +34 -0
  74. package/src/composables/useResourceListModal.tsx +147 -0
  75. package/src/composables/useZoomPan.ts +192 -0
  76. package/src/i18n/locales/ar/components.json +12 -3
  77. package/src/i18n/locales/ar/components.toml +5 -0
  78. package/src/i18n/locales/ar/connection.json +25 -0
  79. package/src/i18n/locales/ar/platform.json +50 -0
  80. package/src/i18n/locales/de/components.json +12 -3
  81. package/src/i18n/locales/de/components.toml +5 -0
  82. package/src/i18n/locales/de/connection.json +25 -0
  83. package/src/i18n/locales/de/platform.json +50 -0
  84. package/src/i18n/locales/en/components.json +12 -3
  85. package/src/i18n/locales/en/components.toml +5 -0
  86. package/src/i18n/locales/en/connection.json +27 -0
  87. package/src/i18n/locales/en/platform.json +53 -0
  88. package/src/i18n/locales/es/components.json +12 -3
  89. package/src/i18n/locales/es/components.toml +5 -0
  90. package/src/i18n/locales/es/connection.json +25 -0
  91. package/src/i18n/locales/es/platform.json +50 -0
  92. package/src/i18n/locales/fr/components.json +12 -3
  93. package/src/i18n/locales/fr/components.toml +5 -0
  94. package/src/i18n/locales/fr/connection.json +25 -0
  95. package/src/i18n/locales/fr/platform.json +50 -0
  96. package/src/i18n/locales/ja/components.json +12 -3
  97. package/src/i18n/locales/ja/components.toml +5 -0
  98. package/src/i18n/locales/ja/connection.json +27 -0
  99. package/src/i18n/locales/ja/platform.json +53 -0
  100. package/src/i18n/locales/ko/components.json +12 -3
  101. package/src/i18n/locales/ko/components.toml +5 -0
  102. package/src/i18n/locales/ko/connection.json +27 -0
  103. package/src/i18n/locales/ko/platform.json +50 -0
  104. package/src/i18n/locales/pt/components.json +12 -3
  105. package/src/i18n/locales/pt/components.toml +5 -0
  106. package/src/i18n/locales/pt/connection.json +25 -0
  107. package/src/i18n/locales/pt/platform.json +50 -0
  108. package/src/i18n/locales/ru/components.json +12 -3
  109. package/src/i18n/locales/ru/components.toml +5 -0
  110. package/src/i18n/locales/ru/connection.json +25 -0
  111. package/src/i18n/locales/ru/platform.json +50 -0
  112. package/src/i18n/locales/zh-Hans/components.json +12 -3
  113. package/src/i18n/locales/zh-Hans/components.toml +5 -0
  114. package/src/i18n/locales/zh-Hans/connection.json +27 -0
  115. package/src/i18n/locales/zh-Hans/platform.json +53 -0
  116. package/src/i18n/locales/zh-Hant/components.json +12 -3
  117. package/src/i18n/locales/zh-Hant/components.toml +5 -0
  118. package/src/i18n/locales/zh-Hant/connection.json +27 -0
  119. package/src/i18n/locales/zh-Hant/platform.json +53 -0
  120. package/src/index.ts +67 -4
  121. package/src/plugins/bootScriptsPlugin.ts +47 -0
  122. package/src/plugins/buildHashPlugin.ts +67 -0
  123. package/src/plugins/index.ts +2 -0
  124. package/src/render.ts +1 -1
  125. package/src/runtime/env.ts +4 -0
  126. package/src/runtime/index.ts +0 -1
  127. package/src/runtime/useConfirm.ts +7 -1
  128. package/src/styles/admin-tokens.scss +634 -0
  129. package/src/styles/index.scss +1 -0
  130. package/src/theme/_field.scss +116 -0
  131. package/src/theme/index.ts +2 -0
  132. package/src/theme/presets.ts +11 -9
  133. package/src/theme/useTheme.ts +54 -5
  134. package/src/tokens.scss +76 -0
  135. package/src/utils/base64.ts +31 -0
  136. package/src/utils/connectivity.ts +51 -0
  137. package/src/utils/dom.ts +39 -0
  138. package/src/utils/download.ts +22 -0
  139. package/src/utils/fileType.ts +127 -0
  140. package/src/utils/fuzzy.ts +77 -0
  141. package/src/utils/fuzzySearch.test.ts +87 -0
  142. package/src/utils/objectPath.ts +59 -0
  143. package/src/utils/password.ts +53 -0
  144. package/src/utils/pow.test.ts +45 -0
  145. package/src/utils/pow.ts +226 -0
  146. package/src/utils/powNonce.ts +83 -0
  147. package/src/utils/validation.test.ts +78 -0
  148. package/src/composables/useBreakpoint.ts +0 -41
  149. package/src/composables/useClipboard.ts +0 -37
  150. package/src/composables/useConfirm.ts +0 -35
  151. package/src/composables/useToast.ts +0 -2
  152. package/src/runtime/useAsyncData.ts +0 -30
@@ -1,17 +1,17 @@
1
- import { defineComponent } from "vue";
2
1
  import { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from "lucide-vue-next";
2
+ import { defineComponent } from "vue";
3
+
4
+
5
+
6
+
7
+
8
+
3
9
 
4
10
  import HButton from "./HkButton";
5
11
  import { useI18n } from "../i18n/context";
6
12
  import HMediaSlider from "./HkMediaSlider";
7
13
  import "./HkMediaControlBar.scss";
8
14
 
9
- export function formatMediaTime(sec: number): string {
10
- if (!Number.isFinite(sec) || sec < 0) sec = 0;
11
- const m = Math.floor(sec / 60);
12
- const s = Math.floor(sec % 60);
13
- return `${m}:${s.toString().padStart(2, "0")}`;
14
- }
15
15
 
16
16
  /**
17
17
  * Shared transport controls for any media controller — the same bar is used
@@ -19,6 +19,13 @@ export function formatMediaTime(sec: number): string {
19
19
  * controller state via props and forwards intents via events. `HMediaSlider`
20
20
  * backs both the seek track and the volume track.
21
21
  */
22
+ export function formatMediaTime(sec: number): string {
23
+ if (!Number.isFinite(sec) || sec < 0) sec = 0;
24
+ const m = Math.floor(sec / 60);
25
+ const s = Math.floor(sec % 60);
26
+ return `${m}:${s.toString().padStart(2, "0")}`;
27
+ }
28
+
22
29
  export default defineComponent({
23
30
  name: "HkMediaControlBar",
24
31
  props: {
@@ -1,5 +1,8 @@
1
- import { computed, defineComponent, onBeforeUnmount, onMounted, ref, type PropType } from "vue";
2
1
  import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
2
+ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, type PropType } from "vue";
3
+
4
+
5
+
3
6
 
4
7
  import { useI18n } from "../i18n/context";
5
8
  import "./HkMinimap.scss";
@@ -35,7 +38,7 @@ export default defineComponent({
35
38
  /** Optional image rendered as the minimap background (image navigator).
36
39
  * When set, the minimap renders even with no boxes. */
37
40
  imageSrc: { type: String, default: undefined },
38
- imageBounds: { type: Object as PropType<MinimapRect>, default: undefined },
41
+ imageBounds: { type: Object as PropType<MinimapRect | undefined>, default: undefined },
39
42
  zoom: { type: Number, default: 1 },
40
43
  panX: { type: Number, default: 0 },
41
44
  panY: { type: Number, default: 0 },
@@ -51,6 +54,11 @@ export default defineComponent({
51
54
  /** Show the reset/fit button in the zoom bar (chest only rendered it
52
55
  * when a reset handler was wired up). */
53
56
  showReset: { type: Boolean, default: false },
57
+ /** Optional prop-callback surface (alternative to the emits). */
58
+ onZoomIn: { type: Function as PropType<() => void>, default: undefined },
59
+ onZoomOut: { type: Function as PropType<() => void>, default: undefined },
60
+ onReset: { type: Function as PropType<() => void>, default: undefined },
61
+ onPanDelta: { type: Function as PropType<(dx: number, dy: number) => void>, default: undefined },
54
62
  },
55
63
  emits: {
56
64
  zoomIn: () => true,
@@ -13,6 +13,7 @@ import {
13
13
 
14
14
  import { useI18n } from "../i18n/context";
15
15
  import "./HkModal.scss";
16
+ import { focusFirst, trapFocus } from "../utils/dom";
16
17
  import { useOverlay } from "../runtime/useOverlay";
17
18
  import { usePopupManager } from "../runtime/usePopupManager";
18
19
  import HButton from "./HkButton";
@@ -29,28 +30,6 @@ export interface ModalAction {
29
30
  const FOCUSABLE_SELECTOR =
30
31
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
31
32
 
32
- function trapFocus(container: HTMLElement, e: KeyboardEvent): void {
33
- const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
34
- if (focusable.length === 0) return;
35
- const first = focusable[0];
36
- const last = focusable[focusable.length - 1];
37
- if (e.shiftKey) {
38
- if (document.activeElement === first) {
39
- e.preventDefault();
40
- last.focus();
41
- }
42
- } else {
43
- if (document.activeElement === last) {
44
- e.preventDefault();
45
- first.focus();
46
- }
47
- }
48
- }
49
-
50
- function focusFirst(container: HTMLElement): void {
51
- const el = container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
52
- if (el) el.focus();
53
- }
54
33
 
55
34
  export default defineComponent({
56
35
  name: "HkModal",
@@ -446,7 +425,7 @@ export default defineComponent({
446
425
  >
447
426
  <polyline points="6 9 12 15 18 9" />
448
427
  </svg>
449
- <span>Auto</span>
428
+ <span>{t("hk.modal.auto", "Auto")}</span>
450
429
  </div>
451
430
  )}
452
431
  </div>
@@ -1,6 +1,7 @@
1
+ import { ChevronRight } from "lucide-vue-next";
1
2
  import { defineComponent, computed, ref, type PropType } from "vue";
2
3
  import type { Component } from "vue";
3
- import { ChevronRight } from "lucide-vue-next";
4
+
4
5
  import "./HkNavItem.scss";
5
6
 
6
7
  interface NavItemChild {
@@ -0,0 +1,99 @@
1
+ import { defineComponent, h, type Component, type PropType } from "vue";
2
+
3
+ import { HNavItem, HSidebar } from "@celestia-island/hikari";
4
+ import { ChartBarBig as BarChart3, Bell, Bot, Box, Cable, Cpu, FileText, FolderOpen, Gauge, Key, Layers, LayoutDashboard, Mic, Monitor, Send, Settings, Share2, Shield, Webhook, Zap } from "lucide-vue-next";
5
+
6
+ interface NavItem {
7
+ key: string;
8
+ icon: string;
9
+ label: string;
10
+ route: string;
11
+ disabled?: boolean;
12
+ badge?: string;
13
+ }
14
+
15
+ const iconMap: Record<string, Component> = {
16
+ chart: LayoutDashboard,
17
+ plug: Cable,
18
+ bot: Bot,
19
+ cpu: Cpu,
20
+ zap: Zap,
21
+ folder: FolderOpen,
22
+ radio: Send,
23
+ monitor: Monitor,
24
+ box: Box,
25
+ link: Webhook,
26
+ key: Key,
27
+ shield: Shield,
28
+ bar: BarChart3,
29
+ gauge: Gauge,
30
+ mic: Mic,
31
+ settings: Settings,
32
+ panels: Layers,
33
+ bell: Bell,
34
+ share: Share2,
35
+ manifest: FileText,
36
+ };
37
+
38
+ export const HkNavSidebar = defineComponent({
39
+ name: "HkNavSidebar",
40
+ props: {
41
+ navItems: {
42
+ type: Array as PropType<NavItem[]>,
43
+ required: true,
44
+ },
45
+ currentRoute: {
46
+ type: String,
47
+ required: true,
48
+ },
49
+ collapsed: {
50
+ type: Boolean,
51
+ default: false,
52
+ },
53
+ },
54
+ emits: ["navigate"],
55
+ setup(props, { emit }) {
56
+ return () => (
57
+ <HSidebar width="224px">
58
+ {{
59
+ default: () => (
60
+ <nav>
61
+ {props.navItems.map((item) => {
62
+ const active = props.currentRoute === item.route;
63
+ const IconComp = iconMap[item.icon];
64
+ return (
65
+ <HNavItem
66
+ key={item.key}
67
+ active={active}
68
+ disabled={item.disabled}
69
+ onClick={() => emit("navigate", item.route)}
70
+ >
71
+ {{
72
+ // Render via h(): Vue's `Component` union includes
73
+ // non-callable option types, so `<IconComp />` is not a
74
+ // valid JSX element type (matches hikari's HkTree).
75
+ icon: () => (IconComp ? h(IconComp, { size: 16 }) : null),
76
+ default: () => (
77
+ <span class="s-nav-item-content">
78
+ <span class="truncate">{item.label}</span>
79
+ {item.badge && (
80
+ <span
81
+ class="s-nav-item-badge"
82
+ aria-label={`${item.badge} pending`}
83
+ >
84
+ {item.badge}
85
+ </span>
86
+ )}
87
+ </span>
88
+ ),
89
+ }}
90
+ </HNavItem>
91
+ );
92
+ })}
93
+ </nav>
94
+ ),
95
+ }}
96
+ </HSidebar>
97
+ );
98
+ },
99
+ });
@@ -0,0 +1,40 @@
1
+ import { defineComponent } from "vue";
2
+
3
+ /**
4
+ * Title + actions page header. Slot `actions` renders on the right.
5
+ */
6
+ export const HkPageHeader = defineComponent({
7
+ name: "HkPageHeader",
8
+ props: {
9
+ title: { type: String, required: true },
10
+ },
11
+ setup(props, { slots }) {
12
+ return () => (
13
+ <div
14
+ style={{
15
+ display: "flex",
16
+ alignItems: "center",
17
+ justifyContent: "space-between",
18
+ gap: "var(--space-12, 0.75rem)",
19
+ marginBottom: "var(--space-16, 1rem)",
20
+ }}
21
+ >
22
+ <h1
23
+ style={{
24
+ margin: 0,
25
+ fontSize: "var(--text-lg, 1.125rem)",
26
+ fontWeight: 700,
27
+ color: "rgb(var(--color-text))",
28
+ }}
29
+ >
30
+ {props.title}
31
+ </h1>
32
+ {slots.actions && (
33
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--space-8, 0.5rem)" }}>
34
+ {slots.actions()}
35
+ </div>
36
+ )}
37
+ </div>
38
+ );
39
+ },
40
+ });
@@ -22,9 +22,8 @@
22
22
  align-items: center;
23
23
  min-height: 2.5rem;
24
24
  padding: 0.5rem 0.75rem;
25
- background: var(--hi-color-surface, rgba(255, 255, 255, 0.95));
26
- backdrop-filter: blur(8px);
27
- border: 1px solid var(--hi-color-border, rgba(0, 0, 0, 0.08));
25
+ background: color-mix(in srgb, rgb(var(--color-surface)) 55%, transparent);
26
+ border: 1px solid color-mix(in srgb, rgb(var(--color-text)) 14%, transparent);
28
27
  border-radius: 6px;
29
28
  overflow: hidden;
30
29
  transition:
@@ -84,9 +83,7 @@
84
83
  display: flex;
85
84
  align-items: center;
86
85
  justify-content: center;
87
- transition:
88
- color 0.3s ease,
89
- filter 0.3s ease;
86
+ transition: color 0.3s ease;
90
87
  }
91
88
 
92
89
  .hk-pwd-lock-empty {
@@ -103,12 +100,10 @@
103
100
  .hk-pwd-box:focus-within .hk-pwd-lock,
104
101
  .hk-pwd-box[data-focused] .hk-pwd-lock {
105
102
  color: var(--hi-color-primary, #58a6ff);
106
- filter: drop-shadow(0 0 5px currentColor);
107
103
  }
108
104
 
109
105
  .hk-pwd-lock[data-revealing] {
110
106
  color: var(--hi-color-primary, #58a6ff);
111
- filter: drop-shadow(0 0 6px currentColor);
112
107
  }
113
108
 
114
109
  .hk-pwd-dots {
@@ -127,7 +122,6 @@
127
122
  align-items: center;
128
123
  justify-content: center;
129
124
  z-index: 1;
130
- pointer-events: none;
131
125
  user-select: none;
132
126
  font-size: 0.875rem;
133
127
  color: var(--hi-color-primary, rgba(88, 166, 255, 0.7));
@@ -135,7 +129,10 @@
135
129
  }
136
130
 
137
131
  .hk-pwd-box[data-focused] .hk-pwd-placeholder {
138
- color: var(--hi-color-primary, rgba(88, 166, 255, 0.85));
132
+ /* Waiting-for-input placeholder turns italic + grey while focused,
133
+ matching the blur/select hints' style. */
134
+ font-style: italic;
135
+ color: var(--hi-color-text-secondary, #666);
139
136
  }
140
137
 
141
138
  @keyframes hk-pwd-breathe {
@@ -151,11 +148,12 @@
151
148
  justify-content: center;
152
149
  z-index: 1;
153
150
  user-select: none;
154
- font-size: 0.8125rem;
151
+ font-size: 0.875rem;
155
152
  font-style: italic;
156
153
  color: var(--hi-color-text-secondary, #666);
157
154
  cursor: pointer;
158
155
  transition: color 0.3s ease;
156
+ animation: hk-pwd-breathe 3s ease-in-out infinite;
159
157
  }
160
158
 
161
159
  .hk-pwd-blur-hint:hover {
@@ -174,10 +172,11 @@
174
172
  justify-content: center;
175
173
  z-index: 1;
176
174
  user-select: none;
177
- font-size: 0.8125rem;
175
+ font-size: 0.875rem;
178
176
  font-style: italic;
179
177
  color: var(--hi-color-text-secondary, #666);
180
178
  pointer-events: none;
179
+ animation: hk-pwd-breathe 3s ease-in-out infinite;
181
180
  }
182
181
 
183
182
  .hk-pwd-reveal-text {
@@ -204,6 +203,9 @@
204
203
  cursor: text;
205
204
  font-size: 1rem;
206
205
  z-index: 2;
206
+ appearance: none;
207
+ -webkit-appearance: none;
208
+ border-radius: 0;
207
209
  }
208
210
 
209
211
  .hk-pwd-strength {
@@ -0,0 +1,242 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick, ref, type Slot } from "vue";
3
+
4
+ import HkPasswordInput from "./HkPasswordInput";
5
+
6
+ interface Mounted {
7
+ model: ReturnType<typeof ref<string>>;
8
+ app: ReturnType<typeof createApp>;
9
+ container: HTMLElement;
10
+ input: HTMLInputElement;
11
+ }
12
+
13
+ const mounts: Mounted[] = [];
14
+
15
+ function mountPasswordInput(
16
+ initial = "",
17
+ props: Record<string, unknown> = {},
18
+ slots: Record<string, Slot> = {},
19
+ ): Mounted {
20
+ const model = ref(initial);
21
+ const container = document.createElement("div");
22
+ document.body.appendChild(container);
23
+ const app = createApp({
24
+ render() {
25
+ return h(
26
+ HkPasswordInput,
27
+ {
28
+ ...props,
29
+ modelValue: model.value,
30
+ "onUpdate:modelValue": (v: string) => {
31
+ model.value = v;
32
+ },
33
+ },
34
+ slots,
35
+ );
36
+ },
37
+ });
38
+ app.mount(container);
39
+ const input = container.querySelector(".hk-pwd-input") as HTMLInputElement;
40
+ const mounted = { model, app, container, input };
41
+ mounts.push(mounted);
42
+ return mounted;
43
+ }
44
+
45
+ function placeholderText(container: HTMLElement): string | null {
46
+ return container.querySelector(".hk-pwd-placeholder")?.textContent ?? null;
47
+ }
48
+
49
+ function blurHintText(container: HTMLElement): string | null {
50
+ return container.querySelector(".hk-pwd-blur-hint")?.textContent ?? null;
51
+ }
52
+
53
+ function fireInput(input: HTMLInputElement, value: string) {
54
+ input.value = value;
55
+ input.dispatchEvent(new Event("input", { bubbles: true }));
56
+ }
57
+
58
+ function fireBeforeinput(input: HTMLInputElement, inputType: string) {
59
+ const event = new InputEvent("beforeinput", {
60
+ inputType,
61
+ bubbles: true,
62
+ cancelable: true,
63
+ });
64
+ input.dispatchEvent(event);
65
+ return event;
66
+ }
67
+
68
+ afterEach(() => {
69
+ for (const { app, container } of mounts.splice(0)) {
70
+ app.unmount();
71
+ container.remove();
72
+ }
73
+ });
74
+
75
+ describe("HkPasswordInput refocus", () => {
76
+ it("keeps the entered password and shows the refocus placeholder on focus", async () => {
77
+ const { container, input, model } = mountPasswordInput("secret");
78
+ expect(placeholderText(container)).toBeNull();
79
+
80
+ input.dispatchEvent(new FocusEvent("focus"));
81
+ await nextTick();
82
+
83
+ expect(model.value).toBe("secret");
84
+ expect(placeholderText(container)).toBe(
85
+ "Focused, typing will clear the current password",
86
+ );
87
+ expect(blurHintText(container)).toBeNull();
88
+ });
89
+
90
+ it("restores the blur hint when the field loses focus again", async () => {
91
+ const { container, input } = mountPasswordInput("secret");
92
+ input.dispatchEvent(new FocusEvent("focus"));
93
+ input.dispatchEvent(new FocusEvent("blur"));
94
+ await nextTick();
95
+
96
+ expect(placeholderText(container)).toBeNull();
97
+ expect(blurHintText(container)).toBe(
98
+ "Entered, click to focus and clear the existing password",
99
+ );
100
+ });
101
+
102
+ it("starts the next input from an empty field after beforeinput", async () => {
103
+ const { input, model } = mountPasswordInput("secret");
104
+ input.dispatchEvent(new FocusEvent("focus"));
105
+ await nextTick();
106
+
107
+ fireBeforeinput(input, "insertText");
108
+ fireInput(input, "x");
109
+ await nextTick();
110
+
111
+ expect(model.value).toBe("x");
112
+ });
113
+
114
+ it("clears the whole field on delete instead of nibbling the old value", async () => {
115
+ const { container, input, model } = mountPasswordInput("secret");
116
+ input.dispatchEvent(new FocusEvent("focus"));
117
+ await nextTick();
118
+
119
+ const event = fireBeforeinput(input, "deleteContentBackward");
120
+ await nextTick();
121
+
122
+ expect(event.defaultPrevented).toBe(true);
123
+ expect(model.value).toBe("");
124
+ expect(input.value).toBe("");
125
+ expect(placeholderText(container)).toBe("Focused, enter your password");
126
+ });
127
+
128
+ it("keeps only the newly typed tail when beforeinput is unavailable", async () => {
129
+ const { input, model } = mountPasswordInput("secret");
130
+ input.dispatchEvent(new FocusEvent("focus"));
131
+ await nextTick();
132
+
133
+ fireInput(input, "secretx");
134
+ await nextTick();
135
+
136
+ expect(model.value).toBe("x");
137
+ });
138
+
139
+ it("starts over when an edit lands mid-value without beforeinput", async () => {
140
+ const { input, model } = mountPasswordInput("secret");
141
+ input.dispatchEvent(new FocusEvent("focus"));
142
+ await nextTick();
143
+
144
+ fireInput(input, "secxret");
145
+ await nextTick();
146
+
147
+ expect(model.value).toBe("");
148
+ });
149
+
150
+ it("shows the waiting placeholder again on refocus after clearing", async () => {
151
+ const { container, input } = mountPasswordInput("secret");
152
+ input.dispatchEvent(new FocusEvent("focus"));
153
+ await nextTick();
154
+
155
+ fireBeforeinput(input, "deleteContentBackward");
156
+ await nextTick();
157
+
158
+ input.dispatchEvent(new FocusEvent("blur"));
159
+ await nextTick();
160
+ expect(blurHintText(container)).toBeNull();
161
+
162
+ input.dispatchEvent(new FocusEvent("focus"));
163
+ await nextTick();
164
+ expect(placeholderText(container)).toBe("Focused, enter your password");
165
+ });
166
+
167
+ it("reclaims focus on a stray blur right after input", async () => {
168
+ const { input } = mountPasswordInput("");
169
+ fireInput(input, "a");
170
+ const focusSpy = vi.spyOn(input, "focus");
171
+
172
+ input.dispatchEvent(new FocusEvent("blur", { relatedTarget: null }));
173
+ await new Promise((r) => setTimeout(r, 10));
174
+
175
+ expect(focusSpy).toHaveBeenCalled();
176
+ });
177
+
178
+ it("lets Tab navigation hand focus to another element", async () => {
179
+ const { input } = mountPasswordInput("");
180
+ const next = document.createElement("input");
181
+ document.body.appendChild(next);
182
+
183
+ fireInput(input, "a");
184
+ const focusSpy = vi.spyOn(input, "focus");
185
+
186
+ input.dispatchEvent(new FocusEvent("blur", { relatedTarget: next }));
187
+ await new Promise((r) => setTimeout(r, 10));
188
+
189
+ expect(focusSpy).not.toHaveBeenCalled();
190
+ next.remove();
191
+ });
192
+ });
193
+
194
+ describe("HkPasswordInput variants and icons", () => {
195
+ it("defaults to the password variant with the lock icon and i18n placeholder", () => {
196
+ const { container } = mountPasswordInput("");
197
+ expect(placeholderText(container)).toBe("Enter your password");
198
+ expect(
199
+ container.querySelector(".hk-pwd-lock")?.getAttribute("data-icon"),
200
+ ).toBe("password");
201
+ expect(
202
+ container
203
+ .querySelector(".hk-pwd-lock")
204
+ ?.classList.contains("hk-pwd-lock-empty"),
205
+ ).toBe(true);
206
+ });
207
+
208
+ it("renders the confirm icon and confirm placeholder for the confirm variant", () => {
209
+ const { container } = mountPasswordInput("", { variant: "confirm" });
210
+ expect(placeholderText(container)).toBe("Confirm your password");
211
+ expect(
212
+ container.querySelector(".hk-pwd-lock")?.getAttribute("data-icon"),
213
+ ).toBe("confirm");
214
+ // The built-in lock icon must not render for the confirm variant.
215
+ expect(container.querySelector(".hk-pwd-lock rect")).toBeNull();
216
+ });
217
+
218
+ it("renders the icon slot for the custom icon instead of a built-in svg", () => {
219
+ const { container } = mountPasswordInput(
220
+ "",
221
+ { icon: "custom" },
222
+ {
223
+ icon: () => [h("span", { class: "custom-icon" }, "custom-✓")],
224
+ },
225
+ );
226
+ expect(
227
+ container.querySelector(".hk-pwd-lock")?.getAttribute("data-icon"),
228
+ ).toBe("custom");
229
+ expect(container.querySelector(".custom-icon")?.textContent).toBe(
230
+ "custom-✓",
231
+ );
232
+ expect(container.querySelector(".hk-pwd-lock svg")).toBeNull();
233
+ });
234
+
235
+ it("lets an explicit placeholder override the variant default", () => {
236
+ const { container } = mountPasswordInput("", {
237
+ variant: "confirm",
238
+ placeholder: "Confirm password (custom)",
239
+ });
240
+ expect(placeholderText(container)).toBe("Confirm password (custom)");
241
+ });
242
+ });