Field
Ties a label, hint and error message to any control, with a small validation helper.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add fieldpnpm dlx ermineui add fieldyarn dlx ermineui add fieldbunx ermineui add fieldThat copies 3 files into src/components/ui/field/. Files you already have are kept.
Use
import { Field } from '@/components/ui/field'<script setup lang="ts">
import { Field, email, required, useValidation } from '@/components/ui/field'
const account = ref({ name: '', email: '' })
const form = useValidation(account, {
name: [required('Give the team a name.')],
email: [required(), email()],
})
const save = () => form.validate() && send(account.value)
</script>
<template>
<Field v-slot="{ control }" label="Email" required hint="Invoices are sent here." :error="form.errors.email">
<Input v-bind="control" v-model="account.email" @blur="form.touch('email')" />
</Field>
</template>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Field.vue | src/components/ui/field/Field.vue |
index.ts | src/components/ui/field/index.ts |
validation.ts | src/components/ui/field/validation.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Field.vue 86 lines
<script setup lang="ts">
import { computed, useId } from 'vue'
const props = defineProps<{
label?: string
/** Help shown under the control. */
hint?: string
/** The problem to show. Anything non-empty marks the control invalid. */
error?: string | null | false
/** Mark the label with an asterisk and tell screen readers an answer is needed. */
required?: boolean
}>()
const id = useId()
const hintId = `${id}-hint`
const errorId = `${id}-error`
/** Bind this to the control inside: `<Input v-bind="control" />`. */
const control = computed(() => ({
id,
'aria-describedby': [props.error ? errorId : undefined, props.hint ? hintId : undefined].filter(Boolean).join(' ') || undefined,
'aria-invalid': props.error ? ('true' as const) : undefined,
'aria-required': props.required ? ('true' as const) : undefined,
}))
</script>
<template>
<div class="er-field" :data-invalid="error ? '' : undefined">
<label v-if="label" class="er-field-label" :for="id">
{{ label }}
<span v-if="required" class="er-field-required" aria-hidden="true">*</span>
</label>
<slot :control="control" :id="id" :invalid="!!error" />
<p v-if="hint" :id="hintId" class="er-field-hint">{{ hint }}</p>
<!-- Always in the page, empty until there is a problem: screen readers reliably announce text that
arrives in an alert they already know about, and often miss an alert that is added later. -->
<p :id="errorId" class="er-field-error" role="alert">{{ error || '' }}</p>
</div>
</template>
<style>
@layer components {
.er-field {
position: relative;
display: grid;
gap: 0.5rem;
min-width: 0;
}
.er-field-label {
color: var(--er-foreground);
font-size: 0.875rem;
font-weight: var(--er-weight-medium);
line-height: 1.25;
}
.er-field-required {
color: var(--er-destructive);
}
.er-field-hint,
.er-field-error {
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
}
.er-field-hint {
color: var(--er-muted-foreground);
}
.er-field-error {
color: var(--er-destructive);
}
/* While empty it takes no room in the layout. */
.er-field-error:empty {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
}
</style>index.ts 4 lines
/** Field — ties a label, hint and error message to any control, with a small validation helper. */
export { default as Field } from './Field.vue'
export * from './validation'validation.ts 99 lines
import { reactive, toValue, watch, type MaybeRefOrGetter } from 'vue'
/** Returns the message to show when the value is not acceptable, or nothing when it is. */
export type Rule<T = unknown> = (value: T) => string | undefined | null | false
const isEmpty = (value: unknown) =>
value === undefined || value === null || value === '' || value === false || (Array.isArray(value) && value.length === 0)
/** Something must be entered, chosen or ticked. */
export const required =
(message = 'This field is required.'): Rule =>
(value) =>
isEmpty(value) && message
// The rules below pass on an empty value, so "optional, but if given it must be an email" needs no extra code.
// Combine with required() when the field must be filled in.
export const minLength =
(length: number, message = `Use at least ${length} characters.`): Rule =>
(value) =>
!isEmpty(value) && String(value).length < length && message
export const maxLength =
(length: number, message = `Use at most ${length} characters.`): Rule =>
(value) =>
!isEmpty(value) && String(value).length > length && message
export const pattern =
(expression: RegExp, message = 'This is not in the expected format.'): Rule =>
(value) =>
!isEmpty(value) && !expression.test(String(value)) && message
export const email = (message = 'Enter an email address, such as name@example.com.'): Rule => pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, message)
export const min =
(limit: number, message = `Enter ${limit} or more.`): Rule =>
(value) =>
!isEmpty(value) && Number(value) < limit && message
export const max =
(limit: number, message = `Enter ${limit} or less.`): Rule =>
(value) =>
!isEmpty(value) && Number(value) > limit && message
/** The first message from `rules` that applies to `value`, or undefined when every rule passes. */
export function check<T>(value: T, rules: ReadonlyArray<Rule<T>> = []): string | undefined {
for (const rule of rules) {
const message = rule(value)
if (message) return message
}
return undefined
}
/**
* Validation for a group of values.
*
* const form = useValidation(() => answers, { email: [required(), email()] })
* form.errors.email the message to show, once the field has been checked
* form.validate() check everything (or just the named fields); true when all pass
* form.touch('email') check one field — bind it to @blur
*
* A field is quiet until it is checked. Once it shows an error, it is re-checked as the
* value changes, so the message clears the moment the problem is fixed.
*/
export function useValidation<T extends Record<string, unknown>>(
values: MaybeRefOrGetter<T>,
rules: { [K in keyof T]?: ReadonlyArray<Rule<T[K]>> },
) {
const keys = Object.keys(rules) as Array<keyof T & string>
const errors = reactive({}) as { [K in keyof T]?: string }
function touch(key: keyof T & string): boolean {
const message = check(toValue(values)[key], rules[key])
;(errors as Record<string, string | undefined>)[key] = message
return !message
}
function validate(only: ReadonlyArray<keyof T & string> = keys): boolean {
// Every field is checked, not just up to the first failure, so all the messages appear at once.
return only.map(touch).every(Boolean)
}
function reset() {
for (const key of keys) (errors as Record<string, string | undefined>)[key] = undefined
}
for (const key of keys) {
watch(
() => toValue(values)[key],
() => {
if (errors[key]) touch(key)
},
{ deep: true },
)
}
return { errors, validate, touch, reset }
}