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.
| Export | Used by | Default 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 search | 36 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 class | Logical CSS property | Effect |
|---|---|---|
ps-3.5 | padding-inline-start | Start-side padding (left in LTR, right in RTL) |
pe-3.5 | padding-inline-end | End-side padding (right in LTR, left in RTL) |
ms-* | margin-inline-start | Inline start margin |
me-* | margin-inline-end | Inline 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
Controllerbinding (reads from the nearest<FormProvider>) - Auto-generated accessible
idandaria-describedbywiring - 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-invalidforwarding 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
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | React Hook Form field name (dot-notation for nested paths) |
label | ReactNode | — | Label text. Omit for visually-hidden labels (add aria-label to the input instead) |
type | text | email | password | number | tel | url | textarea | search | "text" | Input type. Use "textarea" to render a <Textarea> |
placeholder | string | — | Placeholder text |
hint | ReactNode | — | Help text shown below the field when there is no error |
required | boolean | false | Adds a red asterisk to the label |
optional | boolean | false | Adds "(optional)" badge to the label |
rows | number | 4 | Textarea row count |
disabled | boolean | false | Disables the field and applies disabled styles |
className | string | — | 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
InputGrouppattern (icon or text prefix/suffix pinned inside the border) is in a separatecomponents/ui/input-group.tsxprimitive. It wrapsfieldInputClassand adjusts padding via CSS variables. - Combobox. Combobox is a separate primitive built on Radix UI. Its trigger uses
fieldInputClassas a base.