Forms and inputs

For HR administrators

Jadiroon uses a unified input system based on the Jadiroon design system. Every text field, textarea, and select trigger in the admin workspace and employee portal shares a single source of truth: components/ui/field-styles.ts. This page explains the visual specification, available sizes, RTL behavior, validation states, and the FormField wrapper that ties it all together.

The field-styles module

components/ui/field-styles.ts exports four plain string constants. No runtime cost, no dependencies — just Tailwind class strings that every input component imports.

ExportUsed byDefault height
fieldInputClass<Input>, raw <input>40 px (h-10)
fieldTextareaClass<Textarea>min 80 px (min-h-[80px])
fieldSelectTriggerClass<SelectTrigger>40 px (h-10)
fieldInputSmClass<Input size="sm">, inline search36 px (h-9)

All four constants share the same visual language: rounded-lg radius, border-input border, bg-background fill, and the shadow-xs resting elevation that is the design system's signature.

Resting state

Every default-size input renders at 40 px tall (h-10). This keeps text fields, selects, and button-adjacent inputs on the same baseline so horizontal compositions align without manual height overrides.

Key resting-state classes (from fieldInputClass):

h-10 w-full min-w-0
rounded-lg border border-input bg-background
shadow-xs
ps-3.5 pe-3.5 py-2
text-sm placeholder:text-muted-foreground
transition-colors

shadow-xs is a one-pixel drop shadow that lifts the field slightly off the canvas — an intentional design-system detail that distinguishes inputs from plain surface areas.

Focus state

Focus uses an outline-free approach that replaces the browser default ring with a branded one:

outline-none
focus-visible:border-ring
focus-visible:ring-[3px] focus-visible:ring-ring/40

The border-ring swap makes the border colour jump to the brand token on focus. The 3 px semi-transparent ring provides a soft halo that meets WCAG 2.4.11 focus-appearance requirements without the harshness of a solid outline.

Validation / invalid state

Invalid fields receive a destructive-coloured border and ring driven by the aria-invalid attribute. No extra class toggling is needed — the variant responds automatically:

aria-invalid:border-destructive
aria-invalid:ring-[3px] aria-invalid:ring-destructive/20

Dark-mode equivalents are also baked in:

dark:aria-invalid:border-destructive/50
dark:aria-invalid:ring-destructive/40

Set aria-invalid={true} on any input or let FormField do it for you (see below).

Disabled state

disabled:pointer-events-none
disabled:cursor-not-allowed
disabled:opacity-50
disabled:bg-input/50
dark:disabled:bg-input/80

Disabled inputs lose pointer events and dim to 50 % opacity. The background shifts to bg-input/50 to reinforce the inactive state visually.

Sizes

Two sizes are available:

Default — 40 px

Use fieldInputClass (or simply <Input> without a size prop). This is the right choice for forms, settings pages, and anywhere a field lives alongside labels and hint text.

Small — 36 px

Use fieldInputSmClass (or <Input size="sm">). Intended for dense contexts: filter bars, inline table-row search, toolbar controls. The only differences from the default are h-9 and py-1.5.

Avoid mixing sizes inside a single form section. If you need a compact layout, use the small size consistently throughout that section.

RTL safety

All horizontal spacing uses CSS logical properties exclusively. Physical properties (pl, pr, left, right) are banned in this module.

Tailwind classLogical CSS propertyEffect
ps-3.5padding-inline-startStart-side padding (left in LTR, right in RTL)
pe-3.5padding-inline-endEnd-side padding (right in LTR, left in RTL)
ms-*margin-inline-startInline start margin
me-*margin-inline-endInline end margin

When the page dir attribute is rtl (Arabic), the browser flips start/end automatically. No component-level RTL logic is needed.

Dark mode

All dark overrides are included in the constants. Consumers do not add dark: classes manually — they come for free with the export.

dark:bg-input/30                    ← resting fill
dark:disabled:bg-input/80           ← disabled fill
dark:aria-invalid:border-destructive/50
dark:aria-invalid:ring-destructive/40

The FormField component

components/portal/form/FormField.tsx is the recommended wrapper for every form input. It handles:

  • React Hook Form Controller binding (reads from the nearest <FormProvider>)
  • Auto-generated accessible id and aria-describedby wiring
  • Label rendering with optional required-star and "(optional)" badge
  • Hint text shown when the field is valid
  • Error message shown (with role="alert") when the field is invalid
  • aria-invalid forwarding to the underlying <Input> or <Textarea>

Basic usage

import { FormField } from "@/components/portal/form/FormField";

// Inside a component wrapped with <FormProvider methods={...}>
<FormField
  name="email"
  label="Work email"
  type="email"
  placeholder="you@example.com"
  required
/>

With a custom input (render prop)

For selects, comboboxes, date pickers, or any non-standard input, pass a render-prop child:

<FormField name="sector" label="Sector" required>
  {({ id, value, onChange, onBlur, invalid, describedBy }) => (
    <Select value={value} onValueChange={onChange}>
      <SelectTrigger
        id={id}
        aria-invalid={invalid}
        aria-describedby={describedBy}
      >
        <SelectValue placeholder="Select sector" />
      </SelectTrigger>
      <SelectContent>
        {sectors.map((s) => (
          <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
        ))}
      </SelectContent>
    </Select>
  )}
</FormField>

Props reference

PropTypeDefaultDescription
namestring—React Hook Form field name (dot-notation for nested paths)
labelReactNode—Label text. Omit for visually-hidden labels (add aria-label to the input instead)
typetext | email | password | number | tel | url | textarea | search"text"Input type. Use "textarea" to render a <Textarea>
placeholderstring—Placeholder text
hintReactNode—Help text shown below the field when there is no error
requiredbooleanfalseAdds a red asterisk to the label
optionalbooleanfalseAdds "(optional)" badge to the label
rowsnumber4Textarea row count
disabledbooleanfalseDisables the field and applies disabled styles
classNamestring—Merged onto the outer wrapper <div>
children(args: RenderArgs) => ReactNode—Render-prop for custom inputs

The SaveBar companion

FormField.tsx also exports SaveBar — a sticky bottom bar for settings-style forms:

import { SaveBar } from "@/components/portal/form/FormField";

<SaveBar
  dirty={form.formState.isDirty}
  saving={isSaving}
  saveLabel="Save changes"
  cancelLabel="Cancel"
  onCancel={() => form.reset()}
/>

SaveBar renders nothing when dirty is falsy, so there is no need to conditionally mount it.

Extending or overriding

All inputs accept a className prop. Use cn() from @/lib/utils to merge additional classes without clobbering the base styles:

import { cn } from "@/lib/utils";
import { fieldInputClass } from "@/components/ui/field-styles";

// A raw input that opts in to the unified look
<input className={cn(fieldInputClass, "font-mono")} />

Do not override height, padding, border-radius, shadow, or focus classes without updating field-styles.ts — a single-point change keeps all inputs consistent.

What is not in v0.5

  • Large / XL size variant. All inputs are 40 px or 36 px. A 44 px large variant for prominent CTAs is on the v0.6 backlog.
  • Prefix/suffix icon slots. The InputGroup pattern (icon or text prefix/suffix pinned inside the border) is in a separate components/ui/input-group.tsx primitive. It wraps fieldInputClass and adjusts padding via CSS variables.
  • Combobox. Combobox is a separate primitive built on Radix UI. Its trigger uses fieldInputClass as a base.