└── Control // wraps ONE input; adds icons + loading
├── InputBase / SelectBase / TextAreaBase // the raw styled element
└── …
// help / validation message
```
You rarely write all of this by hand. The convenience components (`Input`, `Select`,
`TextArea`, …) auto-wrap themselves in `Field` + `Control` when they aren't already inside one,
using context (`useInsideField` / `useInsideControl`) to detect their surroundings.
```tsx
// Convenience: one line, auto-wrapped in Field + Control.
// Explicit composition: full control over layout.
```
### Layout via Field
- `horizontal` — label and control side by side (wraps children in `Field.Body`; use
`Field.Label` / `Field.Body` directly for multi-control rows).
- `grouped` — `true | 'centered' | 'right' | 'multiline'`, controls in a row.
- `hasAddons` — `true | 'centered' | 'right'`, attached controls (input + button).
```tsx
Go
```
A `grouped` or `hasAddons` Field names none of its controls, and a placeholder disappears once
the user types, so give each input in the row an `aria-label`, or a visible label on an outer
`Field` (`references/patterns.md`, "Grouped controls and addons").
## Component inventory
All import from `@allxsmith/bestax-bulma`. Convenience components auto-wrap Field+Control;
`*Base` components are the raw styled elements for explicit composition.
| Component | What it is |
| ------------------------------------------------------- | ------------------------------------------------------------------------- |
| `Field`, `Field.Label`, `Field.Body` | Field container + horizontal label/body parts. |
| `Control` | Wraps one input; left/right icons, `isLoading`, `isExpanded`, `size`. |
| `Input` / `InputBase` | Text input (convenience / raw). |
| `Select` / `SelectBase` | Dropdown select. |
| `TextArea` / `TextAreaBase` | Multiline text. |
| `Checkbox` / `Checkboxes` | Single checkbox / managed group (array value). |
| `Radio` / `Radios` | Single radio / managed single-select group. |
| `Switch` | Toggle switch (`isRounded`, `isThin`, `isOutlined`, RTL). |
| `File` | File upload input with label/message. |
| `Autocomplete` | Free text with suggestions it filters for you, + keyboard nav. |
| `Combobox` | A value from a list: `data` for a select, tags or groups, or parts. |
| `Slider` | Range slider; single/dual thumbs, steps, tooltips, vertical. |
| `Numberinput` | Numeric input with increment/decrement, min/max, step, stepper. |
| `Rate` | Star rating; `max`, `precision` (half/quarter), custom icons, `disabled`. |
| `Taginput` | Tag/chip input; suggestions, confirm keys, closable tags. |
| `DateInput` / `TimeInput` / `DateTimeInput` (+ `*Base`) | Date / time / datetime pickers; month or year via `granularity`. |
| `DateRangeInput` (+ `*Base`) | Start and end date: `[Date \| null, Date \| null]`, one calendar. |
(`NumberInput` and `TagInput` also exist as deprecated aliases of `Numberinput`/`Taginput` —
same components; prefer the lowercase-second-word spellings.)
**Autocomplete or Combobox?** A field whose text is the value, with suggestions to help type it,
is `Autocomplete`, which filters its `data` itself. A value picked from a list is `Combobox`. Given
`data`, it filters by label, keeps the value (`value`/`onChange`) and shows the picks as tags with
`multiple`, so a searchable select, a multi-select or a grouped list is one element. Build it from
parts for a server search whose answer must show unfiltered, or a "Create" item: then it keeps no
value and never sets the field's text, so you filter the items, keep the value and set the text in
`onSelect`.
## Common props
Across the convenience inputs (`Input`, `Select`, `TextArea`, and similar):
| Prop | Type | Purpose |
| -------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `color` | `'primary' \| 'link' \| 'info' \| 'success' \| 'warning' \| 'danger'` | Visual state — use `'danger'` for errors, `'success'` for valid. |
| `size` | `'small' \| 'medium' \| 'large'` | Input size. |
| `value` / `onChange` | controlled value + handler | Standard React controlled inputs. |
| `defaultValue` | uncontrolled initial value | When not controlling state. |
| `disabled`, `readOnly` | `boolean` | Native states (`readOnly` on `*Base`). |
| `label` | `ReactNode` | Field label (convenience components; auto-associated via `htmlFor`). |
| `message` | `ReactNode` | Help / validation text rendered as ``. |
| `messageColor` | a Bulma color | Colors the help text (`'danger'` for errors). |
| `iconLeftName` / `iconRightName` | `string` | Icon shortcuts; pair with `hasIconsLeft/Right`. |
| `isLoading` | `boolean` | Loading spinner on the Control; `Select` draws it in place of the chevron. |
Plus the full Bulma **helper props** (`m`, `p`, `textColor`, `display`, …) on every component
via `useBulmaClasses`.
Full-width is `isFullwidth` on every component that supports it (`Button`, `LinkButton`,
`Select`, `File`, `Table`, `Tabs`, `Sidebar`) — always write `isFullwidth`. The deprecated
spellings compile only where they historically existed: `isFullWidth` everywhere except
`Sidebar`, `fullwidth` on `Tabs` only, `fullWidth` on `Sidebar` only.
**Labels.** Default to the convenience `label` prop, with `horizontal` for a side-by-side row.
`Input`, `Select`, `TextArea`, `File`, `Numberinput`, `Slider`, `DateInput`, `TimeInput`,
`DateTimeInput`, `Autocomplete` and `Taginput` then render the `Field` and the label and wire
`htmlFor`/`id`, to your `id` when you pass one (` `); an
explicit `labelProps={{ htmlFor }}` wins. `Combobox` renders its `Field` and label too, and wires
them to its field. Given `data`, its `id`, `aria-label` and `aria-labelledby` go to the field;
built from parts, put them on `Combobox.Input`, since on the `Combobox` they land on its wrapper
`div`. The group inputs (`Checkboxes`, `Radios`, `Rate`, `DateRangeInput`) name their
`role="group"`/`"radiogroup"` wrapper through `aria-labelledby` instead. Inside a `Field` a
convenience input drops its `label`, and an `inline` date/time picker has no visible input to wire.
Composed, a labeled `Field` names the one control it holds: a composed `InputBase`/`SelectBase`/
`TextAreaBase`, a composed `DateInputBase`/`TimeInputBase`/`DateTimeInputBase` that is not
`inline`, or any input above takes its generated id, and a group (a composed `DateRangeInputBase`
too, `inline` or not) points `aria-labelledby` at the label's own id. **A control with an `id` of
its own never takes that id**, so ` ` renders a
label that names nothing: leave the `id` off, or pass the same value as
`labelProps={{ htmlFor: 'email' }}`. Wire it that way too where the association is skipped, in a
`grouped`/`hasAddons` row and in a horizontal `Field` whose body holds an inner `Field` (a nested
`Field` starts its own scope). For a group there, put `labelProps={{ id, htmlFor: undefined }}` on
the outer `Field` and a matching `aria-labelledby` on the group; the `htmlFor: undefined` keeps an
unmatched `for` out of server-rendered HTML.
A `range` `Slider` points the `for` at its low thumb and starts both thumbs' names with the label
("Price range Minimum value"); its `ariaLabel={[low, high]}` replaces those names outright, so
leave it off when a label already names the Slider. Those thumbs and an `Autocomplete`'s, a
`Combobox`'s or a `Taginput`'s list point `aria-labelledby` at the label's `id`, which a label
wired by hand has only when you pass one, `labelProps={{ htmlFor, id }}`; without it they keep their
fallback names ("Minimum value"/"Maximum value", "Suggestions"). A `` you put in
`Field.Label` yourself (`references/patterns.md`) names its control through the `for` alone, so
label a row holding either with the `Field`'s `label` prop. An `aria-label` or `aria-labelledby`
you give a group wins over any label, and a `Checkbox`, `Radio` or `Switch` takes nothing from a
`Field` on its own: its children name it, and a `Field`'s label names one only when you wire it as
you would any control with an `id` of its own, `labelProps={{ htmlFor }}` plus that control's
`id`. A labeled `Field` whose `for` nothing takes (a group, a `Checkbox`, `Radio` or `Switch`, an
input with an `id` of its own, your own markup) drops it once mounted, though server-rendered HTML
keeps it. `labelProps={{ htmlFor: undefined }}` opts out of the association entirely.
## Convenience vs composed
- **Convenience** (` `) — for typical, single-control fields. Fewer
lines, auto-wrapping, built-in `message`/`messageColor`. Default to this.
- **Composed** (`Field` + `Control` + `InputBase`) — when you need grouped controls, addons,
multiple controls per field, or custom layout. The convenience components detect they're
already inside a `Field`/`Control` and won't double-wrap, so you can mix the two. Inside a
`Control` they render no `Control` of their own, so set their Control-level props, such as
the icon props, `isLoading` and `controlSize`, on that `Control`: given to the input there,
they do nothing and warn in development. `Select` draws its own `isLoading`, so that one
stays on the `Select`. Inside a `Control` they render no `Field` either, so a convenience
input you place in a `Control` (for icons, say) takes its `label`, `horizontal` and class
name from a `Field` wrapped around that `Control`. Given `label`, `message`, `horizontal` or `fieldClassName` in a
`Control` with no `Field` around it, it renders its own `Field` inside the `.control`,
which Bulma's styles don't expect, and warns in development. `Autocomplete` and
`Numberinput` are the exception: in a bare `Control` they always render their own `Field`,
so give that `Control` a `Field` around it.
## Validation without a library
There is no built-in validation. The pattern is: **own your state, compute errors yourself, and
reflect them with `color` + `message` + `messageColor`.**
```tsx
import { useState } from 'react';
import { Input, Button } from '@allxsmith/bestax-bulma';
function SignupForm() {
const [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);
const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
const error =
touched && !valid ? 'Please enter a valid email address.' : undefined;
return (
);
}
```
Rules of thumb:
- Set `color="danger"` on the input **and** `messageColor="danger"` on the help text so both the
control and the message read as an error. Use `'success'` to signal a valid field.
- Want a different validation library? Wire it up yourself — pass its `value`/`onChange`/error
string into these props. bestax does not prescribe one.
- For grouped controls, render the `` via the `message` prop of the convenience
component, or add it manually inside the `Field` when composing.
See `references/api.md` for per-component props and `references/patterns.md` for a full
multi-field form plus the advanced inputs.
## Reuse the shipped components
bestax ships the whole form surface — Input, Select, TextArea, Checkbox(es), Radio(s), Switch, File,
Autocomplete, Combobox, Slider, Numberinput, Rate, Taginput, and the date/time inputs (see the
inventory above). **Compose these; don't hand-roll raw ` ` markup or reinvent a
control.** If you think a control is missing, check `bulma-ui/src/index.ts` and
`docs/docs/api/form/` first — it's probably already there under a different name.
## What happens after submit
A form is not finished at the last field. The two components that carry the result are easy to
miss because Bulma has something that looks close:
- **Confirmation** — `Toast`, not `Notification`/`Message`. Mount
` ` once at the app root, then call
`toast.success('Demo booked')` from the submit handler (`.danger` for a failed submit). It
self-dismisses.
- **"Are you sure?"** — `Dialog`, not `Modal`. Mount ` ` at the root, then
`if (await dialog.confirm({ title: 'Delete this key?', message: '…', type: 'danger' })) …`.
It resolves to a boolean, so a destructive action stays one `if` rather than a state machine.
`Modal` is an empty shell — with it you rebuild the title, message and button row by hand.
Both also work as plain controlled components (``,
``) when the state should live in your component.
## Visually inspect it in a browser
Forms have layout, spacing, and _stateful_ behavior that types and unit tests don't cover.
Before calling a form done, **render it and look at it**: run `pnpm storybook` (in `bulma-ui`)
or the docs dev server, open the form, and check field alignment/spacing, the help-text/error
states, and the validation flow (submit empty → fields turn `danger` with messages; fix → errors
clear). If claude-in-chrome or Playwright is available, drive the browser and screenshot the
valid and error states; otherwise eyeball it yourself. No browser at all (headless CI)? Fall
back to a production build plus a Node `renderToString` smoke render, grep the emitted HTML
for the expected classes/states, and say plainly that the visual pass is still owed.
## Checklist
- [ ] Built from the shipped form components (no hand-rolled inputs / reinvented controls).
- [ ] Every label is programmatically associated: the convenience `label` prop, the group
inputs, and `Field` + single-control composition do it on their own. A composed labeled
`Field` names a control with an `id` of its own, or one in an inner `Field`, only when its
`labelProps={{ htmlFor }}` matches that `id`, and a multi-control `Field`'s controls are
labeled individually (`aria-label`, `aria-labelledby`, or a `` matching
each control's `id`).
- [ ] Controlled inputs have both `value` and `onChange` (or use `defaultValue` uncontrolled).
- [ ] Error state shows via `color="danger"` + `message` + `messageColor="danger"`.
- [ ] Grouped/addon layouts use explicit `Field` + `Control` composition.
- [ ] No assumption of a built-in validation/form library — state is owned by the app.
- [ ] Submit feedback is a `Toast` and any "are you sure?" is a `Dialog` — not a hand-placed
`Notification` or a `Modal` you filled in yourself.
- [ ] **Rendered and visually inspected in a browser** — layout and the error/validation states
look right, not just green tests. No browser available? The `renderToString` fallback above
counts only if you grepped the emitted classes/states **and** said the visual pass is owed.