@celestia-island/hikari 0.55.123 → 0.55.124

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.55.123",
3
+ "version": "0.55.124",
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",
@@ -0,0 +1,238 @@
1
+ // HkTemplateField / HkTemplateText — the fill-template surfaces.
2
+ // Self-sufficient styling (house rule): the field box, label, hint and
3
+ // error rows live here rather than borrowing HkInput's sheet, and the
4
+ // chip is THE shared token visual — the editor's chips and the
5
+ // read-only renderer's chips are the same class, so a stored template
6
+ // reads identically wherever it appears.
7
+
8
+ .hk-template-field {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: var(--space-4, 4px);
12
+ width: 100%;
13
+
14
+ &[data-disabled] {
15
+ opacity: 0.55;
16
+ pointer-events: none;
17
+ }
18
+ }
19
+
20
+ .hk-template-field-label {
21
+ font-size: var(--text-xs, 0.75rem);
22
+ font-weight: 500;
23
+ color: var(--hi-color-text-primary);
24
+ }
25
+
26
+ .hk-template-field-hint,
27
+ .hk-template-field-error {
28
+ margin: 0;
29
+ font-size: var(--text-2xs, 0.625rem);
30
+ line-height: 1.4;
31
+ }
32
+
33
+ .hk-template-field-hint {
34
+ color: var(--hi-color-muted);
35
+ }
36
+
37
+ .hk-template-field-error {
38
+ color: var(--hi-color-danger);
39
+ }
40
+
41
+ .hk-template-field-box {
42
+ box-sizing: border-box;
43
+ display: block;
44
+ width: 100%;
45
+ padding: var(--space-6, 0.375rem) var(--space-10, 0.625rem);
46
+ background: var(--hi-color-surface);
47
+ border: 1px solid var(--hi-color-border);
48
+ border-radius: 6px;
49
+ transition:
50
+ border-color var(--hi-transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
51
+ box-shadow var(--hi-transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
52
+
53
+ &:hover {
54
+ border-color: var(--hi-color-primary);
55
+ }
56
+
57
+ &:focus-within {
58
+ border-color: var(--hi-color-primary);
59
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--hi-color-primary) 18%, transparent);
60
+ }
61
+ }
62
+
63
+ .hk-template-field-box-error {
64
+ border-color: var(--hi-color-danger);
65
+
66
+ &:focus-within {
67
+ border-color: var(--hi-color-danger);
68
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--hi-color-danger) 15%, transparent);
69
+ }
70
+ }
71
+
72
+ .hk-template-field-edit {
73
+ // Slightly tighter than a plain input: chips carry their own padding,
74
+ // and the field must not read taller than the HkInput beside it.
75
+ min-height: calc(1.5rem + var(--space-4, 4px));
76
+ display: flex;
77
+ flex-wrap: wrap;
78
+ align-items: center;
79
+ gap: 2px 3px;
80
+ font-size: var(--text-base, 0.875rem);
81
+ line-height: 1.5;
82
+ color: var(--hi-color-text-primary);
83
+ outline: none;
84
+ word-break: break-all;
85
+ cursor: text;
86
+
87
+ &:empty::before {
88
+ content: attr(data-ph);
89
+ color: var(--hi-color-muted);
90
+ opacity: 0.6;
91
+ }
92
+ }
93
+
94
+ // ── The chip ─────────────────────────────────────────────────────────
95
+ // Monospace (it IS code — the exact text the host fills), primary
96
+ // tinted, unbreakable. Editable context adds the click affordance and
97
+ // the unknown marker both surfaces share.
98
+
99
+ .hk-tpl-chip {
100
+ display: inline-flex;
101
+ align-items: center;
102
+ padding: 0 var(--space-6, 6px);
103
+ border-radius: var(--radius-sm, 4px);
104
+ border: 1px solid color-mix(in srgb, var(--hi-color-primary) 32%, transparent);
105
+ background: color-mix(in srgb, var(--hi-color-primary) 12%, transparent);
106
+ color: color-mix(in srgb, var(--hi-color-primary) 70%, rgb(0 0 0));
107
+ font-family: var(--font-mono, monospace);
108
+ font-size: 0.8em;
109
+ line-height: 1.5;
110
+ white-space: nowrap;
111
+ user-select: none;
112
+
113
+ [data-theme="dark"] & {
114
+ color: color-mix(in srgb, var(--hi-color-primary) 60%, rgb(255 255 255));
115
+ }
116
+
117
+ &[data-unknown] {
118
+ border-color: var(--hi-color-border);
119
+ background: color-mix(in srgb, var(--hi-color-warning) 12%, transparent);
120
+ color: var(--hi-color-muted);
121
+ font-style: italic;
122
+ }
123
+ }
124
+
125
+ .hk-template-field-edit .hk-tpl-chip {
126
+ cursor: pointer;
127
+
128
+ &:hover {
129
+ border-color: var(--hi-color-primary);
130
+ background: color-mix(in srgb, var(--hi-color-primary) 20%, transparent);
131
+ }
132
+
133
+ &:focus-visible {
134
+ outline: 2px solid var(--hi-color-primary);
135
+ outline-offset: 1px;
136
+ }
137
+ }
138
+
139
+ .hk-template-text {
140
+ display: inline;
141
+ max-width: 100%;
142
+
143
+ &[data-multiline] {
144
+ display: block;
145
+ overflow-wrap: anywhere;
146
+ }
147
+ }
148
+
149
+ .hk-template-text-run {
150
+ white-space: pre-wrap;
151
+ overflow-wrap: anywhere;
152
+ }
153
+
154
+ // ── Vocabulary rows (panel + chip editor share them) ────────────────
155
+
156
+ .hk-tpl-rows,
157
+ .hk-tpl-editor-rows {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 2px;
161
+ padding: var(--space-4, 4px);
162
+ }
163
+
164
+ .hk-tpl-row {
165
+ display: flex;
166
+ flex-direction: column;
167
+ align-items: flex-start;
168
+ gap: 1px;
169
+ padding: var(--space-4, 4px) var(--space-8, 8px);
170
+ border: none;
171
+ border-radius: var(--radius-sm, 4px);
172
+ background: transparent;
173
+ color: var(--hi-color-text-primary);
174
+ text-align: start;
175
+ cursor: pointer;
176
+
177
+ &:hover,
178
+ &[data-active] {
179
+ background: color-mix(in srgb, var(--hi-color-primary) 10%, transparent);
180
+ }
181
+ }
182
+
183
+ .hk-tpl-row-name {
184
+ font-family: var(--font-mono, monospace);
185
+ font-size: var(--text-xs, 0.75rem);
186
+ }
187
+
188
+ .hk-tpl-row-desc {
189
+ font-size: var(--text-2xs, 0.625rem);
190
+ color: var(--hi-color-muted);
191
+ line-height: 1.4;
192
+ }
193
+
194
+ .hk-tpl-rows-empty {
195
+ padding: var(--space-8, 8px) var(--space-12, 12px);
196
+ margin: 0;
197
+ font-size: var(--text-xs, 0.75rem);
198
+ color: var(--hi-color-muted);
199
+ }
200
+
201
+ // ── Chip editor form ────────────────────────────────────────────────
202
+
203
+ .hk-tpl-editor {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: var(--space-8, 8px);
207
+ min-width: 16rem;
208
+ max-width: min(22rem, 90vw);
209
+ padding: var(--space-8, 8px);
210
+ }
211
+
212
+ .hk-tpl-editor-current {
213
+ display: flex;
214
+ align-items: baseline;
215
+ gap: var(--space-8, 8px);
216
+ flex-wrap: wrap;
217
+ }
218
+
219
+ .hk-tpl-editor-desc {
220
+ font-size: var(--text-2xs, 0.625rem);
221
+ color: var(--hi-color-muted);
222
+ line-height: 1.4;
223
+ flex: 1 1 10rem;
224
+ }
225
+
226
+ // ONE scrollbar per window (family contract): the mobile sheet's panel
227
+ // already scrolls the whole form through its own overlay scrollbar, so
228
+ // the rows cap exists ONLY in the desktop popover (which sizes to
229
+ // content and otherwise has no scroll region at all).
230
+ .hk-popover-panel:not(.hk-is-sheet) .hk-tpl-editor-rows {
231
+ max-height: min(16rem, 40dvh);
232
+ overflow-y: auto;
233
+ }
234
+
235
+ .hk-tpl-editor-footer {
236
+ display: flex;
237
+ justify-content: flex-end;
238
+ }
@@ -0,0 +1,388 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick, ref } from "vue";
3
+
4
+ import HkTemplateField from "./HkTemplateField";
5
+
6
+ /**
7
+ * HkTemplateField contract tests:
8
+ *
9
+ * - RENDER: modelValue renders literal runs + whole-token chips; the
10
+ * chip carries the byte-exact data-raw, so a `{{name}}` that came
11
+ * from storage renders styled but still serializes verbatim
12
+ * - CONVERT: a hand-typed `{{ word }}` (input event after a manual DOM
13
+ * text append, what a real browser does between events) folds into a
14
+ * chip on the next input pass, and the emitted value is byte-exact
15
+ * - PANEL: typing an open `{{ quer` opens the vocabulary panel; picking
16
+ * a row replaces the trigger with the chip and emits the new value
17
+ * - CHIP EDITOR: clicking a chip opens the popover form (focus moves
18
+ * into its search input); picking a vocabulary row swaps the token
19
+ * in place; Remove drops the whole chip — every mutation emits the
20
+ * serialized value, never a chip markup string
21
+ * - SUBMIT: Enter with no panel open fires submitOnEnter
22
+ *
23
+ * happy-dom has no real editing engine: the tests drive the DOM the
24
+ * way the browser would (mutate text nodes, then dispatch `input`),
25
+ * which pins exactly the component's own contracts — serialization,
26
+ * normalization, panel state and editor plumbing.
27
+ */
28
+
29
+ const mounts: { app: ReturnType<typeof createApp>; container: HTMLElement }[] = [];
30
+
31
+ interface MountOpts {
32
+ modelValue?: string;
33
+ onUpdate?: (v: string) => void;
34
+ submitOnEnter?: () => void;
35
+ }
36
+
37
+ function mount(opts: MountOpts = {}) {
38
+ const container = document.createElement("div");
39
+ document.body.appendChild(container);
40
+ const app = createApp({
41
+ render: () =>
42
+ h(HkTemplateField, {
43
+ modelValue: opts.modelValue ?? "",
44
+ tokens: [
45
+ { name: "md5_email", description: "MD5 of the account email" },
46
+ { name: "sha256_email", description: "SHA-256 of the account email" },
47
+ { name: "username", description: "The account name" },
48
+ ],
49
+ "onUpdate:modelValue": (v: string) => opts.onUpdate?.(v),
50
+ submitOnEnter: opts.submitOnEnter,
51
+ }),
52
+ });
53
+ app.mount(container);
54
+ mounts.push({ app, container });
55
+ return container;
56
+ }
57
+
58
+ function editable(el: HTMLElement): HTMLElement {
59
+ const edit = el.querySelector<HTMLElement>(".hk-template-field-edit");
60
+ if (!edit) throw new Error("editable not found");
61
+ return edit;
62
+ }
63
+
64
+ function fireInput(edit: HTMLElement) {
65
+ edit.dispatchEvent(new Event("input", { bubbles: true }));
66
+ }
67
+
68
+ afterEach(() => {
69
+ for (const { app, container } of mounts) {
70
+ app.unmount();
71
+ container.remove();
72
+ }
73
+ mounts.length = 0;
74
+ });
75
+
76
+ describe("HkTemplateField render + value contract", () => {
77
+ it("renders tokens as chips with byte-exact raw", async () => {
78
+ const el = mount({ modelValue: "https://g.com/{{username}}.png" });
79
+ await nextTick();
80
+ const chips = el.querySelectorAll<HTMLElement>(".hk-tpl-chip");
81
+ expect(chips).toHaveLength(1);
82
+ expect(chips[0]!.dataset.token).toBe("username");
83
+ expect(chips[0]!.dataset.raw).toBe("{{username}}");
84
+ expect(chips[0]!.textContent).toBe("{{ username }}");
85
+ expect(editable(el).textContent).toContain("https://g.com/");
86
+ });
87
+
88
+ it("re-renders when the parent changes the value", async () => {
89
+ const container = mount({ modelValue: "" });
90
+ await nextTick();
91
+ // Remount-ish: drive the prop through a second mount with a value.
92
+ const edit = editable(container);
93
+ edit.textContent = "{{ a }}";
94
+ fireInput(edit);
95
+ await nextTick();
96
+ expect(edit.querySelectorAll<HTMLElement>(".hk-tpl-chip")).toHaveLength(1);
97
+ });
98
+
99
+ it("an external write re-renders; a parent echo of our own emit must not", async () => {
100
+ // The controlled-value contract: the parent writing a NEW value
101
+ // rebuilds the DOM; the parent echoing back the value we just
102
+ // emitted must NOT (a rebuild under the caret is what breaks
103
+ // mid-typing edits in controlled setups).
104
+ const value = ref("");
105
+ const container = document.createElement("div");
106
+ document.body.appendChild(container);
107
+ const app = createApp({
108
+ setup() {
109
+ return () =>
110
+ h(HkTemplateField, {
111
+ modelValue: value.value,
112
+ tokens: [{ name: "username" }],
113
+ "onUpdate:modelValue": (v: string) => { value.value = v; },
114
+ });
115
+ },
116
+ });
117
+ app.mount(container);
118
+ mounts.push({ app, container });
119
+ await nextTick();
120
+
121
+ // External write: chips appear.
122
+ value.value = "x {{ username }} y";
123
+ await nextTick();
124
+ let edit = editable(container);
125
+ expect(edit.querySelectorAll<HTMLElement>(".hk-tpl-chip")).toHaveLength(1);
126
+
127
+ // Echo path: type more (emit fires, the parent writes the SAME
128
+ // string back); a planted marker node must survive — a rebuild
129
+ // would have dropped it.
130
+ const marker = document.createTextNode("");
131
+ edit.appendChild(marker);
132
+ edit.append(document.createTextNode("z"));
133
+ fireInput(edit);
134
+ await nextTick();
135
+ edit = editable(container);
136
+ expect(edit.contains(marker), "echo must not rebuild the DOM").toBe(true);
137
+ expect(edit.textContent).toContain("{{ username }}");
138
+ expect(value.value).toBe("x {{ username }} yz");
139
+ });
140
+
141
+ it("converts a hand-typed closed token into a chip and emits byte-exact", async () => {
142
+ const seen: string[] = [];
143
+ const el = mount({ modelValue: "x ", onUpdate: (v) => seen.push(v) });
144
+ await nextTick();
145
+ const edit = editable(el);
146
+ // The browser's text insertion between events, emulated.
147
+ edit.append(document.createTextNode("{{ md5_email }}"));
148
+ fireInput(edit);
149
+ await nextTick();
150
+ const chips = edit.querySelectorAll<HTMLElement>(".hk-tpl-chip");
151
+ expect(chips).toHaveLength(1);
152
+ expect(chips[0]!.dataset.raw).toBe("{{ md5_email }}");
153
+ expect(seen.at(-1)).toBe("x {{ md5_email }}");
154
+ });
155
+
156
+ it("leaves non-identifier braces as literal text", async () => {
157
+ const el = mount({ modelValue: "" });
158
+ await nextTick();
159
+ const edit = editable(el);
160
+ edit.append(document.createTextNode("{{ filter | pipe }}"));
161
+ fireInput(edit);
162
+ await nextTick();
163
+ expect(edit.querySelectorAll<HTMLElement>(".hk-tpl-chip")).toHaveLength(0);
164
+ expect(edit.textContent).toContain("{{ filter | pipe }}");
165
+ });
166
+
167
+ it("paste stays plain text and converts tokens", async () => {
168
+ const seen: string[] = [];
169
+ const el = mount({ modelValue: "", onUpdate: (v) => seen.push(v) });
170
+ await nextTick();
171
+ const edit = editable(el);
172
+ const dt = new DataTransfer();
173
+ dt.setData("text/plain", "<b>{{ username }}</b>");
174
+ const pasteEvent = new ClipboardEvent("paste", { bubbles: true, cancelable: true });
175
+ Object.defineProperty(pasteEvent, "clipboardData", { value: dt });
176
+ edit.dispatchEvent(pasteEvent);
177
+ await nextTick();
178
+ expect(edit.querySelector("b")).toBeNull();
179
+ const chips = edit.querySelectorAll<HTMLElement>(".hk-tpl-chip");
180
+ expect(chips).toHaveLength(1);
181
+ expect(seen.at(-1)).toBe("<b>{{ username }}</b>");
182
+ });
183
+ });
184
+
185
+ describe("HkTemplateField vocabulary panel", () => {
186
+ it("opens on an open-brace trigger and picks a row into a chip", async () => {
187
+ const seen: string[] = [];
188
+ const el = mount({ modelValue: "", onUpdate: (v) => seen.push(v) });
189
+ await nextTick();
190
+ const edit = editable(el);
191
+ edit.append(document.createTextNode("https://x/{{ us"));
192
+ // Caret at the end of the typed run (what typing leaves behind).
193
+ const sel = window.getSelection();
194
+ const range = document.createRange();
195
+ range.setStart(edit.lastChild!, edit.lastChild!.nodeValue!.length);
196
+ range.collapse(true);
197
+ sel?.removeAllRanges();
198
+ sel?.addRange(range);
199
+ fireInput(edit);
200
+ await nextTick();
201
+ // The panel's rows are teleported to body by HkMenu's surface.
202
+ const row = document.body.querySelector<HTMLButtonElement>(".hk-tpl-row");
203
+ expect(row).not.toBeNull();
204
+ row!.click();
205
+ await nextTick();
206
+ const chips = editable(el).querySelectorAll<HTMLElement>(".hk-tpl-chip");
207
+ expect(chips).toHaveLength(1);
208
+ expect(chips[0]!.dataset.token).toBe("username");
209
+ expect(seen.at(-1)).toBe("https://x/{{ username }}");
210
+ });
211
+
212
+ it("arrow keys + Enter pick the highlighted row", async () => {
213
+ const seen: string[] = [];
214
+ const el = mount({ modelValue: "", onUpdate: (v) => seen.push(v) });
215
+ await nextTick();
216
+ const edit = editable(el);
217
+ edit.append(document.createTextNode("{{ "));
218
+ const sel = window.getSelection();
219
+ const range = document.createRange();
220
+ range.setStart(edit.lastChild!, 3);
221
+ range.collapse(true);
222
+ sel?.removeAllRanges();
223
+ sel?.addRange(range);
224
+ fireInput(edit);
225
+ await nextTick();
226
+ edit.dispatchEvent(
227
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }),
228
+ );
229
+ await nextTick();
230
+ edit.dispatchEvent(
231
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
232
+ );
233
+ await nextTick();
234
+ // Active row starts at 0 (md5_email); one ArrowDown highlights
235
+ // sha256_email, and Enter picks it.
236
+ expect(seen.at(-1)).toBe("{{ sha256_email }}");
237
+ expect(editable(el).querySelectorAll<HTMLElement>(".hk-tpl-chip")).toHaveLength(1);
238
+ });
239
+ });
240
+
241
+ describe("HkTemplateField chip editor", () => {
242
+ it("click on a chip opens the editor; picking swaps the token", async () => {
243
+ const seen: string[] = [];
244
+ const el = mount({
245
+ modelValue: "https://g.com/{{username}}.png",
246
+ onUpdate: (v) => seen.push(v),
247
+ });
248
+ await nextTick();
249
+ const chip = editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!;
250
+ chip.click();
251
+ await nextTick();
252
+ // The editor is teleported; its rows exist with the current row
253
+ // marked active.
254
+ const rows = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".hk-tpl-editor .hk-tpl-row"));
255
+ expect(rows.length).toBeGreaterThan(0);
256
+ expect(rows.some((r) => r.dataset.active !== undefined && r.textContent!.includes("username"))).toBe(true);
257
+ // Swap to sha256_email.
258
+ const target = rows.find((r) => r.textContent!.includes("sha256_email"))!;
259
+ target.click();
260
+ await nextTick();
261
+ expect(seen.at(-1)).toBe("https://g.com/{{ sha256_email }}.png");
262
+ const nextChip = editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!;
263
+ expect(nextChip.dataset.token).toBe("sha256_email");
264
+ });
265
+
266
+ it("Remove drops the whole chip and emits the residue", async () => {
267
+ const seen: string[] = [];
268
+ const el = mount({
269
+ modelValue: "a {{ md5_email }} b",
270
+ onUpdate: (v) => seen.push(v),
271
+ });
272
+ await nextTick();
273
+ editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
274
+ await nextTick();
275
+ const remove = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".hk-tpl-editor button"))
276
+ .find((b) => b.textContent!.trim().length > 0 && !b.closest(".hk-tpl-editor-rows"))!;
277
+ remove.click();
278
+ await nextTick();
279
+ expect(seen.at(-1)).toBe("a b");
280
+ expect(editable(el).querySelectorAll<HTMLElement>(".hk-tpl-chip")).toHaveLength(0);
281
+ });
282
+ });
283
+
284
+ describe("HkTemplateField keyboard", () => {
285
+ it("Enter without a panel fires the submit intent", async () => {
286
+ const submit = vi.fn();
287
+ const el = mount({ modelValue: "", submitOnEnter: submit });
288
+ await nextTick();
289
+ editable(el).dispatchEvent(
290
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
291
+ );
292
+ expect(submit).toHaveBeenCalledTimes(1);
293
+ });
294
+ });
295
+
296
+ /** Drive the editable into an open `{{ query` trigger state with the
297
+ * caret at the end of the typed run, so the panel opens. */
298
+ async function openTrigger(el: HTMLElement, typed: string) {
299
+ const edit = editable(el);
300
+ edit.append(document.createTextNode(typed));
301
+ const sel = window.getSelection();
302
+ const range = document.createRange();
303
+ range.setStart(edit.lastChild!, edit.lastChild!.nodeValue!.length);
304
+ range.collapse(true);
305
+ sel?.removeAllRanges();
306
+ sel?.addRange(range);
307
+ fireInput(edit);
308
+ await nextTick();
309
+ }
310
+
311
+ describe("HkTemplateField vocabulary guardrails (R2 gap killers)", () => {
312
+ it("falls back to in-order subsequence matching (md5e finds md5_email)", async () => {
313
+ const el = mount({ modelValue: "" });
314
+ await nextTick();
315
+ // "md5e" is not a substring of any name/description — only the
316
+ // subsequence pass can surface the row.
317
+ await openTrigger(el, "{{ md5e");
318
+ const rows = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".hk-tpl-row"));
319
+ expect(rows.some((r) => r.textContent!.includes("md5_email"))).toBe(true);
320
+ });
321
+
322
+ it("Shift+Tab does NOT pick the highlighted row", async () => {
323
+ const seen: string[] = [];
324
+ const el = mount({ modelValue: "", onUpdate: (v) => seen.push(v) });
325
+ await nextTick();
326
+ await openTrigger(el, "{{ us");
327
+ const before = seen.length;
328
+ editable(el).dispatchEvent(
329
+ new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
330
+ );
331
+ await nextTick();
332
+ expect(seen.length).toBe(before);
333
+ expect(editable(el).querySelectorAll<HTMLElement>(".hk-tpl-chip")).toHaveLength(0);
334
+ });
335
+
336
+ it("a paste whose selection straddles the field boundary appends at the end", async () => {
337
+ const el = mount({ modelValue: "x" });
338
+ await nextTick();
339
+ const edit = editable(el);
340
+ // A selection that starts inside the field and ends in an outside
341
+ // node: the guard must treat it as foreign (delete nothing past
342
+ // the field, insert at the end).
343
+ const outsider = document.createElement("span");
344
+ outsider.textContent = "OUTSIDE";
345
+ el.appendChild(outsider);
346
+ const sel = window.getSelection();
347
+ const range = document.createRange();
348
+ range.setStart(edit.firstChild!, 0);
349
+ range.setEnd(outsider.firstChild!, 2);
350
+ sel?.removeAllRanges();
351
+ sel?.addRange(range);
352
+
353
+ const seen: string[] = [];
354
+ const dt = new DataTransfer();
355
+ dt.setData("text/plain", "{{ username }}");
356
+ const pasteEvent = new ClipboardEvent("paste", { bubbles: true, cancelable: true });
357
+ Object.defineProperty(pasteEvent, "clipboardData", { value: dt });
358
+ edit.dispatchEvent(pasteEvent);
359
+ await nextTick();
360
+ // The outside node survived the straddling selection…
361
+ expect(outsider.textContent).toBe("OUTSIDE");
362
+ // …and the paste landed at the field's end, chips up.
363
+ const chips = edit.querySelectorAll<HTMLElement>(".hk-tpl-chip");
364
+ expect(chips).toHaveLength(1);
365
+ expect(edit.textContent).toContain("x");
366
+ });
367
+
368
+ it("Enter in the chip editor search picks the first matching row", async () => {
369
+ const seen: string[] = [];
370
+ const el = mount({
371
+ modelValue: "a {{ username }} b",
372
+ onUpdate: (v) => seen.push(v),
373
+ });
374
+ await nextTick();
375
+ editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
376
+ await nextTick();
377
+ const search = document.body.querySelector<HTMLInputElement>(".hk-tpl-editor-search input")!;
378
+ expect(search).toBeTruthy();
379
+ search.value = "md5";
380
+ search.dispatchEvent(new Event("input", { bubbles: true }));
381
+ await nextTick();
382
+ search.dispatchEvent(
383
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
384
+ );
385
+ await nextTick();
386
+ expect(seen.at(-1)).toBe("a {{ md5_email }} b");
387
+ });
388
+ });