模式
Forms and settings
Settings that apply at once, groups of fields saved together, forms in dialogs, the large one-pass form dialog, validation, long values in fields, option height and the danger zone.
A control that applies at once has no Save button. Fields that are edited together (a profile, a server's name and description) have their own Save at the end of their group. A form in a dialog has Cancel and the submit button in its footer. A large form filled in one pass, such as registering an app, opens as a wide dialog with a section list.
When it applies
Settings pages, forms in dialogs and drawers, and large configuration forms. Which control to use for a value is in Choosing a component.
Structure
Fields
- Put the label above the field and the help text below it (Field).
- Mark optional fields "(optional)", and required fields with an asterisk.
- Reserve the line under a field for its help text or its error, so the form doesn't move when an error appears.
- Make a setting's description state the result, not repeat the title: the title already names the setting, and people read on to learn what it does.
- Put a checkbox, a radio or a switch 8px before its label, centered on the label's first line, and at the end of a settings row on the title's first line. raft-ui places it there (
FieldItem, a checkbox's or radio's<label>row,ListRow); never nudge it with a margin (Alignment). The switch stays 50 × 30 and the checkbox and radio 20px, on a phone and a desktop alike.
Settings that apply at once
- Use a switch, a segmented control or a picker for a setting that stands alone (the appearance, the font size, whether the public page is on), and apply each change as it happens. Don't add a Save button: the change already shows.
Settings saved together
- Put fields that are edited together in one card, with Save at its end: a profile, a password, a server's name and description. Save applies only that card, so a half-edited card never saves another one.
- After a save, change the button to "Saved" with a check for a moment.
- Use a checkbox, not a switch, for a yes or no that is saved with its card, such as muting a server: a switch looks as if it already applied.
Forms in a dialog
- Put Cancel and the submit button at the bottom of a form in a dialog. Cancel is a ghost button there, and takes an ink border on a page (Button). Cancel comes first; the submit button is the screen's pink button and names the action it performs, usually the title's verb: Create in Create Channel; an Edit title's button is Save (Writing). Both are
size="lg", the height of the fields above them (Spacing). - Make a form with a few fields a small dialog, the default
size="md", 448px wide: creating a channel, an agent or a task. A form whose content needs more width, such as key-value rows or a command, takessize="lg", 640px (Dialog).
The large form dialog
A large form filled in one pass, such as registering or editing an app, opens as the wide dialog (Dialog): centered, 896px wide, with a fixed height of 760px or the window less 32px, whichever is smaller. It doesn't go to a settings page or a drawer: a form with many fields has to be seen as one whole, not as a strip along the edge.
- Make the 56px header the item's logo, the title and ×. The title is the words that opened it (Register App), or Edit and the item's name when editing. Don't put the submit button in the header.
- Put a strip under the header for what must stay in view while the form scrolls: the form's error, or a secret shown once.
- Put a section list on the left, 208px wide, so people can jump around a long form (sidebar rows; the section being read is a yellow sticker and follows the scroll), and the sections on the right (FormSection: a small heading, a status chip, one line of description, and a hairline between sections).
- Put Cancel first and the pink button last in the footer.
- Don't close the dialog when someone clicks outside it, so a stray click doesn't throw away a long form. ×, Esc and Cancel close it.
- Below 768px, leave out the section list.
- When creating, show only the sections that can be filled in before saving. Sections about an item that already exists (distribution, deletion) appear after it is saved.
- Write an action inside a section as an ordinary row, with the description on the left and the button on the right, not as a card inside a card.
Rules
Validation
- Check the fields when the form is submitted, not while people are still typing. A field whose value is wrong when the form opens shows its error at once.
- Show an invalid field with a
danger-strongborder, and put the error in its reserved line, saying how to fix it. - Put a problem with the whole form, such as a failed request, in an orange banner inside the form (Banner). Don't repeat the field errors in it.
- Don't show a toast for a validation error: it disappears, and the error has a place of its own under the field.
Long values in fields
A value can be any length: a pasted link with no spaces, a long Chinese name, a label translated into long words. Nothing in a form grows wider than its field, and no field grows wider than its dialog, its drawer or the screen, down to 320px (Responsive layout). What gives way depends on whether the part is one line by design.
- A label, help text or error wraps, breaking inside a word when the word alone is wider than the line. It's what explains the field, so cutting it off would hide the part people need. This holds for the label beside a checkbox, a radio or a switch too.
- A value in a one-line control truncates with an ellipsis: the selected value of a Select, a chip, a text prefix. The control keeps its height, so the form doesn't move as values change (Spacing). The whole value is the part's
titlewhile it is cut off, and stays its accessible name. - A value people type scrolls inside its field. An Input keeps its text on one line and scrolls it; a Textarea wraps it.
- An option in a list wraps to two lines, then truncates, with its whole text as the
title. Two lines let people tell apart two long values that begin the same way, which one line can't do, while every option stays short enough to scan. The selected value in the trigger is still one line. - A chip truncates at the width of its field, and its remove button stays visible at its end.
- A text prefix in an InputGroup takes at most half the field, and truncates past that, so there is always room to type.
- A list is never wider than the screen less 8px on each side. It is at least as wide as its field and at least 220px. A Select's or a Combobox's list grows to fit a longer option up to 24rem (or the field's width, when that is wider); a ComboboxSelect's list stays the field's width. Past that, the option wraps.
Every control holds these rules itself, so a form never adds its own min-w-0 or truncate. A control that truncates a value gets its title from useOverflowTitle (use-overflow-title.ts), which sets it only while the value is cut off. Give the hook a stable ref, a ref object or a memoized callback: an inline callback ref rebuilds its observers on every render. A layout that puts a field in a flex row still gives the field's column min-w-0, since a flex item won't shrink below its content without it.
Option height
An option's height follows its content, and its padding is constant. This holds for every row in a popup list: an option of a Select, a Combobox or a ComboboxSelect, and an item of a DropdownMenu or a ContextMenu, with a check mark, a radio mark or neither.
- One line is 36px, with a mouse and on a touch screen. The space above and below the text is what that row leaves around one line of the option's type: 7px around a desktop's 22px line, 8px around a touch screen's 20px line.
- A second line adds its own height and nothing else, whether the label wraps (Long values in fields) or a description sits under it. The space above and below stays the one-line option's, so a two-line option looks as loose as its neighbors instead of cramped, and two of them never touch.
- A list never sets its options' height. Options in one list may differ in height; they never differ in padding. Don't give an option a fixed height, a fixed line box or
leading-none, and don't give a list a fixed row size: one line would fit and the second would be cut off or squeezed. A list that only shows some of its options at a time measures each one. - A part beside the text fits the line: a 16px icon, a check mark, a count. It doesn't make a one-line option taller.
- The highlight sits 4px inside the popup, with
radius-chip: that close to the popup's edge it follows the popup's corner, so the space around it is even at the corner as along the sides. A separator leaves the same 4px above and below it (Shape).
The components hold this themselves (py-option-inset, the space a 36px row leaves around one line, with min-h-option as the floor for an option with no line of text), so a product never pads an option of its own. The menus no longer tighten to py-1 on a short window (600px tall or less): a one-line item kept its 36px floor there anyway, so the change only squeezed items of two lines, and a short screen, such as a phone held sideways, may now scroll a menu a little more.
Danger zone
- Put the danger zone at the end of the page, away from the settings people change often.
- Use orange for actions that interrupt but can be undone (stop, leave, reset) and red for actions that cannot be undone (delete) (Color).
- Ask for the server's address to be typed before deleting a server, because it deletes everything in it for everyone. Confirm other deletions with a dialog that names what is deleted (Feedback).
Prerequisites
- Don't show a form that can't be used yet. When something is missing, say what, and offer the way forward: connect a computer first, then create the agent.