raft-ui
Version:
React UI components for Raft.
229 lines (166 loc) • 10.3 kB
Markdown
---
name: forms
description: "Choosing and composing form controls: Select vs DropdownMenu, Switch vs Checkbox, Combobox, SegmentedControl vs RadioGroup vs Tabs, Field wiring."
---
# Form controls
## Contents
- Select vs DropdownMenu — the mistake to avoid
- Choosing an option control
- Checkbox vs Switch
- Combobox vs Select
- SegmentedControl vs RadioGroup vs Tabs vs ToggleGroup
- Input vs InputGroup vs Textarea
- Use Field when the field contract applies
- Per-component norms
---
## Select vs DropdownMenu — the mistake to avoid
These look alike and are not interchangeable. The rule is about **what happens on click**, not about appearance.
| | `Select` | `DropdownMenu` |
| ----------------------- | ---------------------------------- | ---------------------------------- |
| Purpose | Pick a **value** | Run an **action** |
| After choosing | The trigger shows the chosen value | The menu closes, something happens |
| Has a persistent value | Yes | No |
| Can represent form data | Yes, when given a `name` | No |
| Submitted with the form | When named | Never |
**Incorrect** — an actions menu built out of `Select`:
```tsx
<Select>
<SelectTrigger>
<SelectValue placeholder="Actions" />
<SelectIcon />
</SelectTrigger>
<SelectContent>
<SelectList>
<SelectItem value="rename">
<SelectItemText>Rename</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="delete">
<SelectItemText>Delete</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectList>
</SelectContent>
</Select>
```
**Correct:**
```tsx
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label="Actions" />}>
…
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={rename}>Rename</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={remove}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
`DropdownMenuContent` handles the portal and popup itself — do not wrap it in `DropdownMenuPortal` or `DropdownMenuPopup`. Those parts exist for granular control only.
Quick test: if the trigger should still read "Actions" after the user picks something, it is a menu. If the trigger should now read "Rename", it is a select.
`DropdownMenu` does carry state for _settings-style_ menus — that is what `DropdownMenuCheckboxItem` and `DropdownMenuRadioGroup` / `DropdownMenuRadioItem` are for (view density, sort order). Those still are not form fields and are never submitted.
---
## Choosing an option control
Walk this in order.
1. Is it an action, not a value? → `DropdownMenu` / `ContextMenu`.
2. Is it a single on/off that applies immediately? → `Switch`.
3. Is it a single on/off submitted with a form? → `Checkbox`.
4. Multiple independent on/offs? → several `Checkbox`, or `ToggleGroup` when they are a compact toolbar.
5. One of 2–5, all worth showing, in a form? → `RadioGroup`.
6. One of 2–5, switching a view or filter, space is tight? → `SegmentedControl`.
7. One of many? → `Select`.
8. Many, and the user will want to type to narrow? → `Combobox`.
9. Multiple selections from many? → `Combobox` with chips.
---
## Checkbox vs Switch
| | `Checkbox` | `Switch` |
| ---------------------- | -------------------------------------- | ---------------------------- |
| Takes effect | On submit | Immediately |
| Needs a Save button | Yes | No |
| Typical home | Form, list selection, terms acceptance | Settings row, feature toggle |
| Supports indeterminate | Yes | No |
**Incorrect** — a settings toggle that saves instantly, built as a checkbox:
```tsx
<Checkbox checked={muted} onCheckedChange={saveImmediately} />
```
**Correct:**
```tsx
<Switch checked={muted} onCheckedChange={saveImmediately} />
```
If the user would be surprised that nothing needs saving, use `Switch`. If they would be surprised that it saved already, use `Checkbox`.
`Checkbox` sizes are `sm` `md` `lg`. `Switch` sizes are `sm` `md` only — there is no `lg` switch.
---
## Combobox vs Select
Reach for `Combobox` when any of these is true:
- More than roughly 10 options.
- The user knows the value and would rather type it.
- More than one value can be selected.
- Options are grouped and the list would be long to scan.
`Combobox` owns the multi-select story through `ComboboxChips`, `ComboboxChip`, and `ComboboxChipRemove`. Do not build multi-select on `Select`.
`Combobox` also provides `ComboboxEmpty` for the no-match state — render it, otherwise a search that matches nothing shows a blank popup.
`Select` provides `SelectGroup` + `SelectGroupLabel` for grouping, and `SelectScrollUpArrow` / `SelectScrollDownArrow` for long lists.
---
## SegmentedControl vs RadioGroup vs Tabs vs ToggleGroup
All four render a row of choices. They differ in what the choice does.
| Component | The choice… |
| ------------------ | --------------------------------------------------- |
| `RadioGroup` | is an answer to a question, submitted with the form |
| `SegmentedControl` | changes a view, filter, or mode, in place |
| `Tabs` | swaps which panel of content is visible |
| `ToggleGroup` | toggles one or more independent options |
`Tabs` implies panels. If there are no panels — you are only filtering a list that stays in place — use `SegmentedControl`.
`Tabs` variants are `default` and `underline`. Use `TabsIndicator` and `TabsBackground` for the moving marker rather than animating a border yourself. For reorderable tabs use `SortableTabsList` / `SortableTabsTab` with the `useOrderedTabs` hook.
---
## Input vs InputGroup vs Textarea
- `Input` — a bare single-line control.
- `InputGroup` — when the control needs an attached prefix, suffix, icon, or button. Compose `InputGroupAddon` + `InputGroupInput`. On `InputGroupAddon`, `align` is `inline-start` or `inline-end`; `variant` is `default` or `container`.
- `Textarea` — multi-line. Pair with `TextareaGroup` and `TextareaCounter` when there is a character limit. Give the counter `value` (or `count`) and `limit`; it computes and styles the over-limit state itself.
**Incorrect** — faking an addon with absolute positioning:
```tsx
<div className="relative">
<Input className="pl-8" />
<SearchIcon className="absolute left-2 top-2" />
</div>
```
**Correct:**
```tsx
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search" />
</InputGroup>
```
---
## Use Field when the field contract applies
Default to `Field` when a raft-ui control has a visible label, description, required marker, or validation message. It wires those parts together. A simple native label/control pair may remain native when it needs none of that shared treatment; icon-only or compact toolbar controls may instead use an explicit accessible name. Do not add `Field` only to satisfy a wrapper rule.
```tsx
const emailError = getEmailError(email);
<Field invalid={Boolean(emailError)}>
<FieldLabel htmlFor="work-email">Email</FieldLabel>
<Input
id="work-email"
type="email"
data-invalid={Boolean(emailError)}
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
<FieldDescription>Use your work email.</FieldDescription>
<FieldError match={Boolean(emailError)}>{emailError}</FieldError>
</Field>;
```
- For externally controlled validation, bind `Field invalid`, the control's `data-invalid` (mirrored to `aria-invalid`), and `FieldError match` to the same error condition. A bare `match` means “always show”; use it only for a deliberately static invalid-state example. Without `match`, `FieldError` follows native, custom `validate`, or enclosing `Form` errors.
- Do not hand-roll an error paragraph; `FieldError` owns the error semantics and styling.
- `FieldItem` is for horizontal label/control rows (settings lists).
- `FieldValidity` exposes raw validity when you need to drive something custom.
- `FieldControl` is the Base UI input part. Use it when you need that primitive directly; `Input`, `Checkbox`, `Select`, and other Base UI controls already integrate with `Field` and do not need a `FieldControl` wrapper.
- `FieldLabel` and standalone `Label` take `size` of `sm` or `md`; `Field` itself has no `size` prop. Keep label sizes consistent within a form.
Standalone `Label` exists for cases outside a field (a section caption above a group). It composes `LabelAsterisk` for required, `LabelOptional` for optional, and `LabelSub` for secondary text — do not type "(optional)" into the label string.
---
## Per-component norms
**`Select`** — compose `SelectValue` and `SelectIcon` inside `SelectTrigger`. Put `SelectList` and its items inside `SelectContent`; `SelectContent` owns portal and positioning. Items take `SelectItemText` and, when selected state should be visible, `SelectItemIndicator`. `SelectItemLeading` holds an icon.
**`Combobox`** — put `ComboboxInput` in `ComboboxControl` when the searchable field is the anchor control. Use `ComboboxInputGroup` for a search row inside `ComboboxContent`, such as a trigger-led filter popup. Provide `ComboboxClear` when a value can be cleared. `ComboboxRow` is for grid-style option layouts.
**`RadioGroup`** — each `RadioGroupItem` needs a `RadioGroupIndicator`.
**`Checkbox`** — has both `variant` and `color`, each `default` / `primary`.
**`SegmentedControl`** — compose `SegmentedControlItem` with `SegmentedControlLabel`, and `SegmentedControlCount` when an option carries a number. Do not put a count in the label string.
**`Switch`** — always render `SwitchThumb`.