@celestia-island/hikari 0.40.3 → 0.40.4
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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkInput from "./HkInput";
|
|
5
|
+
|
|
6
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
7
|
+
const containers: HTMLElement[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
11
|
+
for (const el of containers.splice(0)) el.remove();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
interface FieldHandle {
|
|
15
|
+
wrapper: HTMLElement;
|
|
16
|
+
label: HTMLLabelElement | null;
|
|
17
|
+
field: HTMLInputElement | HTMLTextAreaElement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mount HkInput with the given props and grab label + field element. */
|
|
21
|
+
async function mountField(
|
|
22
|
+
props: Record<string, unknown>,
|
|
23
|
+
textarea = false,
|
|
24
|
+
): Promise<FieldHandle> {
|
|
25
|
+
const container = document.createElement("div");
|
|
26
|
+
document.body.appendChild(container);
|
|
27
|
+
containers.push(container);
|
|
28
|
+
|
|
29
|
+
const model = ref("");
|
|
30
|
+
const Wrapper = defineComponent({
|
|
31
|
+
setup() {
|
|
32
|
+
return () =>
|
|
33
|
+
h(HkInput, {
|
|
34
|
+
modelValue: model.value,
|
|
35
|
+
"onUpdate:modelValue": (v: string) => { model.value = v; },
|
|
36
|
+
...props,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const app = createApp(Wrapper);
|
|
41
|
+
mounts.push(app);
|
|
42
|
+
app.mount(container);
|
|
43
|
+
await nextTick();
|
|
44
|
+
|
|
45
|
+
const wrapper = container.querySelector<HTMLElement>(".hk-input-wrapper")!;
|
|
46
|
+
const field = (textarea
|
|
47
|
+
? wrapper.querySelector("textarea")
|
|
48
|
+
: wrapper.querySelector("input")) as
|
|
49
|
+
HTMLInputElement | HTMLTextAreaElement;
|
|
50
|
+
return { wrapper, label: wrapper.querySelector("label"), field };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("HkInput label association", () => {
|
|
54
|
+
it("binds the rendered label to the field without caller effort", async () => {
|
|
55
|
+
const { label, field } = await mountField({ label: "Workspace name" });
|
|
56
|
+
expect(label).toBeTruthy();
|
|
57
|
+
expect(label!.getAttribute("for")).toBe(field.id);
|
|
58
|
+
expect(field.id).not.toBe("");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("generates unique ids to sibling fields in the same app", async () => {
|
|
62
|
+
const container = document.createElement("div");
|
|
63
|
+
document.body.appendChild(container);
|
|
64
|
+
containers.push(container);
|
|
65
|
+
|
|
66
|
+
const Wrapper = defineComponent({
|
|
67
|
+
setup() {
|
|
68
|
+
return () => [
|
|
69
|
+
h(HkInput, { modelValue: "", label: "One" }),
|
|
70
|
+
h(HkInput, { modelValue: "", label: "Two" }),
|
|
71
|
+
];
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const app = createApp(Wrapper);
|
|
75
|
+
mounts.push(app);
|
|
76
|
+
app.mount(container);
|
|
77
|
+
await nextTick();
|
|
78
|
+
|
|
79
|
+
const fields = container.querySelectorAll<HTMLInputElement>(".hk-input-element");
|
|
80
|
+
expect(fields).toHaveLength(2);
|
|
81
|
+
expect(fields[0]!.id).not.toBe("");
|
|
82
|
+
expect(fields[0]!.id).not.toBe(fields[1]!.id);
|
|
83
|
+
const labels = container.querySelectorAll<HTMLLabelElement>(".hk-input-label");
|
|
84
|
+
expect(labels[0]!.getAttribute("for")).toBe(fields[0]!.id);
|
|
85
|
+
expect(labels[1]!.getAttribute("for")).toBe(fields[1]!.id);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("honors an explicit id prop over the generated one", async () => {
|
|
89
|
+
const { label, field } = await mountField({ label: "Name", id: "given-id" });
|
|
90
|
+
expect(field.id).toBe("given-id");
|
|
91
|
+
expect(label!.getAttribute("for")).toBe("given-id");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("associates textarea fields the same way", async () => {
|
|
95
|
+
const { label, field } = await mountField(
|
|
96
|
+
{ label: "Comment", type: "textarea" },
|
|
97
|
+
true,
|
|
98
|
+
);
|
|
99
|
+
expect(field.tagName).toBe("TEXTAREA");
|
|
100
|
+
expect(label!.getAttribute("for")).toBe(field.id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("omits spellcheck entirely when the prop is undefined", async () => {
|
|
104
|
+
const { field } = await mountField({ label: "Default" });
|
|
105
|
+
expect(field.hasAttribute("spellcheck")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders spellcheck={false} as an explicit attribute", async () => {
|
|
109
|
+
const { field } = await mountField({ label: "Literal", spellcheck: false });
|
|
110
|
+
expect(field.getAttribute("spellcheck")).toBe("false");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Eye, EyeOff } from "lucide-vue-next";
|
|
2
|
-
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, watch } from "vue";
|
|
2
|
+
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch } from "vue";
|
|
3
3
|
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
|
|
@@ -20,6 +20,20 @@ export default defineComponent({
|
|
|
20
20
|
readonly: { type: Boolean, default: false },
|
|
21
21
|
required: { type: Boolean, default: false },
|
|
22
22
|
name: { type: String, default: undefined },
|
|
23
|
+
/**
|
|
24
|
+
* `id` for the field element; the rendered label's `for` points here.
|
|
25
|
+
* Generated via Vue's useId when omitted, so a bare `label` prop is
|
|
26
|
+
* fully associated for screen readers and label clicks with zero
|
|
27
|
+
* caller effort.
|
|
28
|
+
*/
|
|
29
|
+
id: { type: String, default: undefined },
|
|
30
|
+
/**
|
|
31
|
+
* Native spellcheck toggle. Undefined keeps the browser default;
|
|
32
|
+
* `false` is the right call for exact-literal entry fields (type-
|
|
33
|
+
* to-confirm gates, addresses, serial paths) where the red squiggle
|
|
34
|
+
* under correct input reads as an error.
|
|
35
|
+
*/
|
|
36
|
+
spellcheck: { type: Boolean, default: undefined },
|
|
23
37
|
/** Submit intent on Enter (no modifiers) — see HkPasswordInput. */
|
|
24
38
|
submitOnEnter: { type: Function, default: undefined },
|
|
25
39
|
autocomplete: { type: String, default: "off" },
|
|
@@ -117,6 +131,13 @@ export default defineComponent({
|
|
|
117
131
|
return rest;
|
|
118
132
|
});
|
|
119
133
|
|
|
134
|
+
// Field identity: the rendered label points at this id, so a bare
|
|
135
|
+
// `label` prop gives a fully associated field (screen readers, label
|
|
136
|
+
// clicks) without caller effort. An explicit `id` prop wins.
|
|
137
|
+
// useId() must run synchronously in setup; it is SSR-safe.
|
|
138
|
+
const generatedId = useId();
|
|
139
|
+
const fieldId = computed(() => props.id ?? generatedId);
|
|
140
|
+
|
|
120
141
|
// ── affix width reservation ──────────────────────────────────────
|
|
121
142
|
// The input element overlays the WHOLE box (absolute inset:0) while
|
|
122
143
|
// the prefix/suffix affixes flow above it — so centered text, the
|
|
@@ -250,7 +271,7 @@ export default defineComponent({
|
|
|
250
271
|
return () => (
|
|
251
272
|
<div class="hk-input-wrapper">
|
|
252
273
|
{props.label && (
|
|
253
|
-
<label class="hk-input-label">
|
|
274
|
+
<label class="hk-input-label" for={fieldId.value}>
|
|
254
275
|
{props.label}
|
|
255
276
|
{props.required && <span class="hk-input-required">*</span>}
|
|
256
277
|
</label>
|
|
@@ -279,11 +300,13 @@ export default defineComponent({
|
|
|
279
300
|
{isText ? (
|
|
280
301
|
<input
|
|
281
302
|
ref={inputRef}
|
|
303
|
+
id={fieldId.value}
|
|
282
304
|
type={resolvedType.value}
|
|
283
305
|
value={props.modelValue}
|
|
284
306
|
placeholder={nativePlaceholder.value}
|
|
285
307
|
disabled={props.disabled}
|
|
286
308
|
readonly={props.readonly}
|
|
309
|
+
spellcheck={props.spellcheck}
|
|
287
310
|
name={props.name}
|
|
288
311
|
autocomplete={props.autocomplete}
|
|
289
312
|
data-1p-ignore
|
|
@@ -313,10 +336,12 @@ export default defineComponent({
|
|
|
313
336
|
) : (
|
|
314
337
|
<textarea
|
|
315
338
|
ref={inputRef}
|
|
339
|
+
id={fieldId.value}
|
|
316
340
|
value={props.modelValue}
|
|
317
341
|
placeholder={nativePlaceholder.value}
|
|
318
342
|
disabled={props.disabled}
|
|
319
343
|
readonly={props.readonly}
|
|
344
|
+
spellcheck={props.spellcheck}
|
|
320
345
|
rows={props.rows}
|
|
321
346
|
name={props.name}
|
|
322
347
|
autocomplete={props.autocomplete}
|