Use shared form controls and enforce UI patterns (Refs #110)
CI / checks (push) Successful in 4m51s

This commit is contained in:
2026-10-10 00:19:48 -04:00
parent 8d1bb69591
commit cc9c10226e
54 changed files with 1431 additions and 1449 deletions
+61
View File
@@ -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/);
});
+71
View File
@@ -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
);
});
});