Use shared form controls and enforce UI patterns (Refs #110)
CI / checks (push) Successful in 4m51s
CI / checks (push) Successful in 4m51s
This commit is contained in:
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import Field from '../../src/lib/ui/components/primitives/Field.svelte';
|
||||
import Input from '../../src/lib/ui/components/primitives/Input.svelte';
|
||||
import Select from '../../src/lib/ui/components/primitives/Select.svelte';
|
||||
|
||||
let count = $state<number | undefined>(3);
|
||||
let order = $state(false);
|
||||
let version = $state<number | null>(null);
|
||||
let password = $state('');
|
||||
let showPassword = $state(false);
|
||||
let element = $state<HTMLInputElement>();
|
||||
let selected = $state<HTMLSelectElement>();
|
||||
let inputEvents = $state(0);
|
||||
let changes = $state('');
|
||||
</script>
|
||||
|
||||
<form onsubmit={(event) => event.preventDefault()}>
|
||||
<Field label="Lobbies" forId="count" hint="Choose one to five lobbies.">
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
required
|
||||
bind:value={count}
|
||||
bind:element
|
||||
oninput={(event) => (inputEvents = event.currentTarget.valueAsNumber)}
|
||||
aria-describedby="count-extra"
|
||||
/>
|
||||
</Field>
|
||||
<p id="count-extra">A separate explanation.</p>
|
||||
<Field label="Order" forId="order">
|
||||
<Select
|
||||
id="order"
|
||||
bind:value={order}
|
||||
bind:element={selected}
|
||||
onchange={(event) => (changes = event.currentTarget.value)}
|
||||
>
|
||||
<option value={true}>Newest</option>
|
||||
<option value={false}>Oldest</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Version" forId="version">
|
||||
<Select id="version" bind:value={version}>
|
||||
<option value={null} disabled>Choose a version</option>
|
||||
<optgroup label="Published"
|
||||
><option value={2}>Version 2</option><option value={7}>Version 7</option></optgroup
|
||||
>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Password" forId="password" error="Use at least eight characters.">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
minlength={8}
|
||||
maxlength={128}
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field><Input ariaLabel="Standalone search" /></Field>
|
||||
<button type="button" onclick={() => (showPassword = !showPassword)}>Show password</button>
|
||||
<button type="button" onclick={() => element?.focus()}>Focus count</button>
|
||||
<button type="button" onclick={() => selected?.focus()}>Focus order</button>
|
||||
<output id="values">{JSON.stringify({ count, order, version, inputEvents, changes })}</output>
|
||||
</form>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import FormControls from '../fixtures/FormControls.svelte';
|
||||
import Select from '../../src/lib/ui/components/primitives/Select.svelte';
|
||||
|
||||
it('preserves native constraints and combines field help with explicit descriptions', () => {
|
||||
const body = render(FormControls).body;
|
||||
const count = body.match(/<input[^>]*id="count"[^>]*>/)?.[0] ?? '';
|
||||
expect(count).toContain('type="number"');
|
||||
expect(count).toContain('min="1"');
|
||||
expect(count).toContain('max="5"');
|
||||
expect(count).toContain('step="1"');
|
||||
expect(count).toContain('required');
|
||||
expect(count).toContain('value="3"');
|
||||
const descriptions = count.match(/aria-describedby="([^"]+)"/)?.[1].split(' ') ?? [];
|
||||
expect(descriptions).toHaveLength(2);
|
||||
for (const id of descriptions) expect(body).toContain(`id="${id}"`);
|
||||
expect(descriptions).toContain('count-extra');
|
||||
const password = body.match(/<input[^>]*id="password"[^>]*>/)?.[0] ?? '';
|
||||
expect(password).toContain('minlength="8"');
|
||||
expect(password).toContain('maxlength="128"');
|
||||
expect(password).toContain('autocomplete="new-password"');
|
||||
expect(password).toContain('aria-invalid="true"');
|
||||
const errorId = password.match(/aria-describedby="([^"]+)"/)?.[1];
|
||||
expect(body).toContain(`id="${errorId}"`);
|
||||
const standalone = body.match(/<input[^>]*aria-label="Standalone search"[^>]*>/)?.[0] ?? '';
|
||||
expect(standalone).not.toContain('aria-labelledby');
|
||||
});
|
||||
|
||||
it('renders typed child options, groups and the shared chevron with the selected value', () => {
|
||||
const body = render(FormControls).body;
|
||||
expect(body).toMatch(/<option[^>]*value="false"[^>]*selected[^>]*>Oldest<\/option>/);
|
||||
expect(body).toMatch(/<option[^>]*disabled[^>]*selected[^>]*>Choose a version<\/option>/);
|
||||
expect(body).toContain('<optgroup label="Published">');
|
||||
expect(body.match(/aria-hidden="true">▾/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('retains the options API, placeholders and explicitly supplied accessibility attributes', () => {
|
||||
const body = render(Select<number>, {
|
||||
props: {
|
||||
value: 2,
|
||||
options: [
|
||||
{ value: 1, label: 'One', disabled: true },
|
||||
{ value: 2, label: 'Two' }
|
||||
],
|
||||
placeholder: 'Choose',
|
||||
disabled: true,
|
||||
name: 'quantity',
|
||||
'aria-label': 'Quantity',
|
||||
'aria-describedby': 'quantity-help',
|
||||
'aria-invalid': true
|
||||
}
|
||||
}).body;
|
||||
expect(body).toMatch(/<option value="" disabled[^>]*>Choose<\/option>/);
|
||||
expect(body).toMatch(/<option[^>]*value="1"[^>]*disabled[^>]*>One<\/option>/);
|
||||
expect(body).toMatch(/<option[^>]*value="2"[^>]*selected[^>]*>Two<\/option>/);
|
||||
expect(body).toContain('aria-label="Quantity"');
|
||||
expect(body).toContain('aria-describedby="quantity-help"');
|
||||
expect(body).toContain('aria-invalid="true"');
|
||||
expect(body).toMatch(/<select[^>]*disabled/);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { checkFormControls } from '../../scripts/check-ui-styles.mjs';
|
||||
|
||||
const filename = 'src/routes/example/+page.svelte';
|
||||
|
||||
describe('shared form control guard', () => {
|
||||
it.each([
|
||||
'',
|
||||
' type="text"',
|
||||
' type="search"',
|
||||
' type="password"',
|
||||
' type="email"',
|
||||
' type="url"',
|
||||
' type="tel"',
|
||||
' type="number"',
|
||||
' type="time"',
|
||||
' type={visible ? "text" : "password"}'
|
||||
])('rejects raw input%s with a useful location', (attributes) => {
|
||||
expect(checkFormControls(`<div>\n<input${attributes} />\n</div>`, filename)).toEqual([
|
||||
`${filename}:2: use Field with Input instead of raw <input>`
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds selects nested in blocks without confusing event expressions for markup', () => {
|
||||
const source =
|
||||
'{#if ready}<select onchange={(event) => choose(event.currentTarget.value)}><option>One</option></select>{/if}';
|
||||
expect(checkFormControls(source, filename)).toEqual([
|
||||
`${filename}:1: use Field with Select instead of raw <select>`
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows native controls that do not use the text field presentation', () => {
|
||||
const source = [
|
||||
'checkbox',
|
||||
'radio',
|
||||
'file',
|
||||
'hidden',
|
||||
'range',
|
||||
'color',
|
||||
'button',
|
||||
'submit',
|
||||
'reset',
|
||||
'image'
|
||||
]
|
||||
.map((type) => `<input type="${type}" />`)
|
||||
.join('\n');
|
||||
expect(checkFormControls(source, filename)).toEqual([]);
|
||||
expect(checkFormControls('<input type={"checkbox"} />', filename)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores comments, script strings and shared components', () => {
|
||||
const source =
|
||||
'<script>const example = "<input>";</script><!-- <select></select> --><Field label="Search"><Input type="search" /></Field><Select options={[]} />';
|
||||
expect(checkFormControls(source, filename)).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows raw controls only in their implementing primitives', () => {
|
||||
expect(
|
||||
checkFormControls('<input />', 'src/lib/ui/components/primitives/Input.svelte')
|
||||
).toEqual([]);
|
||||
expect(
|
||||
checkFormControls('<select></select>', 'src/lib/ui/components/primitives/Select.svelte')
|
||||
).toEqual([]);
|
||||
expect(
|
||||
checkFormControls('<input />', 'src/lib/ui/components/primitives/Field.svelte')
|
||||
).toHaveLength(1);
|
||||
expect(checkFormControls('<input />', 'src/lib/ui/components/Input.svelte')).toHaveLength(
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user