@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 +1 -1
- package/src/components/HkTemplateField.scss +238 -0
- package/src/components/HkTemplateField.test.tsx +388 -0
- package/src/components/HkTemplateField.tsx +971 -0
- package/src/components/HkTemplateText.test.tsx +77 -0
- package/src/components/HkTemplateText.tsx +70 -0
- package/src/components/templateGrammar.test.ts +110 -0
- package/src/components/templateGrammar.ts +98 -0
- package/src/i18n/locales/ar/components.json +5 -0
- package/src/i18n/locales/de/components.json +5 -0
- package/src/i18n/locales/en/components.json +5 -0
- package/src/i18n/locales/es/components.json +5 -0
- package/src/i18n/locales/fr/components.json +5 -0
- package/src/i18n/locales/ja/components.json +5 -0
- package/src/i18n/locales/ko/components.json +5 -0
- package/src/i18n/locales/pt/components.json +5 -0
- package/src/i18n/locales/ru/components.json +5 -0
- package/src/i18n/locales/zh-Hans/components.json +5 -0
- package/src/i18n/locales/zh-Hant/components.json +5 -0
- package/src/index.ts +12 -0
package/package.json
CHANGED
|
@@ -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
|
+
});
|