Status: accepted direction for frappe-ui v1 planning.
This document defines the v1 API direction for the input-family components:
TextInputTextareaPasswordCheckboxSwitchRatingSliderErrorMessage
FileUploader is intentionally out of scope for this spec; it will be
covered separately.
It also covers the v1 stance on the deprecated Input.vue and Autocomplete,
and the FormControl type='autocomplete' route.
It is a sibling of selection.md
and inherits any shared design rules from that document where they apply.
This spec answers:
- what the shared labeling, sizing, variant, and
v-modelcontracts look like across every input - which existing per-component issues must be resolved before v1
- which deprecations must be wired with dev warnings
Decisions involving real-world usage data are backed by
v1-release/research/09-input-components-usage-audit.md.
- every input gets the same shared labeling props:
label,description,error,required - every input auto-generates an
idand wires label-for,aria-describedby, andaria-errormessageautomatically - text-style inputs and binary/numeric controls follow two separate size scales by component class
- text inputs converge on a single
subtle | outline | ghostvariant set - every input uses
defineModel<T>()for the primary value, with typed*Emitsinterfaces only for non-model events FeatherIconis removed fromSwitchandRating; both use LucideRating.rating_fromis renamed tomax, with a deprecated aliasInput.vue,Autocomplete, and theFormControl type='autocomplete'route all gain dev-mode deprecation warnings via a shared utilityFormControlstays a type-routing component for v1 (185 router-style call sites, 0 wrapper-style)Switch.labelClassesandCheckbox.paddingare deprecated (warned, still functional) in favor ofdata-*styling hooks; removal is post-v1- v1 introduces no breaking changes. Every API change is additive or
ships behind a
warnDeprecatedalias that keeps the old call site working
Every input that has a labelable role accepts the same four props.
interface InputLabelingProps {
/** Label rendered above (or beside, for binary controls) the input. */
label?: string
/**
* Helper text rendered below the input.
* Hidden when `error` is set.
*/
description?: string
/**
* Error message rendered below the input.
* Sets `aria-invalid="true"` and `data-state="invalid"` on the control.
* Accepts an `Error` object; `Error.messages` is rendered as stacked
* plain text, `Error.message` is the fallback.
*/
error?: string | Error
/**
* Marks the field as required.
* Renders an asterisk after the label (with `sr-only` "(required)" text)
* and forwards `required` / `aria-required` to the underlying control.
*/
required?: boolean
}interface InputLabelingSlots {
/** Overrides the rendered label content. Receives `{ required }`. */
label?: (props: { required: boolean }) => any
/** Overrides the rendered description content. */
description?: () => any
// No `#error` slot. The error string is a stable contract; apps that need
// rich error UI should render it as a sibling. The error-state wiring
// (`aria-invalid`, `data-state`, `aria-errormessage`) stays driven by the
// `error` prop.
}Two layouts, automatic per component:
- Stack (label above the control):
TextInput,Textarea,Password,Rating,Slider
- Inline row (label beside the control):
Checkbox,Switch
For inline-row controls, description and error stack below the row,
indented to align under the label region (not under the control).
- Every input calls
const inputId = props.id ?? useId(). - The generated id is shared between the rendered
<label for>(oraria-labelledbyfor non-form controls likeSlider) and the underlying control. descriptionis rendered withid="${inputId}-description"and linked viaaria-describedbyon the control.erroris rendered withid="${inputId}-error"and linked viaaria-errormessageon the control.aria-invalid="true"is set whenerroris non-empty.- Apps may pass
idexplicitly to override.
Reuse FormLabel's existing pattern as the canonical implementation:
<label for="...">
Label text
<span aria-hidden="true" class="text-ink-red-3 select-none">*</span>
<span class="sr-only">(required)</span>
</label>Apps using the #label slot receive { required } so they can render the
indicator inside their custom label content.
error: stringrenders as a single line of text below the control.error: ErrorrendersError.messages(joined with line breaks viawhitespace-pre-line) when present, otherwiseError.message.- The error region is rendered as plain text.
v-htmlis not used. errortext usestext-ink-red-3(matches the required asterisk for visual consistency of "needs attention" affordances).- Setting
errorautomatically suppresses thedescriptionregion.
/** Size scale for text-style inputs. */
export type InputSize = 'sm' | 'md' | 'lg' | 'xl'
/** Size scale for binary and numeric range controls. */
export type ToggleSize = 'sm' | 'md'
/** Variant scale for text-style inputs that have a container surface. */
export type InputVariant = 'subtle' | 'outline' | 'ghost'Apply per component:
| Component | Size type | Variant type |
|---|---|---|
TextInput |
InputSize |
InputVariant |
Textarea |
InputSize |
InputVariant |
Password |
InputSize |
InputVariant |
Rating |
InputSize |
n/a |
Checkbox |
ToggleSize |
n/a |
Switch |
ToggleSize |
n/a |
Slider |
ToggleSize |
n/a |
Rationale: lg and xl produce useful visual range for text inputs and
trigger surfaces but produce oversized chunky controls for binary
affordances and sliders. Size scales follow the control's visual nature,
not API symmetry.
Every input uses defineModel<T>() for the primary value:
const model = defineModel<string>() // TextInput, Textarea, Password
const model = defineModel<boolean>() // Switch
const model = defineModel<boolean | 1 | 0>() // Checkbox (union retained for v1; coerce internally)
const model = defineModel<number>() // Rating
const model = defineModel<SliderValue>() // SliderTyped *Emits interfaces are added only when a component emits non-model
events:
// Slider
interface SliderEmits {
'value-commit': [value: SliderValue]
}*Props interfaces in types.ts continue to declare every non-model prop;
the model itself is documented at the component file via the
defineModel<T>() generic.
- Already at v1 baseline. No structural changes.
- Apply shared labeling props (
label,description,error,required). - Auto-generate
idviauseId()fallback. - Confirm
InputSizeandInputVariant(no change from current shape).
- Apply shared labeling props.
- Drop the unused
type: 'text'default inwithDefaults(typeis not inTextareaProps). - Add
'ghost'to the variant set so it matchesTextInputandPassword. - Add
requiredprop for parity. - Consolidate the local
labelprop into the shared labeling contract (same prop name, no breaking change — call sites continue to work).
- Add
defineModel<string>(). This fixes the current bug where<Password v-model>does not update from typing. - Deprecate the
valueprop alias (warn viawarnDeprecated); remove in a future major. - Add explicit
size,variant,disabled,placeholder,id,requiredprops instead of routing through$attrs. - Apply shared labeling props.
- Keep
modelValuetyped asboolean | 1 | 0for v1 — narrowing tobooleanis a breaking change for any consumer passing1/0. UsedefineModel<boolean | 1 | 0>()and coerce internally tobooleanfor the rendered control state. Documentbooleanas the canonical type; narrow in a future major. - Type emits via a
CheckboxEmitsinterface. - Apply shared labeling props (inline-row layout).
- Switch to
defineModel. - Deprecate the
paddingprop (warn viawarnDeprecated); keep functional throughv1.x. Styling moves todata-*hooks (see "Styling hooks" below). Audit found 0 real call sites, so the warning is essentially a no-op in practice.
- Remove internal
FeatherIconimport. Replace with Lucide. (Internal refactor — no public API change.) - Retype
iconfromanytostring | Component. Strings starting withlucide-route through the shared Lucide Tailwind utility (matches the pattern recently adopted byButton.icon). Existing values continue to resolve. - Deprecate the
changeemit (warn viawarnDeprecatedwhen the parent binds@change); keep firing throughv1.x. It duplicatesupdate:modelValue; switches do not have a meaningfulinputvschangedistinction. - Apply shared labeling props (inline-row layout).
Switchalready usesdefineModel<boolean>()— no change to the model wiring beyond the refactor.- Deprecate the
labelClassesprop (warn viawarnDeprecatedwhen set); keep applied to the<label>throughv1.x. Styling moves todata-*hooks (see "Styling hooks" below). Audit found 0 real call sites on frappe-ui'sSwitch, so the warning is essentially a no-op in practice.
- Remove internal
FeatherIconimport. Default icon becomeslucide-starvia the shared Lucide Tailwind utility. Star icon stays hardcoded for v1; configurable shape is a post-v1 additive change if needed. - Rename
rating_fromtomax. Default5. Keeprating_fromworking as a deprecated alias throughv1.xwith awarnDeprecatedcall. - Type emits via a
RatingEmitsinterface (replace the currentdefineEmits(['update:modelValue'])string-array form). - Apply shared labeling props.
- Switch to
defineModel<number>().
- Add
disabledprop, forwarded toSliderRoot.disabledandaria-disabled. - Add
size: ToggleSize('sm' | 'md'), default'sm'.mdincreases track and thumb proportionally. - Bug fix: remove the hardcoded
aria-label="Volume". The string was a leftover from a specific consumer and was incorrect for every other call site (assistive tech announces every Slider as "Volume"). Labeling now flows through the shared labeling contract; if nolabelis provided, noaria-labelis set and consumers should pass one explicitly. Treated as a bug fix, not an API change. - Add a typed
SliderEmitsinterface exposing'value-commit': [value: SliderValue], bound toSliderRoot's@valueCommit. Apps use this to fire side-effects only when the user finishes dragging. - Apply shared labeling props.
ErrorMessage continues to exist for direct use, but its v1 role narrows.
v-htmlis preserved as-is for v1. Removal is deferred — revisit post-v1 once consumers are tracked.- Type the message prop as
string | Errorcleanly; remove the(message as any).messagescast by typingError.messages?: string[]via a small library-level interface. (Internal typing improvement, not a runtime change.) - Most consumers should migrate to the input-level
errorprop. DocumentErrorMessageas the standalone option for contexts where an input is not present (e.g. form-level error banners).
Note: the input-level error region (rendered by the shared labeling
contract) is plain text and does not use v-html. That rule is about the
new in-input rendering, independent of the standalone ErrorMessage
component.
Add src/utils/warnDeprecated.ts:
const warned = new Set<string>()
export function warnDeprecated(
name: string,
replacement: string,
docHref?: string,
) {
if (import.meta.env.PROD) return
if (warned.has(name)) return
warned.add(name)
const suffix = docHref ? ` See ${docHref}` : ''
console.warn(
`[frappe-ui] ${name} is deprecated. Use ${replacement} instead.${suffix}`,
)
}Rules:
- dev-mode only (
import.meta.env.PRODshort-circuit) - module-level
Setdedupes bynameso the warning fires once per session - called from each deprecated component's
setup(or the relevant code path), not at import time, so stack traces point at the call site - consolidates the existing one-off pattern used by
Divider.action.handler
| Component / API | Warning name | Replacement |
|---|---|---|
Input.vue |
Input |
TextInput |
Autocomplete |
Autocomplete |
Combobox or MultiSelect |
FormControl type='autocomplete' |
FormControl type="autocomplete" |
Use Combobox standalone |
Password.value prop |
Password.value |
v-model / modelValue |
Rating.rating_from prop |
Rating.rating_from |
max |
Switch.change emit |
Switch.change |
update:modelValue / v-model |
Switch.labelClasses prop |
Switch.labelClasses |
data-* styling hooks |
Checkbox.padding prop |
Checkbox.padding |
data-* styling hooks |
Divider.action.handler (existing) |
Divider.action.handler |
Divider.action.onClick |
FeatherIcon removal is tracked in the broader v1 plan and uses the same
utility once finalized.
Per the v1 plan:
- deprecated APIs continue to work through
v1.x - removal is a future-major concern
- legacy and deprecated components move out of standard docs and onto the single legacy-docs page
FormControl type='autocomplete'route warns but keeps renderingAutocomplete(removing the route now would break consumers; removal is a post-v1 step)
The two decisions below are backed by data in
v1-release/research/09-input-components-usage-audit.md.
The audit found 185 FormControl call sites across nine app frontends.
Every observed call site uses it as a type-routing component. Zero
sites use it as a slot-based label wrapper around a custom control.
Deprecation pressure is narrow: only 7 type='autocomplete' call sites,
concentrated in 2 files (meet/.../DeviceSettingsTab.vue and
insights/.../DashboardFilterEditor.vue). All pass standard :options
arrays of { label, value } shape.
v1 decision:
FormControlremains a type-routing component- the
type='autocomplete'route stays functional, with a dev-mode deprecation warning pointing consumers atComboboxstandalone - a router-vs-wrapper redesign is not pursued; the data shows no real-world consumer leans on a wrapper-style use that would block the router approach
The audit found:
Switch.labelClasses— 0 real call sites on frappe-ui'sSwitch. Two files surface alabelClassessymbol but they are app-localAutocompletewrappers with their own internal API, not consumers of the prop on the frappe-ui component.Checkbox.padding— 0 real call sites across all audited apps.- No other class-injection props were found on input components in real usage.
v1 decision:
- both props remain functional in v1, with a
warnDeprecatedwarning when set; removal is post-v1 - inputs additionally expose a
data-*vocabulary for external styling:data-slot,data-size,data-variant,data-state,data-disabled,data-required - this matches the selection-spec (rule 4) precedent and keeps v1 strictly non-breaking
Migration load: zero apps affected.
The per-component changes above are the source of truth for behavior. This section captures the cross-cutting infrastructure, acceptance gates, and test/story expectations that apply to every input in scope.
src/utils/warnDeprecated.ts— dev-mode warning utility (see "Deprecations").src/composables/useInputLabeling.ts— returns{ inputId, labelledBy, describedBy, errorMessageId, dataAttrs, hasError }. Also exports the sharedInputLabelingPropsandInputLabelingSlotsinterfaces from the same module.src/composables/inputTypes.ts— exportsInputSize,ToggleSize,InputVariant(shared types live next to composables; no separatesrc/types/directory).src/components/FormLabel.vue— required-indicator markup is extracted here so every input reuses the same DOM.
- Input components live under
src/components/<Component>/with<Component>.vue,index.ts,types.ts,<Component>.cy.ts, and astories/directory. - Use the existing
src/utils/useId.tsinstead of re-implementing. - Use
<script setup lang="ts">everywhere; never the Options API. - Lucide icons go through the shared Tailwind utility (see
Button.iconprecedent). - Stories regenerate
meta/<Component>.mdviapropsgen— don't hand-edit meta files.
Every change against this spec must pass:
yarn typecheckcleanyarn test(Cypress component tests) clean for touched components- Storybook stories render without console warnings or errors
- No new
console.warnfromwarnDeprecatedin clean (non-deprecated) call paths - No regressions in
propsgen-generated meta — diffs reviewed dist/builds without new warnings (yarn build)- No breaking changes. Every existing call site continues to work with
no source edits required. New behaviors are additive; old behaviors that
are being phased out fire
warnDeprecatedand continue to function throughv1.x.
Each touched component must ship updated/new Cypress tests
(<Component>.cy.ts) covering, at minimum:
- v-model round trip —
defineModelwrites propagate to the parent and parent updates re-render the control - Shared labeling contract —
label,description,error,requiredeach render correctly;aria-describedby,aria-errormessage,aria-invalid,aria-requiredwire to the right ids idassociation —<label for>matches the control'sid; explicitidprop overrides the generated one- Sizes / variants — every value in the component's
Size/Variantunion renders (smoke-level is fine; visual regression is not required) - Disabled state —
disabledforwards to the control and todata-disabled - Component-specific behaviors:
Password— toggling visibility, no plaintext leak in DOM when hiddenSwitch/Checkbox— clicking the label toggles the controlSlider—value-commitfires on drag end (not on every step)Rating—maxcontrols star count;rating_fromalias still works
- Deprecation warnings — when a deprecated API is used, the test
asserts
console.warnfires once with the expected message; when the modern API is used, the test asserts no warning fires
Stories must cover the same surface visually so consumers can see the labeling contract in action.
Each touched component must ship Storybook stories that let a human exercise the component end-to-end, not just snapshot it. At minimum, every component gets:
- Default — bare-bones usage with
v-modelbound to a story arg, so the toggle in the controls panel updates the live component - All sizes — one story rendering every value of the component's size union side by side, labeled with the size name
- All variants (text inputs only) — one story rendering every value
of
InputVariantside by side - Labeling contract — a story that exposes
label,description,error,requiredas story args so the reviewer can flip each one and see the rendered label/description/error region update, including thearia-*wiring (verify in browser devtools) - Disabled — disabled state rendered alongside enabled for visual contrast
- Deprecated API — one story per deprecated prop/emit on the component
that explicitly uses the old API, so the reviewer can confirm the
console.warnfires once and the component still works (e.g. aSwitchstory binding@change, aRatingstory passing:rating_from="10") - Component-specific scenarios — anything worth eyeballing:
Password— visibility toggle in actionSlider— drag interaction, watchvalue-commitin the actions panelRating— differentmaxvalues, hover stateSwitch/Checkbox— clicking the label vs the control
Stories should be runnable with yarn dev against the local frappe-ui
copy, so the reviewer can manually exercise every code path the spec
introduces.
Rewrite existing stories where it makes sense. Don't preserve old stories out of inertia. If a current story:
- predates the shared labeling contract and renders a hand-rolled
<label>next to the control, - duplicates what the new "All sizes" / "All variants" / "Labeling contract" stories cover,
- demos a deprecated API as the primary example (e.g.
Ratingshowingrating_from,Switchshowing@change), or - exists only to demo a removed structural detail,
replace it with the v1 equivalent rather than keeping both. Keep an old story only when it covers a real scenario the new stories don't (e.g. an integration with another component, a non-obvious prop combination). Deprecated APIs still need their own dedicated story per the list above — that's separate from rewriting the primary examples to use the new contract.
warnDeprecated(...) is wired in the following files (matching the
Deprecations table):
src/components/Input.vue— warn on mount:Input→TextInputsrc/components/Autocomplete/Autocomplete.vue— warn on mount:Autocomplete→ComboboxorMultiSelectsrc/components/FormControl/FormControl.vue— warn whenprops.type === 'autocomplete': →Comboboxstandalonesrc/components/Password/Password.vue—valuepropsrc/components/Rating/Rating.vue—rating_frompropsrc/components/Switch/Switch.vue—changeemit,labelClassespropsrc/components/Checkbox/Checkbox.vue—paddingpropsrc/components/Divider/Divider.vue—Divider.action.handler(replaces the prior ad-hoc deprecation log)
Input, Autocomplete, and FormControl type='autocomplete' move out of
standard component docs onto the single legacy-components docs page; the
v1 migration guide points at the new APIs.
Every input shell renders the canonical data-* vocabulary:
data-slot— element role inside the component (e.g."label","control","description","error")data-size— currentsizevaluedata-variant— currentvariantvalue (where applicable)data-state—"valid" | "invalid" | "checked" | "unchecked" | …data-disabled—"true"when disabled, absent otherwisedata-required—"true"when required, absent otherwise
The useInputLabeling composable returns a dataAttrs object that
components spread onto their root element so the vocabulary stays
consistent.
Switch.labelClasses and Checkbox.padding continue to work alongside
the data-* hooks. They are deprecated, not removed in v1.
FileUploader(covered in a separate spec)- removing the
FormControl type='autocomplete'route (warn only — gated on post-v1FormControlscope decision) - removing
Switch.labelClasses,Checkbox.padding,Switch.changeemit, or any other deprecated API in v1 (warn only — removal is post-v1) - narrowing
Checkbox.modelValuetoboolean(breaking; deferred to a future major) - adding an
#errorslot on inputs (the spec rejects this) - removing
v-htmlfromErrorMessage(deferred — preserved as-is for v1) - new size or variant tokens beyond
InputSize/ToggleSize/InputVariant - any change that requires consumer source edits to keep working
- strictly no breaking changes — every existing call site keeps working with no source edits required
- new shared labeling contract is additive on every input in scope
- size and variant scales are codified into shared types
- deprecated APIs continue to function with dev-mode warnings; removal is post-v1
- a more consistent mental model across
TextInput,Textarea,Password,Checkbox,Switch,Rating, andSlider FileUploaderis out of scope and addressed in a separate spec