Skip to content

Form Field

A wrapper component that provides a label, hint text, and error message around any form control. Instead of each input managing its own label and validation display, CoarFormField handles these concerns in one place.

ts
import { CoarFormField } from '@cocoar/vue-ui';

Basic Usage

Wrap any form control in CoarFormField and pass label, hint, or error props. The label is automatically associated with the input inside via generated IDs.

Enter your first and last name
Please enter a valid email address
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 16px; max-width: 320px;">
    <CoarFormField label="Full Name" hint="Enter your first and last name">
      <CoarTextInput v-model="name" placeholder="Jane Doe" />
    </CoarFormField>
    <CoarFormField label="Email" error="Please enter a valid email address">
      <CoarTextInput value="not-an-email" />
    </CoarFormField>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarTextInput, CoarFormField } from '@cocoar/vue-ui';

const name = ref('');
</script>

Grouping Controls

Use CoarFormField to add a group label and shared error to a set of checkboxes or radio buttons. Each checkbox keeps its own inline label prop for the option text.

At least one permission is required
vue
<template>
  <div style="max-width: 400px;">
    <CoarFormField
      label="Permissions"
      :error="permissionsError"
      hint="Select at least one permission"
      required
    >
      <div style="display: flex; flex-direction: column; gap: 8px;">
        <CoarCheckbox v-model="read" label="Read — View content" />
        <CoarCheckbox v-model="write" label="Write — Create and edit content" />
        <CoarCheckbox v-model="admin" label="Admin — Manage users and settings" />
      </div>
    </CoarFormField>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { CoarCheckbox, CoarFormField } from '@cocoar/vue-ui';

const read = ref(false);
const write = ref(false);
const admin = ref(false);

const permissionsError = computed(() => {
  if (!read.value && !write.value && !admin.value) {
    return 'At least one permission is required';
  }
  return '';
});
</script>

Registration Form

A complete registration form with submit-on-click validation. Errors only appear after the user attempts to submit.

We'll never share your email
At least 8 characters
vue
<template>
  <form class="demo-form" @submit.prevent="onSubmit">
    <CoarFormField label="Full Name" :error="errors.name" required>
      <CoarTextInput v-model="form.name" placeholder="Jane Doe" required />
    </CoarFormField>

    <CoarFormField label="Email" :error="errors.email" hint="We'll never share your email" required>
      <CoarTextInput v-model="form.email" placeholder="jane@example.com" required />
    </CoarFormField>

    <CoarFormField label="Password" :error="errors.password" hint="At least 8 characters" required>
      <CoarPasswordInput v-model="form.password" required />
    </CoarFormField>

    <CoarFormField label="Role" :error="errors.role" required>
      <CoarSelect v-model="form.role" :options="roles" placeholder="Select a role..." />
    </CoarFormField>

    <CoarFormField label="Department">
      <CoarSelect v-model="form.department" :options="departments" placeholder="Optional..." />
    </CoarFormField>

    <CoarFormField :error="errors.terms">
      <CoarCheckbox v-model="form.terms" label="I agree to the terms and conditions" />
    </CoarFormField>

    <div class="demo-form__actions">
      <CoarButton type="submit" :disabled="!isValid">Create Account</CoarButton>
    </div>

    <CoarNote v-if="submitted" variant="success" padding="s">
      Account created successfully!
    </CoarNote>
  </form>
</template>

<script setup lang="ts">
import { reactive, ref, computed } from 'vue';
import { CoarFormField, CoarTextInput, CoarPasswordInput, CoarSelect, CoarCheckbox, CoarButton, CoarNote } from '@cocoar/vue-ui';
import type { CoarSelectOption } from '@cocoar/vue-ui';

const form = reactive({
  name: '',
  email: '',
  password: '',
  role: null as string | null,
  department: null as string | null,
  terms: false,
});

const submitted = ref(false);
const touched = ref(false);

const roles: CoarSelectOption<string>[] = [
  { value: 'developer', label: 'Developer' },
  { value: 'designer', label: 'Designer' },
  { value: 'manager', label: 'Manager' },
  { value: 'qa', label: 'QA Engineer' },
];

const departments: CoarSelectOption<string>[] = [
  { value: 'engineering', label: 'Engineering' },
  { value: 'product', label: 'Product' },
  { value: 'marketing', label: 'Marketing' },
];

const errors = computed(() => {
  if (!touched.value) return { name: '', email: '', password: '', role: '', terms: '' };
  return {
    name: form.name.length === 0 ? 'Name is required' : '',
    email: form.email.length === 0 ? 'Email is required' : !form.email.includes('@') ? 'Enter a valid email' : '',
    password: form.password.length === 0 ? 'Password is required' : form.password.length < 8 ? 'At least 8 characters' : '',
    role: !form.role ? 'Please select a role' : '',
    terms: !form.terms ? 'You must accept the terms' : '',
  };
});

const isValid = computed(() =>
  form.name.length > 0 &&
  form.email.includes('@') &&
  form.password.length >= 8 &&
  form.role !== null &&
  form.terms
);

function onSubmit() {
  touched.value = true;
  if (isValid.value) {
    submitted.value = true;
  }
}
</script>

<style scoped>
.demo-form {
  display: flex;
  flex-direction: column;
  gap: var(--coar-spacing-m);
  max-width: 400px;
}
.demo-form__actions {
  padding-top: var(--coar-spacing-s);
}
</style>

Settings Panel

A settings page using every form control type — text inputs, textareas, selects, checkboxes, radio groups, and switches — all wrapped in CoarFormField for consistent layout.

This is shown publicly
vue
<template>
  <form class="demo-form" @submit.prevent>
    <CoarFormField label="Display Name" hint="This is shown publicly">
      <CoarTextInput v-model="displayName" placeholder="Your display name" />
    </CoarFormField>

    <CoarFormField label="Bio">
      <CoarTextInput v-model="bio" placeholder="Tell us about yourself..." :rows="3" />
    </CoarFormField>

    <CoarFormField label="Language">
      <CoarSelect v-model="language" :options="languages" />
    </CoarFormField>

    <CoarFormField label="Notifications">
      <div class="demo-checks">
        <CoarCheckbox v-model="emailNotifs" label="Email notifications" />
        <CoarCheckbox v-model="pushNotifs" label="Push notifications" />
        <CoarCheckbox v-model="weeklyDigest" label="Weekly digest" />
      </div>
    </CoarFormField>

    <CoarFormField label="Theme">
      <CoarRadioGroup v-model="theme" name="theme" orientation="horizontal">
        <CoarRadioButton value="light" label="Light" />
        <CoarRadioButton value="dark" label="Dark" />
        <CoarRadioButton value="system" label="System" />
      </CoarRadioGroup>
    </CoarFormField>

    <CoarFormField label="Experimental Features">
      <CoarSwitch v-model="experiments" label="Enable beta features" />
    </CoarFormField>

    <div class="demo-form__actions">
      <CoarButton>Save Settings</CoarButton>
    </div>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarFormField, CoarTextInput, CoarSelect, CoarCheckbox, CoarRadioGroup, CoarRadioButton, CoarSwitch, CoarButton } from '@cocoar/vue-ui';
import type { CoarSelectOption } from '@cocoar/vue-ui';

const displayName = ref('Jane Doe');
const bio = ref('');
const language = ref('en');
const emailNotifs = ref(true);
const pushNotifs = ref(false);
const weeklyDigest = ref(true);
const theme = ref('system');
const experiments = ref(false);

const languages: CoarSelectOption<string>[] = [
  { value: 'en', label: 'English' },
  { value: 'de', label: 'Deutsch' },
  { value: 'fr', label: 'Français' },
  { value: 'es', label: 'Español' },
];
</script>

<style scoped>
.demo-form {
  display: flex;
  flex-direction: column;
  gap: var(--coar-spacing-m);
  max-width: 400px;
}
.demo-checks {
  display: flex;
  flex-direction: column;
  gap: var(--coar-spacing-s);
}
.demo-form__actions {
  padding-top: var(--coar-spacing-s);
}
</style>

Validation with vee-validate

CoarFormField integrates seamlessly with vee-validate and Zod schemas. Use useField() to get reactive value and errorMessage refs, then bind them to the input and CoarFormField respectively.

At least 8 characters
vue
<template>
  <form class="demo-form" @submit="onSubmit">
    <CoarFormField label="Email" :error="emailError" required>
      <CoarTextInput v-model="email" placeholder="jane@example.com" required />
    </CoarFormField>

    <CoarFormField label="Password" :error="passwordError" hint="At least 8 characters" required>
      <CoarPasswordInput v-model="password" required />
    </CoarFormField>

    <CoarFormField label="Confirm Password" :error="confirmError" required>
      <CoarPasswordInput v-model="confirm" required />
    </CoarFormField>

    <CoarFormField :error="termsError">
      <CoarCheckbox v-model="terms" label="I accept the terms of service" />
    </CoarFormField>

    <div class="demo-form__actions">
      <CoarButton type="submit">Sign Up</CoarButton>
    </div>

    <CoarNote v-if="submitted" variant="success" padding="s">
      Form submitted successfully!
    </CoarNote>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
import { CoarFormField, CoarTextInput, CoarPasswordInput, CoarCheckbox, CoarButton, CoarNote } from '@cocoar/vue-ui';

const schema = toTypedSchema(
  z.object({
    email: z.string().min(1, 'Email is required').email('Enter a valid email'),
    password: z.string().min(8, 'At least 8 characters'),
    confirm: z.string().min(1, 'Please confirm your password'),
    terms: z.literal(true, { errorMap: () => ({ message: 'You must accept the terms' }) }),
  }).refine((data) => data.password === data.confirm, {
    message: 'Passwords do not match',
    path: ['confirm'],
  }),
);

const submitted = ref(false);

const { handleSubmit } = useForm({ validationSchema: schema });

const { value: email, errorMessage: emailError } = useField<string>('email', undefined, { initialValue: '' });
const { value: password, errorMessage: passwordError } = useField<string>('password', undefined, { initialValue: '' });
const { value: confirm, errorMessage: confirmError } = useField<string>('confirm', undefined, { initialValue: '' });
const { value: terms, errorMessage: termsError } = useField<boolean>('terms', undefined, { initialValue: false });

const onSubmit = handleSubmit(() => {
  submitted.value = true;
});
</script>

<style scoped>
.demo-form {
  display: flex;
  flex-direction: column;
  gap: var(--coar-spacing-m);
  max-width: 400px;
}
.demo-form__actions {
  padding-top: var(--coar-spacing-s);
}
</style>

Other validation libraries

CoarFormField is library-agnostic — it just takes an error string. Any validation approach works: vee-validate, vuelidate, or plain computed properties.

Standalone Form Controls

Form controls work without CoarFormField when no label or validation is needed — inline search inputs, table checkboxes, toolbar buttons.

vue
<!-- No label needed -->
<CoarTextInput v-model="search" placeholder="Search..." />

<!-- Inline checkbox with its own label -->
<CoarCheckbox v-model="agree" label="I agree" />

Accessibility

CoarFormField generates unique IDs automatically:

  • Label: <label for="..."> points to the input — clicking the label focuses the control
  • Error/Hint: linked via aria-describedby — screen readers announce the message on focus
  • Required: asterisk is aria-hidden="true" — use required on the input for semantics

API

Props

PropTypeDefaultDescription
labelstringundefinedLabel text displayed above the input
errorstring''Error message — overrides hint when set
hintstring''Hint text displayed below the input
requiredbooleanfalseShow required asterisk next to label
disabledbooleanfalseDisabled state — propagated to child inputs
idstringautoExplicit input ID (auto-generated if omitted)

Slots

SlotDescription
defaultThe form control(s) to wrap

Released under the Apache-2.0 License.