sveltelean Primitives
Versionv0.2.0 GitHub

Example

A hint and an error

The receipt is sent to this address.

Letters, digits and hyphens.

This username is taken.

<div data-slean="field">
	<label for="field-email" data-slean-part="label">Email</label>
	<input id="field-email" type="email" name="email" autocomplete="email"
		aria-describedby="field-email-description" data-slean="input" />
	<p id="field-email-description" data-slean-part="description">
		The receipt is sent to this address.
	</p>
</div>

<div data-slean="field">
	<label for="field-username" data-slean-part="label">Username</label>
	<input id="field-username" name="username" autocomplete="username" value="ada"
		aria-describedby="field-username-description field-username-error"
		aria-invalid="true" data-slean="input" />
	<p id="field-username-description" data-slean-part="description">
		Letters, digits and hyphens.
	</p>
	<p id="field-username-error" data-slean-part="error">This username is taken.</p>
</div>
Tier 0: the second field shows an error the application rendered, with aria-invalid on the input. Its accessible description is the hint followed by the error, in the order aria-describedby lists them.

Why this implementation exists

A label, a hint and an error message are three elements and two attributes. <label for> gives the control its name, aria-describedby gives it a description made of the listed elements in the listed order, and aria-invalid exposes the error state. A field component that generates the ids and wires them spends script and state on what is markup.

What the platform does not decide is where an error comes from. The contract’s answer is the application: the server, or the application’s own validation, renders the message and aria-invalid together and removes both when the value is fixed. The stylesheet never reveals a hidden message on :user-invalid, because a hidden element listed in aria-describedby is still read as part of the description, and one that is not listed is never read.

The browser owns

  • the accessible name from <label for>, and focus on a label click
  • the accessible description from aria-describedby, in the listed order
  • the error state from aria-invalid and from constraint validation
  • the platform’s own message on submit for a constraint the value breaks

Svelte Lean owns

  • field.css: the stacked and the horizontal layout, label, description and error type, the muted label of a disabled control
  • the contract: which ids go where, and why an error is rendered rather than revealed
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

npm install @svelte-lean/styles
stylesheets
import '@svelte-lean/styles/input.css';
import '@svelte-lean/styles/field.css';

Wrap the label, the control and the messages in <div data-slean="field"> and mark them with data-slean-part: label, description, error. Give the description and the error ids, and list them in the control’s aria-describedby.

Render the error part and aria-invalid="true" only while there is an error; the control’s own stylesheet colors it.

data-slean-orientation="horizontal" puts the label beside the control.

Anatomy

PartElementdata-slean-partRequiredNotes
root<div data-slean="field">–yesOption: data-slean-orientation="horizontal" puts the label beside the control.
label<label for="…">labelyesNames the control. Omitted when the control is a fieldset with its own legend.
control<input>, <select>, <textarea>, a fieldset, …–yesIts own primitive and stylesheet; aria-describedby lists the description and error ids.
description<p id="…">descriptionnoA hint, read as part of the control’s description.
error<p id="…">errornoRendered by the application with aria-invalid="true" on the control, and removed with it.

Runtime profile

Tier 0: the field has no behavior module, the Vite plugin maps field to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.

Accessibility contract

  • <label for> names the control; a click on the label focuses it.
  • aria-describedby lists the description and then the error; screen readers read them after the name and the role.
  • aria-invalid="true" with a visible message marks an error the application found. The browser reports required, type and pattern failures itself on submit.
  • A group control is a fieldset named by its legend, and aria-describedby goes on the fieldset.
  • The error is text, not a color alone, so it survives forced colors and color blindness.

Keyboard

KeyWhenResult
Tabon the pageReaches the control; the label, description and error are not focusable (native)

Platform features

FeatureBaselineOutside the target
<label for>, aria-describedby, aria-invalidWidely availableNot applicable
:has()Baseline 2023 (Chrome 105, Safari 15.4, Firefox 121)The label of a disabled control keeps its color

Without JavaScript

Fully functional: a form that posts and renders the returned errors works without JavaScript, and the browser blocks a submit that breaks a constraint.

Server rendering

Everything is markup. A server-side validation answer renders the same field with the error part and aria-invalid.

Before hydration

Nothing is attached. An error rendered on the server is in the document before hydration and stays until the application removes it.

Styling

field.css stacks the parts or places them in two columns, sets the type and color of the label, description and error, and mutes the label of a disabled control through :has(> :disabled). The control’s colors come from its own stylesheet. The tokens it reads:

TokenDefault (light)Applies to
--slean-space-20.5remgap
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-font-weight-medium500font-weight
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-dangeroklch(55% 0.2 25)color
--slean-space-41remgap

State selectors the stylesheet targets, all from the platform or ARIA: :disabled.

Variant attributes: data-slean-orientation (horizontal).

Controlled integration

With a SvelteKit form action, the server returns the error and the submitted value, and the page renders the error part and aria-invalid from them. The same page works with JavaScript disabled.

+page.svelte
<script lang="ts">
	// +page.svelte: the error part and aria-invalid exist only when there is an error.
	let { form } = $props();
	const error = $derived(form?.errors?.username);
</script>

<form method="POST">
	<div data-slean="field">
		<label for="username" data-slean-part="label">Username</label>
		<input id="username" name="username" value={form?.username ?? ''}
			aria-describedby={error ? 'username-error' : undefined}
			aria-invalid={error ? 'true' : undefined} data-slean="input" />
		{#if error}
			<p id="username-error" data-slean-part="error">{error}</p>
		{/if}
	</div>
	<button data-slean="button">Save</button>
</form>

Compatibility notes

Labels, aria-describedby and aria-invalid are widely available. :has() (Baseline 2023) mutes the label of a disabled control; where it is missing the label keeps its color.

Examples

Horizontal

data-slean-orientation="horizontal" puts the label in a first column of up to 10rem, aligned with the first line of the control; the description and the error follow the control in the second column.

Shown on your posts.

horizontal
<div data-slean="field" data-slean-orientation="horizontal">
	<label for="field-name" data-slean-part="label">Display name</label>
	<input id="field-name" name="name" autocomplete="nickname"
		aria-describedby="field-name-description" data-slean="input" />
	<p id="field-name-description" data-slean-part="description">Shown on your posts.</p>
</div>

<div data-slean="field" data-slean-orientation="horizontal">
	<label for="field-website" data-slean-part="label">Website</label>
	<input id="field-website" type="url" name="website" autocomplete="url" data-slean="input" />
</div>

A group control

A segmented control, a rating or a radio group is a fieldset named by its own legend. The field has no label part then, and aria-describedby goes on the fieldset.

Units

Used for distances and weights.

group
<div data-slean="field">
	<fieldset data-slean="segmented" aria-describedby="field-units-description">
		<legend>Units</legend>
		<label><input type="radio" name="field-units" value="metric" checked /> Metric</label>
		<label><input type="radio" name="field-units" value="imperial" /> Imperial</label>
	</fieldset>
	<p id="field-units-description" data-slean-part="description">
		Used for distances and weights.
	</p>
</div>

Server validation

The action returns the error with the submitted values; the page in Controlled integration renders the error part and aria-invalid from it.

+page.server.ts
// +page.server.ts: the server validates and returns the errors with the values.
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
	default: async ({ request }) => {
		const data = await request.formData();
		const username = String(data.get('username') ?? '');
		if (await isTaken(username)) {
			return fail(400, { username, errors: { username: 'This username is taken.' } });
		}
	}
};

Testing

Source