Form layout
In reviewLive · web
// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform; this is the intended API,// not yet exported from @dizyx/nockerl-react (promotion tracked).) Form layout is the COMPOSITION that// arranges fields: the section (a lifted card + header), the row grid (single/two// column), the settings row (label+desc left, control right), and the actions footer.// The fields themselves are NockerlTextField / ValidatedField; this is the scaffold around them.import { NockerlButton, NockerlTextField, NockerlSwitch } from '@dizyx/nockerl-react';
export function AccountForm() { return ( <Form onSubmit={save} aria-label="Account settings" maxWidth="md"> <FormSection title="Profile" description="Shown on your sessions and in the directory."> <FormRow columns={2}> <NockerlTextField label="Full name" required value={name} onValueChange={setName} /> <NockerlTextField label="Handle" required leadingIcon="@" value={handle} onValueChange={setHandle} /> <NockerlTextField label="Work email" type="email" required span value={email} onValueChange={setEmail} /> <Select label="Role" optional value={role} onValueChange={setRole} options={roles} /> </FormRow> </FormSection>
<FormSection title="Preferences" optional description="Defaults for new sessions and how we reach you."> <SettingRow label="Weekly digest" description="A Monday summary of every session."> <NockerlSwitch checked={digest} onChange={setDigest} ariaLabel="Weekly digest" /> </SettingRow> <SettingRow label="Mentions" description="Notify me when an agent needs a decision."> <NockerlSwitch checked={mentions} onChange={setMentions} ariaLabel="Mentions" /> </SettingRow> </FormSection>
<FormActions sticky={false}> <NockerlButton text="Cancel" variant="ghost" onClick={cancel} /> <NockerlButton text="Save changes" variant="primary" type="submit" loading={saving} /> </FormActions> </Form> );}// Android: Jetpack Compose (canonical). A form is a scrolling Column: each SECTION is// started by a HorizontalDivider() + a labelMedium/Medium header, fields are stacked with// Spacer(16.dp) (12.dp within a section), and the footer is a Row(Arrangement.End) with a// GHOST Cancel + PRIMARY Save. Pattern from SessionConfigFields / AvatarSettingsSheet /// NockerlSettingsRow. (No extracted NockerlForm scaffold yet; see the drift note.)import androidx.compose.foundation.layout.*import androidx.compose.foundation.rememberScrollStateimport androidx.compose.foundation.verticalScrollimport androidx.compose.material3.HorizontalDividerimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Switchimport androidx.compose.material3.Textimport com.nockerl.app.core.ui.NockerlButtonimport com.nockerl.app.core.ui.NockerlButtonVariantimport com.nockerl.app.core.ui.NockerlSettingsRow
Column( modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp),) { // ── Section: Profile ────────────────────────── Text("Profile", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(12.dp)) NockerlTextField(value = name, onValueChange = ::setName, label = "Full name", required = true) Spacer(Modifier.height(16.dp)) NockerlTextField(value = email, onValueChange = ::setEmail, label = "Work email", required = true)
// ── Section: Preferences ────────────────────── Spacer(Modifier.height(20.dp)); HorizontalDivider(); Spacer(Modifier.height(16.dp)) Text("Preferences", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(12.dp)) // Settings row: label + description on the left, the control on the right. NockerlSettingsRow(label = "Weekly digest", value = "A Monday summary of every session", onClick = {}) { Switch(checked = digest, onCheckedChange = ::setDigest) }
// ── Actions footer ──────────────────────────── Spacer(Modifier.height(24.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { NockerlButton("Cancel", onClick = ::cancel, variant = NockerlButtonVariant.GHOST) Spacer(Modifier.width(8.dp)) NockerlButton(if (saving) "Saving…" else "Save", onClick = ::save, variant = NockerlButtonVariant.PRIMARY, enabled = canSave) }}// macOS: SwiftUI (canonical). The form is a VStack(spacing: 16) of SettingsCards capped// at maxWidth 640 (SettingsComponents.SettingsCard / AppSettingsView). Each card is the// SECTION: an uppercase muted header (+ optional ⓘ) over stacked content at spacing 12.// A settings row is an HStack { label; Spacer(); control }. Save is a .nockerlPrimary// Button placed inline or in a trailing HStack. (No extracted Form/Section type yet.)ScrollView { VStack(alignment: .leading, spacing: 16) { SectionTitle(title: "Account")
SettingsCard("Profile") { TextField("Full name", text: $name).textFieldStyle(.plain).nockerlFieldBackground() TextField("Work email", text: $email).textFieldStyle(.plain).nockerlFieldBackground() }
SettingsCard("Preferences") { // Settings row: label on the left, control on the right. HStack(spacing: 6) { Text("Weekly digest").foregroundStyle(NockerlTheme.onSurface) InfoTip(text: "A Monday summary of every session.") Spacer() Toggle("", isOn: $digest).labelsHidden().toggleStyle(.switch) } }
// Actions footer, trailing-aligned. HStack { Spacer() Button("Cancel") { cancel() }.buttonStyle(.nockerlGhost) Button("Save") { save() }.buttonStyle(.nockerlPrimary).disabled(!canSave) } } .padding(24).frame(maxWidth: 640, alignment: .leading)}Parameters
Section titled “Parameters”| Prop | Type | Default | Description |
|---|---|---|---|
Form · onSubmit | (e: FormEvent) => void | Submit handler on the underlying <form>. Wire the primary action’s type="submit" to it so Enter submits. | |
Form · maxWidth | 'sm' | 'md' | 'lg' | number | 'md' | Bounded content width so sections, the two-column grid, and the footer all align to one edge. |
FormSection · title * | string | Section header, rendered uppercase + tracked in the muted token (the Voice SettingsCard idiom). | |
FormSection · description | string | One muted help line under the header. | |
FormSection · optional | boolean | false | Marks a whole section optional with an Optional tag beside the title. |
FormSection · headerAccessory | ReactNode | Optional slot trailing the header, center-aligned: the ⓘ InfoTip seat (settings grammar, task 2657). Empty → the header renders exactly as before. | |
FormSection · footer | ReactNode | Optional card footer INSIDE the section under a full-bleed hairline, for muted hints or quiet section actions (settings grammar, task 2657). | |
FormRow · columns | 1 | 2 | 1 | Single-column (stacked) or two-column grid. Fields stay aligned to the grid; collapses to one column when narrow. |
field · span | boolean | false | In a two-column row, makes a field span both columns (e.g. email, bio). |
field · required | boolean | false | Adds a status-colored * and sets the control required. Paired with a form-level legend, never color alone. |
field · optional | boolean | false | Adds an Optional marker for forms that mark optional rather than required. |
SettingRow · label / description * | string | The settings-style row: label + description on the LEFT, a control (its child) on the RIGHT. The control is a separate focusable target. | |
FormActions · sticky | boolean | false | Pins the right-aligned Cancel/Save footer to the bottom of the scroll area on long forms. |
Update (task 2657): Compose now ships the extracted scaffold
NockerlFormSection(title, description?, headerAccessory?, footer?, content), the
ratified lifted-card section with the same settings-grammar slots as the web
FormSection (the ⓘ seat + the hairline footer). The rows below remain as the
building blocks the pre-scaffold screens used inline.
| Parameter | Type | Default | Description |
|---|---|---|---|
Column | Modifier | fillMaxWidth + verticalScroll + padding(horizontal = 20.dp) | The form root. A long form scrolls; add imePadding() so the active field clears the keyboard. |
section header | Text | A labelMedium Text in onSurfaceVariant with FontWeight.Medium, preceded by a HorizontalDivider() + Spacer(16.dp). | |
field rhythm | Spacer | Spacer(Modifier.height(16.dp)) between top-level fields; 12.dp within a section; 20.dp before a section divider. | |
NockerlSettingsRow | (label, value?, trailingContent?) -> Unit | The settings row: label + optional value description on the left (a weighted Column), trailingContent (a Switch / chevron) on the right. | |
actions footer | Row | horizontalArrangement = Arrangement.End | A trailing Row: NockerlButton GHOST Cancel, Spacer(8.dp), PRIMARY Save (enabled = canSave; spinner + "Saving…" via leadingContent). |
required indicator | n/a | Not extracted. Android marks required in the label copy (no * mark yet). See the drift note. |
SwiftUI assembles the form from SettingsComponents. There is no Form /
Section type. The section is SettingsCard; the form is a VStack of cards.
| Style | Type | Default | Description |
|---|---|---|---|
SettingsCard(_:info:) | View | The form SECTION, a lifted card (chromeSurface + hairline + elevation, cardRadius 14) with an uppercase muted header (11pt, tracking .6), optional ⓘ InfoTip, and content at spacing: 12. | |
SectionTitle(title:) | View | The page/form heading above the cards (22pt bold, onSurface). | |
VStack(spacing: 16) | Layout | The form body: cards stacked at spacing: 16, .padding(24), .frame(maxWidth: 640, alignment: .leading) (the bounded content width). | |
settings row | HStack | Label + optional ⓘ on the left, Spacer(), then the control (Toggle().labelsHidden() / a Menu) on the right. | |
.nockerlFieldBackground() | ViewModifier | The recessed input well a stacked field sits in: canvasAlt inset + hairline, rounded rect. | |
NockerlHint / .nockerlHint() | View · ViewModifier | Packaged: the inline form micro-copy voice (onCanvasMuted + size-12 light + the Field-help space.1 gap above). Quiet, always visible, never interactive (an InfoTip is the popover trigger; a Callout is the banner). | |
actions footer | HStack | A trailing HStack { Spacer(); Button.nockerlGhost; Button.nockerlPrimary }, with Save .disabled(!canSave). |