fix(forms): surface validation and recover schema drift
This commit is contained in:
@@ -39,15 +39,24 @@ component Input {
|
||||
step: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
state validationError: string = ""
|
||||
functions {
|
||||
client function detail(sourceEvent) {
|
||||
return { value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }
|
||||
}
|
||||
client function handleInput(sourceEvent) { sourceEvent.stopPropagation(); output.input(detail(sourceEvent)) }
|
||||
client function handleInput(sourceEvent) {
|
||||
sourceEvent.stopPropagation()
|
||||
if (sourceEvent.currentTarget.validity.valid) {
|
||||
validationError = ""
|
||||
sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false")
|
||||
sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false")
|
||||
}
|
||||
output.input(detail(sourceEvent))
|
||||
}
|
||||
client function handleChange(sourceEvent) { sourceEvent.stopPropagation(); output.change(detail(sourceEvent)) }
|
||||
client function handleFocus(sourceEvent) { sourceEvent.stopPropagation(); output.focus(detail(sourceEvent)) }
|
||||
client function handleBlur(sourceEvent) { sourceEvent.stopPropagation(); output.blur(detail(sourceEvent)) }
|
||||
client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) }
|
||||
client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) }
|
||||
client function handleKeydown(sourceEvent) { sourceEvent.stopPropagation(); output.keydown({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleKeyup(sourceEvent) { sourceEvent.stopPropagation(); output.keyup({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
|
||||
}
|
||||
@@ -58,7 +67,7 @@ component Input {
|
||||
data-variant="{variant}"
|
||||
data-inline="{inline}"
|
||||
data-floating="{variant === 'floating'}"
|
||||
data-invalid="{error ? 'true' : 'false'}"
|
||||
data-invalid="{error || validationError ? 'true' : 'false'}"
|
||||
>
|
||||
<div
|
||||
class="wrn-next__field-heading"
|
||||
@@ -105,8 +114,8 @@ component Input {
|
||||
min="{min}"
|
||||
max="{max}"
|
||||
step="{step}"
|
||||
aria-invalid="{error ? 'true' : 'false'}"
|
||||
aria-describedby="{error ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}"
|
||||
aria-invalid="{error || validationError ? 'true' : 'false'}"
|
||||
aria-describedby="{error || validationError ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}"
|
||||
@input="handleInput(event)"
|
||||
@change="handleChange(event)"
|
||||
@focus="handleFocus(event)"
|
||||
@@ -135,8 +144,9 @@ component Input {
|
||||
id="{(id || name) + '-error'}"
|
||||
class="wrn-next__field-error"
|
||||
data-error="{name}"
|
||||
aria-live="polite"
|
||||
>
|
||||
{error}
|
||||
{error || validationError}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ size: string = "default"
|
||||
required: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
state validationError: string = ""
|
||||
functions {
|
||||
shared function selected(option) {
|
||||
if (multiple) return values.includes(option.value)
|
||||
@@ -40,34 +41,47 @@ size: string = "default"
|
||||
}
|
||||
client function emitField(nameEvent, sourceEvent) {
|
||||
sourceEvent.stopPropagation()
|
||||
if (sourceEvent.currentTarget.validity.valid) { validationError = ""; sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false") }
|
||||
output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent })
|
||||
}
|
||||
client function handleFocus(sourceEvent) { emitField("focus", sourceEvent); output.open({ name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleBlur(sourceEvent) { emitField("blur", sourceEvent); output.close({ name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleInvalid(sourceEvent) { output.invalid({ name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) }
|
||||
client function handleInvalid(sourceEvent) { validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) }
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--select {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error ? 'true' : 'false'}" data-readonly="{readonly}">
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--select {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error || validationError ? 'true' : 'false'}" data-readonly="{readonly}">
|
||||
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
|
||||
<div class="wrn-next__field-control" data-icon-position="{iconPosition}">
|
||||
{#if icon}<span class="{icon} wrn-next__field-icon" aria-hidden="true"></span>{/if}
|
||||
<select id="{id || name}" name="{name}" multiple="{multiple}" disabled="{disabled || readonly}" required="{required}" aria-readonly="{readonly}" aria-invalid="{error ? 'true' : 'false'}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="handleFocus(event)" @blur="handleBlur(event)" @invalid="handleInvalid(event)">
|
||||
<select id="{id || name}" name="{name}" multiple="{multiple}" disabled="{disabled || readonly}" required="{required}" aria-readonly="{readonly}" aria-invalid="{error || validationError ? 'true' : 'false'}" aria-describedby="{error || validationError ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="handleFocus(event)" @blur="handleBlur(event)" @invalid="handleInvalid(event)">
|
||||
{#if !multiple}<option value="" selected="{value === ''}" disabled="{required}">{placeholder}</option>{/if}
|
||||
{#each options as option}<option value="{option.value}" selected="{selected(option)}" disabled="{option.disabled}">{option.label}</option>{/each}
|
||||
</select>
|
||||
{#if !multiple}<span class="icon-[lucide--chevron-down] wrn-next__select-indicator" aria-hidden="true"></span>{/if}
|
||||
{#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if}
|
||||
</div>
|
||||
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}
|
||||
<small class="wrn-next__field-error" data-error="{name}">{error}</small>
|
||||
<small id="{(id || name) + '-error'}" class="wrn-next__field-error" data-error="{name}" aria-live="polite">{error || validationError}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--select select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
margin: 0;
|
||||
padding-inline-end: 2.5rem;
|
||||
line-height: 1.35;
|
||||
color: var(--wrn-color-text);
|
||||
background-color: var(--wrn-color-surface);
|
||||
}
|
||||
|
||||
.wrn-next__select-indicator {
|
||||
position: absolute;
|
||||
right: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
@@ -32,25 +32,30 @@ size: string = "default"
|
||||
maxlength: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
state validationError: string = ""
|
||||
functions {
|
||||
client function emitField(nameEvent, sourceEvent) {
|
||||
sourceEvent.stopPropagation()
|
||||
if (sourceEvent.currentTarget.validity.valid) { validationError = ""; sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false") }
|
||||
output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent })
|
||||
}
|
||||
client function handleInvalid(sourceEvent) {
|
||||
output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent })
|
||||
validationError = sourceEvent.currentTarget.validationMessage
|
||||
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
|
||||
sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true")
|
||||
output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent })
|
||||
}
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--textarea {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error ? 'true' : 'false'}">
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--textarea {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error || validationError ? 'true' : 'false'}">
|
||||
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
|
||||
<div class="wrn-next__field-control" data-icon-position="{iconPosition}">
|
||||
{#if icon}<span class="{icon} wrn-next__field-icon" aria-hidden="true"></span>{/if}
|
||||
<textarea id="{id || name}" name="{name}" value="{value}" placeholder="{variant === 'floating' ? ' ' : placeholder}" rows="{rows}" style="resize: {resize}" readonly="{readonly}" disabled="{disabled}" required="{required}" minlength="{minlength}" maxlength="{maxlength}" aria-invalid="{error ? 'true' : 'false'}" aria-describedby="{error ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="handleInvalid(event)"></textarea>
|
||||
<textarea id="{id || name}" name="{name}" value="{value}" placeholder="{variant === 'floating' ? ' ' : placeholder}" rows="{rows}" style="resize: {resize}" readonly="{readonly}" disabled="{disabled}" required="{required}" minlength="{minlength}" maxlength="{maxlength}" aria-invalid="{error || validationError ? 'true' : 'false'}" aria-describedby="{error || validationError ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="handleInvalid(event)"></textarea>
|
||||
{#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if}
|
||||
</div>
|
||||
{#if helperText}<small id="{(id || name) + '-help'}" class="wrn-next__field-help">{helperText}</small>{/if}
|
||||
<small id="{(id || name) + '-error'}" class="wrn-next__field-error" data-error="{name}">{error}</small>
|
||||
<small id="{(id || name) + '-error'}" class="wrn-next__field-error" data-error="{name}" aria-live="polite">{error || validationError}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1309,6 +1309,29 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
|
||||
expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]);
|
||||
});
|
||||
|
||||
test("basic fields render native constraint messages and select matches field surfaces", async () => {
|
||||
const inputSource = readFileSync(uiComponentPath("Input"), "utf8");
|
||||
const dom = mountHtml(
|
||||
await renderComponent(inputSource, {
|
||||
id: "required-name",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
required: true,
|
||||
}),
|
||||
);
|
||||
const input = dom.querySelector("input") as HTMLInputElement;
|
||||
input.dispatchEvent(new (dom.window as any).Event("invalid", { bubbles: false }));
|
||||
expect(dom.querySelector("[data-error='name']")?.textContent?.trim()).toBe(
|
||||
input.validationMessage,
|
||||
);
|
||||
expect(dom.querySelector(".wrn-next--input")?.getAttribute("data-invalid")).toBe("true");
|
||||
|
||||
const selectSource = readFileSync(uiComponentPath("Select"), "utf8");
|
||||
expect(selectSource).toContain("appearance: none");
|
||||
expect(selectSource).toContain("background-color: var(--wrn-color-surface)");
|
||||
expect(selectSource).toContain("wrn-next__select-indicator");
|
||||
});
|
||||
|
||||
test("all basic form components render values and their must-have interaction events", async () => {
|
||||
const components = [
|
||||
"Checkbox",
|
||||
|
||||
Reference in New Issue
Block a user