Skip to content

CInput

CInput is the low-level primitive for building input controls. It manages focus state, validation, aria attributes, and CForm integration. It does not render a native <input> itself — instead it exposes everything through the field slot so the consumer can build any kind of control on top.

When to use CInput directly?

For most use cases, prefer CTextField. Use CInput when you need a non-standard control: a styled textarea, a PIN input, a numeric stepper, or any other widget that needs validation and focus state.

Example: custom field

Verification code
Enter the 6-digit code from your email
About me0/200
Show code
vue
<template>
  <CInput
    v-model="pin"
    id="custom-pin"
    label="PIN code"
    :rules="pinRules"
    validate-on="blur"
  >
    <template #field="field">
      <div class="pin-wrap" :class="{ 'has-error': field.hasError }">
        <label :for="field.uid">PIN code</label>
        <input
          v-bind="field.attrs"
          :id="field.uid"
          type="password"
          maxlength="4"
          inputmode="numeric"
          :value="pin"
          @input="(e: any) => pin = (e.target as HTMLInputElement).value"
          @focus="field.focus"
          @blur="field.blur"
        />
      </div>
    </template>
    <template #details="{ errorMessage, hasError }">
      <span :style="{ color: hasError ? 'var(--c-app-error-color)' : 'inherit' }">
        {{ errorMessage || '4-digit PIN' }}
      </span>
    </template>
  </CInput>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const pin = ref('')
const pinRules = [
  (v: string) => ({ valid: /^\d{4}$/.test(v), message: 'Enter a 4-digit PIN' }),
]
</script>

Preset system

The preset system is the primary way to style CInput-based components. Instead of writing conditional CSS classes in every template, you define a preset once and reference it by name. The component resolves the right classes automatically for the current state.

Every value is an array of utility class names, so presets work with any utility-first CSS engine you have configured.

Zones

A flat preset (ZonePreset) maps zones to class lists. Zones map 1:1 to the rendered DOM:

ZoneElement
rootthe .c-input wrapper
fieldthe .c-field box (border / background)
inputthe native <input>
labelthe floating label
detailsthe hint / error row
prependthe prepend-slot wrapper
appendthe append-slot wrapper

The CInputPreset type

A preset is a set of flat presets keyed by state. base is the resting look; each state is its own complete flat preset:

ts
type CInputZone = 'root' | 'field' | 'input' | 'label' | 'details' | 'prepend' | 'append'
type CInputState = 'focused' | 'filled' | 'error' | 'disabled' | 'readonly'

type ZonePreset = Partial<Record<CInputZone, string[]>>

// base + optional per-state flat presets — everything optional
type CInputPreset = Partial<Record<'base' | CInputState, ZonePreset>>

There are no compound states and no nesting — a state is just another flat preset.

One state at a time

The component is always in a single current state, and that state's preset is applied (or base when the field is at rest). The active state's zones replace the base zones per-zone; a zone the state doesn't define falls back to base. There's nothing to stack and no priorities to reason about — you just define a flat preset per state.

Why one state, not stacked?

Utility classes are !important with equal specificity, so stacking conflicting classes (say two bg-*) is resolved by stylesheet order, not by intent. Applying exactly one set of classes per zone keeps the result predictable.

Addressing a state directly

Each state is itself a flat preset, addressable by name.state. input.blue is the whole set; input.blue.focused is just the focused flat preset.

Registering presets

Presets are registered globally in createVuelandUI:

ts
import { createVuelandUI } from '@vueland/ui'
import type { CInputPreset } from '@vueland/ui/types'

function makePreset(color: string): CInputPreset {
  return {
    base:     { label: [color] },
    focused:  { label: [color], field: [color] },
    filled:   { label: [color] },
    error:    { label: ['text-red'], field: ['text-red'], details: ['text-red'] },
    readonly: { label: ['text-grey'] },
    // `disabled` is dimmed by the component; add a zone only to override
  }
}

const vueland = createVuelandUI({
  presets: {
    input: {
      blue: makePreset('text-blue'),
      teal: makePreset('text-teal'),
    },
  },
})

Then use the preset by name on any CInput-based component:

vue
<CTextField preset="input.blue" ... />
<CTextField preset="input.teal" ... />

CInput → CField distribution

You write a single preset. CInput resolves it, applies its own zones (root, details), and shares the set with the field subtree through provide/inject. CField injects it and applies field, input, label, prepend, append. You never write a separate field preset, and nothing is mutated globally.

All states at a glance

Default
Focused
Filled
Error
Disabled
Readonly

The example above uses preset="input.blue" across six states:

  • Defaultbase zones
  • Focusedfocused replaces base per-zone
  • Filledfilled replaces base (label floats up, keeps color)
  • Errorerror replaces base (red)
  • Disabled — interaction blocked; the component dims the field
  • Readonly — value visible but not editable

API

Props

PropTypeDefaultDescription
modelValueanyundefinedField value (v-model)
idstringautoBase ID used to generate uid, uid-label, uid-details
labelstringLabel text (forwarded into the field slot)
detailsstringHint text shown below the field
noDetailsbooleanfalseHide the details area entirely
clearablebooleanfalseForward clearable into the field slot
disabledbooleanfalseBlocks focus, adds aria-disabled
readonlybooleanfalseAdds aria-readonly, blocks editing
focusedbooleanfalseInitial focused state
roleCInputRoleSemantic role. Drives the aria wiring and the uid prefix
rulesValidateFn[][]Validation functions
validateOn'input' | 'blur''input'When to trigger automatic validation
presetstringPreset name (dot-path into the presets object passed to createVuelandUI)

CInputRole type

ts
type CInputRole = 'combobox' | 'checkbox' | 'radio' | 'listbox'
ValueBehavior
'combobox'Adds role="combobox", aria-haspopup="listbox", aria-controls, aria-expanded — for select / autocomplete activators
'checkbox'Wires aria-labelledby to the label
'radio'Wires aria-labelledby to the label
'listbox'For listbox-based controls (aria wiring + uid prefix)

Slots

SlotPropsDescription
fieldCInputFieldSlotPropsRequired. Renders the actual input control
detailsCInputDetailsSlotPropsReplaces the hint/error area

field slot props

PropTypeDescription
uidstringGenerated ID for the native <input>
attrsRecord<string, unknown>Ready-to-use aria + native attrs — spread with v-bind
focusedbooleanCurrent focus state
labelstring | undefinedValue of the label prop
clearableboolean | undefinedValue of the clearable prop
disabledboolean | undefinedValue of the disabled prop
readonlyboolean | undefinedValue of the readonly prop
presetCInputPreset | undefinedResolved preset set (also provided to the field subtree)
hasErrorbooleanWhether there is an active validation error
errorMessagestring | undefinedCurrent error message
validatingbooleanWhether async validation is running
focus() => voidCall when the native element receives focus
blur() => voidCall when the native element loses focus
reset() => voidClear the validation error
validate() => Promise<boolean>Trigger validation

details slot props

PropTypeDescription
uidstringField ID
errorMessagestring | undefinedCurrent error message
hasErrorbooleanWhether there is an error
validatingbooleanWhether async validation is running
detailsstring | undefinedValue of the details prop

Events

EventArgumentsDescription
focusbooleanField received focus
blurField lost focus

Expose

MethodSignatureDescription
validate() => Promise<boolean>Trigger validation manually
reset() => voidClear the error state
focus() => voidProgrammatically focus the field
blur() => voidProgrammatically remove focus

CForm integration

CInput automatically registers its validate method with the nearest parent CForm. When form.validate() is called, all registered fields are validated in parallel via Promise.all.

vue
<template>
  <CForm>
    <template #default="{ validate }">
      <CInput v-model="pin" :rules="rules">
        <template #field="field">
          <input
            :id="field.uid"
            v-bind="field.attrs"
            :value="pin"
            @input="(e: any) => {}"
            @focus="field.focus"
            @blur="field.blur"
          />
        </template>
      </CInput>
      <button @click="validate">Validate</button>
    </template>
  </CForm>
</template>

Automatic aria attributes

CInput computes aria attributes and passes them via field.attrs. Always spread v-bind="field.attrs" on the native element.

AttributeCondition
aria-labelledby="{uid}-label"label is set, or kind = checkbox/radio
aria-labelIf label is the only label
aria-describedby="{uid}-details"details prop or error message is present
aria-invalid="true"Validation error is active
aria-errormessage="{uid}-details"Error message is present
aria-disabled="true"disabled = true
aria-readonly="true"readonly = true
aria-haspopup="listbox"kind = 'listbox'
aria-controls="{uid}-menu"kind = 'listbox'
aria-expandedkind = 'listbox' (updated on focus change)

CSS variables

VariableDefaultDescription
--c-input-background-colorvar(--c-app-surface-color)Component background
--c-input-primary-colorvar(--c-app-primary-color)Text color in default state
--c-input-error-colorvar(--c-app-error-color)Text color on error
--c-input-disabled-colorvar(--c-app-disabled-color)Text color when disabled
--c-input-readonly-colorvar(--c-app-primary-color)Text color when readonly
--c-input-details-height24pxHeight of the details area

State CSS classes

ClassCondition
c-input--defaultNo error, not disabled, not readonly
c-input--focusedField is focused
c-input--has-errorValidation error is active
c-input--disableddisabled = true
c-input--readonlyreadonly = true
c-input--clearableclearable = true
c-input--validatingAsync validation is running