Appearance
Validation
Form fields check their own value with rules: plain functions that return true or an error text. The error is shown through SFormField, the wrapper every field is built on: the text replaces the hint under the field, the control gets aria-invalid, and screen readers announce the message. A whole form is checked on submit by the SForm component.
No validation library is required. Built-in rule factories cover the common cases, and schemas from Zod, Valibot and other Standard Schema libraries plug in as a rule.
Rules
A rule is a function of the field value. It returns true when the value is valid, or the text of the error otherwise. It may also return a Promise of either (see Async rules).
ts
type SRule<T> = (value: T, ctx: SRuleContext) => true | string | PromiseLike<true | string>Pass the rules to the field in the rules prop. Leave the field empty and move focus away: the first rule fails. Type a letter and the error goes away; type more than 20 characters and leave the field again: now the second rule fails.
How the rules run:
- In order, up to the first failure. The first rule that returns a string gives the error; the rules after it do not run. So put cheap checks first and a slow server check last.
- One error at a time. The field shows a single message, the one from the first failed rule.
falseis not a result. An error always has a text, otherwise the field would have nothing to show and nothing to announce. The type accepts onlytrueor a string. If a rule still returns something else at runtime (false, an empty string), the field shows theruleFailedtext, "The value could not be checked".- A rule that throws (or whose Promise rejects) fails with the same
ruleFailedtext, and a warning with the error goes to the console in development.
The second argument, ctx, carries messages: the library strings of the current locale. Your own rules can ignore it; the built-in factories take their default texts from it.
rules does not mark the field as required. The * next to the label and the native required attribute still come from the field's own required prop, so set both:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { required } from '@smalt-ui/core'
const name = ref('')
</script>
<template>
<SInput
v-model="name"
label="Name"
required
:rules="[required()]"
/>
</template>Built-in rules
Factories for the common checks are exported from @smalt-ui/core. Each factory except schemaRule takes an optional text of its own as the last argument; without it the text comes from the library dictionary (SMessages).
| Factory | Passes when | Empty value | Default text (key) |
|---|---|---|---|
required(message?) | the value is not empty (see below) | fails | This field is required (ruleRequired) |
minLength(min, message?) | a text or a list has at least min items | passes | text: Enter at least {min} characters (ruleMinLength); list: Select at least {min} (ruleMinItems) |
maxLength(max, message?) | a text or a list has at most max items | passes | text: Enter no more than {max} characters (ruleMaxLength); list: Select no more than {max} (ruleMaxItems) |
min(limit, message?) | a number or a numeric string is at least limit | passes | Must be at least {min} (ruleMin) |
max(limit, message?) | a number or a numeric string is at most limit | passes | Must be no more than {max} (ruleMax) |
pattern(re, message?) | a text matches the regular expression re | passes | Invalid format (rulePattern) |
email(message?) | a text looks like an email address | passes | Enter a valid email address (ruleEmail) |
schemaRule(schema) | a schema accepts the value | checked by the schema | the schema's first issue |
A sign-up form built from the factories. The email is optional, so it only has to look like an address when filled; the age is a numeric SInput, checked with min. For the multiple SSelectminLength counts the chosen options, and the terms SCheckbox is required to be checked. Press Sign up with empty fields to see every rule at once (the form is SForm):
Empty values
Only required() decides whether a field may stay empty. The other factories let an empty value pass, so an optional field is not forced to be filled: the email field above accepts an empty value but not abc.
A value is empty when it is:
undefinedornull;- a string that is empty after trimming the spaces (
'',' '); - an empty array (no tags, nothing selected in a multiple select);
false(an unchecked checkbox or switch);- a date range whose
startandendare both missing.
The check is exported as isEmptyValue(value) for your own rules.
0 is a value, not an empty one. This matters for SRating, whose value is 0 until the user picks a rating: required() always passes on it, so ask for a rating with min(1):
vue
<script setup lang="ts">
import { ref } from 'vue'
import { min } from '@smalt-ui/core'
const score = ref(0)
</script>
<template>
<SRating
v-model="score"
aria-label="Your rating"
:rules="[min(1, 'Please rate')]"
/>
</template>The factories check a single kind of value: minLength/maxLength a string or an array (the length), min/max a single number or numeric string (any other value, such as the array of a range SSlider, passes them), pattern/email a string. What each field passes is listed in Value types.
min and max also accept a string, the value of SInput with numeric ('1234.5'): the string is converted with Number() and compared. A blank string passes, as any empty value does. A string that is not a number (an unfinished '-') passes too: telling a malformed number apart is left to pattern() or rules of your own, so the field does not report "Must be at least 18" for something that is not a number yet. The age field of the sign-up form above works this way.
Custom texts and placeholders
A text passed to a factory replaces the default one. The limit is filled in the same way as in the default texts: {min} in the text of minLength and min, {max} in the text of maxLength and max:
ts
minLength(8, 'The password needs {min} characters or more')
max(10, 'No more than {max} guests')minLength and maxLength pick the default text by the value: "characters" for a string, "Select at least" for an array (tags, a multiple select). A custom text is used for both.
pattern works with any regular expression, including ones with the g or y flag: the search position is reset before each check. email is deliberately loose (some text, @, a domain with a dot, no spaces): a strict check rejects valid addresses, and only the server can confirm an address anyway.
When fields check
The validate-on prop of a field sets when it checks its rules:
validate-on | The field checks |
|---|---|
blur (default) | when focus leaves the field; while a rule error is shown, also on every change of the value |
input | on every change of the value, and when focus leaves the field |
submit | only when the form is submitted or validate() is called; then, while a rule error is shown, on every change |
The three fields below have the same rules, [required(), minLength(4)], and differ only in validate-on. Type two letters into each, move between them, then press Submit:
With the default blur a field goes through these steps:
- While the user has not left the field, it is silent: typing does not show errors, and nothing is checked when the page opens.
- When focus leaves the field, the rules run. This also happens when the user only tabbed through an empty field, so a
required()field reports the missing value right away. - While the rule error is shown, every change of the value runs the rules again, so the error goes away as soon as the value is fixed. A manual
errordoes not start these rechecks. - Once the value passes, the field is silent again until the next blur.
Leaving a field for its own popup (the list of SSelect, the calendar of SDatePicker) does not count as a blur: the field checks when focus leaves the field together with its popup.
input checks on every change of the value, but not on mount: an empty required field does not show an error before the user touches it. submit stays silent until the form is submitted or validate() is called; after that a shown rule error is rechecked on every change, the same as in blur mode, so the user sees it go away while fixing it.
The mode can be set in several places. From strongest to weakest:
validate-onpassed to the field itself;validate-onof the enclosingSForm(passed to it or set in its prop defaults);- the prop defaults of the field (
SInput: { validateOn: 'input' }, orglobal); blur.
To switch every field of the app to checking while typing, set a global default:
ts
app.use(createSUI({ defaults: { global: { validateOn: 'input' } } }))global reaches SForm as well as the single fields, so the setting works the same inside a form and outside one. The field's own defaults apply inside a form only while the form has no mode. With global.validateOn set, every SForm has a mode, so a default for one kind of field (SInput: { validateOn }) applies only outside forms. The same happens with defaults: { SForm: { validateOn: 'submit' }, SInput: { validateOn: 'input' } }: an SInput inside a form checks on submit, and outside a form on input.
Changing the rules array does not check the field by itself: the new rules apply at the next check (blur, change, submit or validate()). An inline array (:rules="[required()]") is a new array on every render of the parent, so rechecking on every new array would check the field on every render. To apply new rules at once, call validate() on the field (see Checking from code).
Checking from code
Rules work without a form. Through a template ref each field provides:
validate(): runs the rules now and resolves totruewhen the value is valid. It shows the error, if any, whatever thevalidate-onmode;resetValidation(): clears the rule error; the field then waits for its next trigger, as if it had never been checked. The value and a manualerrorare not changed;focus(): moves focus to the control.
vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { required } from '@smalt-ui/core'
const city = ref('')
const step = ref(1)
const field = useTemplateRef('field')
async function next() {
if (await field.value?.validate()) step.value++
else field.value?.focus()
}
</script>
<template>
<SInput
ref="field"
v-model="city"
label="City"
:rules="[required()]"
/>
<SButton @click="next"> Next </SButton>
</template>validate() always resolves with the outcome of the latest check: if another check starts while it is still waiting for an async rule, it resolves with the result of that newer check. If resetValidation() interrupts it, it resolves false.
Disabled fields
A disabled field is not checked: validate() resolves true, changes and blur do not run the rules, and a rule error is cleared the moment the field becomes disabled. A manual error is still displayed on a disabled field, but it does not make its validate() fail. In a form, a disabled field does not take part in the check at all.
Async rules
A rule may return a Promise, for example to ask the server whether a login is free. In the demo the "server" answers after one second and knows the names admin, root and smalt; the form around the field reports validating while the answer is on its way. Type a name and move focus away:
With a real request the rule is an async function:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { required, type SRule } from '@smalt-ui/core'
import { api } from './api'
const login = ref('')
const isFree: SRule<string> = async (v) => !(await api.isTaken(v)) || 'This login is taken'
</script>
<template>
<SInput
v-model="login"
label="Login"
:rules="[required(), isFree]"
/>
</template>- The field waits for the Promise before it runs the next rule, so a failed
required()above means no request is sent for an empty value. - Only the latest check counts. If the value changes while a request is in flight, the old answer is thrown away when it arrives, so a slow response can never overwrite a newer result.
validatingistruewhile an awaited rule runs. Synchronous rules settle in the same tick and never set it. Library fields do not show a spinner by themselves; the state is available to custom fields throughuseValidation, andSFormreports it for all its fields in thevalidatingslot prop, as in the demo above.- A rejected Promise shows the
ruleFailedtext, as a thrown error does.
The field does not debounce the rules. In blur mode that rarely matters (one check per blur), but with validate-on="input", or while an error is shown, every keystroke runs the rules again. Debounce the request inside the rule, or cache answers per value, when the check is expensive. This version of isFree replaces the one in the example above:
ts
const cache = new Map<string, Promise<boolean>>()
const isFree: SRule<string> = async (v) => {
if (!cache.has(v)) cache.set(v, api.isTaken(v))
return !(await cache.get(v)) || 'This login is taken'
}Server errors
Some errors are known only after the form is sent: "this email is already registered". Pass them to the field in the error prop. A manual error:
- wins over the rules: the field shows it instead of a rule error;
- makes the field invalid:
validate()resolvesfalsewhile it is set, even if every rule passes, and a form with such a field is not valid; - stays until you clear it: the field does not clear it on its own. The usual place to clear it is the change of the value.
Enter a valid address, press Simulate a server error, then type into the field:
With a real request, the error comes from the response:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { email, required } from '@smalt-ui/core'
import { api } from './api'
const address = ref('')
const serverError = ref<string>()
async function save() {
const response = await api.register(address.value)
if (response.errors?.email) serverError.value = response.errors.email
}
</script>
<template>
<SInput
v-model="address"
label="Email"
:rules="[required(), email()]"
:error="serverError"
@update:model-value="serverError = undefined"
/>
<SButton @click="save"> Register </SButton>
</template>When error is cleared, the field shows the error of its rules again, if there is one. The invalid prop still only paints the field red: it adds no text and does not fail the check.
Schemas: Zod, Valibot
schemaRule(schema) turns a schema into a rule. It works with any library that implements Standard Schema: Zod 3.24 and later, Valibot 1, ArkType, Effect Schema. Smalt UI does not depend on any of them: install the one you use.
The message of the schema's first issue becomes the error text. The first field below checks with Zod, the second with Valibot:
An async schema (Zod refine with an async function, Valibot pipeAsync) makes the rule async, and the field awaits it as any async rule:
ts
const loginSchema = z
.string()
.min(3, 'At least 3 characters')
.refine(async (v) => !(await api.isTaken(v)), 'This login is taken')schemaRule goes into the same array as other rules, and a field can have several schema rules; they run in order like any rules.
Three things to keep in mind:
The schema sees the value as the field holds it. The rule receives the raw model, not a converted one:
SInputpasses a string even innumericmode,SNumberFieldpassesnullwhen empty, date fields passDateValueobjects. Write the schema for that type. See Value types. For a numericSInput, keep in mind thatz.coerce.number()turns''into0. With a bound, a cleared optional field gets the bound's error ("At least 1") instead of passing. Without a bound, the schema accepts an empty field as0, so it cannot express "required". Keep the empty string out of the conversion instead:tsconst quantity = z.union([z.literal(''), z.coerce.number().min(1, 'At least 1')])Empty values reach the schema. Unlike the factories,
schemaRuledoes not let an empty value pass:z.email()fails on''. For an optional field allow the empty value in the schema:z.union([z.literal(''), z.email()]). For a required field putrequired()first,[required(), schemaRule(z.email())]: it reports the empty value with its own text, and the rules stop there, so the schema checks only the format.An issue without a message falls back to the
ruleFailedtext, as a rule that returns no text does.
A schema rule sees one field's value only. Checks across fields ("the passwords match") need a validation library that owns the whole form.
Forms
SForm wraps the fields in a native <form novalidate> and checks them together. The fields register in it by themselves, wherever they are inside it:
- on submit it checks every field, emits
submitonly when all of them pass, and otherwise emitsinvalidand moves focus to the first invalid field; - without a
submitlistener a valid form is submitted natively (action,method); validate-onon the form sets the mode for all its fields;v-modeland the default slot report the validity (true,falseornull),validatingand the list of errors;validate()andresetValidation()check or clear every field from code;- a reset button triggers
reset: the app clears its data, and the form clears the errors.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { email, minLength, required } from '@smalt-ui/core'
const address = ref('')
const password = ref('')
function send() {
// Every field has passed its rules here.
}
</script>
<template>
<SForm @submit="send">
<SInput
v-model="address"
label="Email"
autocomplete="username"
:rules="[required(), email()]"
/>
<SInput
v-model="password"
label="Password"
type="password"
autocomplete="current-password"
:rules="[required(), minLength(8)]"
/>
<SButton type="submit"> Sign in </SButton>
</SForm>
</template>Live examples of submission, reset, validity and methods are on the Form page.
VeeValidate
Smalt UI rules check one field at a time. When the whole form is described by one schema, or when fields depend on each other ("the passwords match"), a form library such as VeeValidate can own the validation instead. The fields then take its errors through the error prop, and a plain <form> replaces SForm, since VeeValidate decides when the form is valid. The example uses VeeValidate 4 with @vee-validate/zod, which requires Zod 3 (its peer dependency is zod ^3.24), so the schema is written in Zod 3 syntax:
vue
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const { defineField, errors, handleSubmit } = useForm({
validationSchema: toTypedSchema(
z
.object({
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'At least 8 characters'),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: 'The passwords do not match',
path: ['confirm'],
}),
),
})
const [address, addressAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')
const [confirm, confirmAttrs] = defineField('confirm')
const onSubmit = handleSubmit((values) => {
// values is typed from the schema
})
</script>
<template>
<form
novalidate
@submit="onSubmit"
>
<SInput
v-model="address"
v-bind="addressAttrs"
label="Email"
autocomplete="email"
:error="errors.email"
/>
<SInput
v-model="password"
v-bind="passwordAttrs"
label="Password"
type="password"
autocomplete="new-password"
:error="errors.password"
/>
<SInput
v-model="confirm"
v-bind="confirmAttrs"
label="Repeat the password"
type="password"
autocomplete="new-password"
:error="errors.confirm"
/>
<SButton type="submit"> Sign up </SButton>
</form>
</template>The fields get no rules here: the error prop alone shows the message with the same accessibility wiring, and VeeValidate decides when to validate. defineField returns the value and the attributes VeeValidate needs on the control (its event listeners); v-bind passes them to SInput, which forwards them to its input.
VeeValidate 5 (in beta at the time of writing) accepts a Standard Schema, such as a Zod 4 or Valibot schema, as validationSchema directly, without toTypedSchema; see its migration guide.
Custom fields
The useValidation composable gives your own control the same behavior as the library fields: rules, validate-on, a manual error, async checks and, inside SForm, taking part in the form. Library fields use it internally.
The usual way is to wrap the control in SFormField and pass it the error from useValidation:
vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { SFormField, useValidation, type SRule, type SValidateOn } from '@smalt-ui/core'
const props = defineProps<{
label?: string
name?: string
rules?: SRule<string>[]
validateOn?: SValidateOn
error?: string
disabled?: boolean
}>()
const model = defineModel<string>({ default: '' })
const input = useTemplateRef<HTMLInputElement>('input')
const { errorMessage, invalid, validating, onBlur, validate, resetValidation } = useValidation({
value: model,
rules: () => props.rules,
validateOn: () => props.validateOn,
error: () => props.error,
disabled: () => props.disabled,
name: () => props.name,
focus: () => input.value?.focus(),
el: () => input.value,
})
defineExpose({ validate, resetValidation, errorMessage, invalid, validating })
</script>
<template>
<SFormField
v-slot="{ id, describedBy, invalid: marked }"
:label="label"
:error="errorMessage"
>
<div
class="color-code"
:class="{ 'color-code--invalid': marked }"
@focusout="onBlur"
>
<span>#</span>
<input
:id="id"
ref="input"
v-model="model"
:name="name"
:disabled="disabled"
:aria-describedby="describedBy"
:aria-invalid="marked || undefined"
/>
</div>
</SFormField>
</template>
<style scoped>
.color-code {
display: flex;
align-items: center;
gap: 4px;
height: 2.5rem;
padding: 0 12px;
border: 1px solid var(--s-color-outline);
border-radius: var(--s-radius-sm);
background: var(--s-color-surface);
color: var(--s-color-text);
}
.color-code:focus-within {
border-color: var(--s-color-primary);
}
.color-code--invalid {
border-color: var(--s-color-negative);
}
.color-code input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font: inherit;
}
</style>Call onBlur when focus leaves the whole control. For a control made of several focusable parts, skip the moves between them: check event.relatedTarget against the control's root before calling onBlur, otherwise the field would check each time focus moves inside it.
The control also exposes errorMessage, invalid and validating, so the demo below can print its state. Saved as ColorCodeField.vue, it is used like a library field. The first field checks on blur; the second checks on every change and has an async rule that takes 0.8 s and rejects ffffff. The buttons set a server error on the first field, call validate() on both and print what it resolves to, and call resetValidation().
#
#
blur:
errorMessage: undefined invalid: false validating: falseinput: errorMessage: undefined invalid: false validating: falsevalidate(): not calledOptions
Every option except value is optional. Options that can change take a ref or a getter.
| Option | Type | Purpose |
|---|---|---|
value | MaybeRefOrGetter<T> | The value the rules check. Changes are watched deeply, so a mutated array counts. |
rules | MaybeRefOrGetter<SRule<T>[] | undefined> | Rules, run in order until the first failure. |
validateOn | MaybeRefOrGetter<SValidateOn | undefined> | When to check; without it the SForm setting applies, then blur. |
error | MaybeRefOrGetter<string | undefined> | A manual error (a server response); it wins over the rules. |
disabled | MaybeRefOrGetter<boolean | undefined> | A disabled control is not checked and leaves the form. |
name | MaybeRefOrGetter<string | undefined> | Field name, reported with the errors of SForm. |
focus | () => void | Moves focus to the control; SForm calls it for the first invalid field on submit. |
el | () => Element | null | undefined | Root element of the control; SForm orders its fields by their place in the page. |
Pass validateOn only when it is set on the control itself, as in the example above (the prop has no default), and leave it undefined otherwise: useValidation then falls back to the setting of the enclosing SForm, then to blur. A value resolved from prop defaults would override the form's validate-on.
Return value
| Property | Type | Meaning |
|---|---|---|
errorMessage | ComputedRef<string | undefined> | The error to show: the manual error, otherwise the text of the failed rule. |
invalid | ComputedRef<boolean> | Whether an error is shown. |
validating | Readonly<Ref<boolean>> | Whether an async rule is running. |
validate() | () => Promise<boolean> | Runs the rules now; resolves true when the value is valid. |
resetValidation() | () => void | Clears the rule error; the control waits for its next trigger. |
onBlur() | () => void | Call when focus leaves the control; checks unless the mode is submit. |
validate(), resetValidation(), disabled fields and stale async results behave exactly as described for the library fields above. Outside SForm the composable works on its own; inside one, the control registers when it mounts and leaves the form when it unmounts or becomes disabled.
Value types
A rule receives the field's v-model value as is. Write custom rules and schemas for this type:
| Field | Value | Empty for required() |
|---|---|---|
SInput | string; string[] with use-tags; with numeric, a number as a string ('1234.5'), which min/max convert | '', [] |
STextarea | string | '' |
SSelect | string; string[] with multiple or use-tags | nothing chosen, [] |
SAutocomplete | string: the value of the chosen suggestion, not the typed query | nothing chosen |
SNumberField | number | null | null |
SPinInput | string[], one character per cell | [] |
SSlider | number; number[] for a range (not checked by min/max) | never |
SColorField | string in hex ('#3B82F6') | '' |
SEditable | string | '' |
SFileUpload | File[], in the order they were added | [] |
SDateField | DateValue | no date |
STimeField | STimeValue (Time, CalendarDateTime or ZonedDateTime) | no time |
STimePicker | STimeValue, as in STimeField: the rules run on the field inside the picker | no time |
SDatePicker | DateValue | no date |
SDateRangePicker | SDateRange: { start, end } | both ends missing |
SCheckbox | boolean | 'indeterminate' | false |
SCheckboxGroup | string[]: the values of the checked boxes | [] |
SRadioGroup | string | nothing chosen |
SSwitch | boolean | false |
SRating | number, 0 without a rating | never: use min(1) |
Fields whose model has no default of its own (every field above except SNumberField, SPinInput, SCheckbox, SSwitch, SRating, SSlider, SEditable and SFileUpload) pass undefined while their v-model holds nothing. Rules typed for a string should allow that: (v?: string) => …. The built-in factories handle undefined already.
Some consequences worth knowing:
- Numeric
SInputholds a string with a dot as the decimal separator and no group separators ('1234.5').min/maxconvert it themselves (see Empty values); in rules of your own and in schemas convert it withNumber()orz.coerce.number(). SPinInput:required()only checks that the array is not empty. To require every cell, check the joined length:(v: string[]) => v.join('').length === 6 || 'Enter all 6 digits'.SCheckbox:'indeterminate'is not empty, sorequired()passes on it. Checkv === trueto require a checked box in a tri-state checkbox.min/maxcheck a single value: a single-thumbSSlider,SNumberField,SRatingand a numericSInput. Any other value passes them, so a rangeSSlider(number[]) needs a rule of its own:(v: number | number[]) => !Array.isArray(v) || v[1] - v[0] >= 10 || 'Pick a range of at least 10'.
Localization
The default texts of the built-in rules are keys of the library dictionary SMessages, like every other library string:
| Key | Default (en) |
|---|---|
ruleRequired | This field is required |
ruleMinLength | Enter at least {min} characters |
ruleMaxLength | Enter no more than {max} characters |
ruleMinItems | Select at least |
ruleMaxItems | Select no more than |
ruleMin | Must be at least |
ruleMax | Must be no more than |
rulePattern | Invalid format |
ruleEmail | Enter a valid email address |
ruleFailed | The value could not be checked |
Translate them through ConfigProvider (or installLocale, or the messages option of the Nuxt module), as described in Internationalization:
vue
<script setup lang="ts">
import { ConfigProvider, type SMessages } from '@smalt-ui/core'
const deMessages: Partial<SMessages> = {
ruleRequired: 'Pflichtfeld',
ruleMinLength: 'Mindestens {min} Zeichen',
ruleEmail: 'Ungültige E-Mail-Adresse',
}
</script>
<template>
<ConfigProvider :messages="deMessages">
<App />
</ConfigProvider>
</template>In the demo the toggle switches the dictionary of the field between English and German. Leave the field empty and move focus away, switch the language, then type one letter: the error on screen changes its language at the next check.
The text is taken from the dictionary when the check runs, not when the rule is created, so required() in a module-level array follows the current locale. An error already on screen keeps its text until the field checks again.
A text you pass to a factory (required('Enter your name')) is used as is and is not translated; translate it with your app's i18n before passing it.
Accessibility
Fields show rule errors through SFormField, so a rule error gets the same wiring as a manual error:
- the control gets
aria-invalid="true"; - the error text replaces the hint under the field and is linked to the control through
aria-describedby, so a screen reader reads it together with the field; - the text is rendered into a live region (
aria-live="polite"), so a new error is announced without moving focus; - on submit,
SFormmoves focus to the first invalid field in page order, so a keyboard or screen reader user lands on the problem.
The live region stays in the page while the field has no error: a region is announced only if it exists before its content appears.