Files
technitium-console/src/ui/Form.tsx
T
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 00:06:06 +07:00

136 lines
5.3 KiB
TypeScript

import { useId, type ReactNode } from 'react'
import { Tag } from './Tag'
import frm from './Form.module.css'
/*
A form row: label on the left, control on the right.
It was written by hand 38 times, and on top of that existed TWICE as a component
(in `screens/settings/parts.tsx` and in `screens/dhcp/parts.tsx`, byte-identical
apart from one comment) without any of the other twelve screens using either. With
the row loose, the pieces around it drifted apart too: the help under a field and
the checkbox group were each defined three times.
`modal` changes the grid: inside a dialog there is less room and fewer rows, so the
label column is narrower and carries no separator. That is the only real
difference between the two variants, which is why it is a parameter and not
another component.
*/
export function Row({
label,
help,
modal = false,
dependsOn,
children,
}: {
label: string
help?: ReactNode
modal?: boolean
/*
The name of the switch that has this row turned off, if there is one.
It is the master-switch signal, and it comes in THREE pieces with none to
spare: an **amber edge** on the side (seen without reading), **opacity**, which
already comes with the control's own `disabled`, and **a pill that NAMES it**,
because "this is off" without saying who turned it off makes you hunt for the
switch across the whole screen.
Amber and not a padlock: *amber = you can*. A control turned off by its master
can be switched on by the user themselves; one turned off by permission cannot.
If both applied, **the padlock wins**: what you cannot touch does not need two
explanations of why it is off.
*/
dependsOn?: string
/** Receives the `id` to put on the control, so the label governs it. */
children: (id: string) => ReactNode
}) {
const id = useId()
return (
<div className={`${modal ? frm.mrow : frm.row}${dependsOn != null ? ` ${frm.dependent}` : ''}`}>
{/*
The pill goes OUTSIDE the `<label>`, and this was measured before being left
this way.
Put inside, the field's accessible name became "ECS IPv4 Prefix
Length**Needs Enable EDNS Client Subnet**" and the control **stopped being
findable by its own label**. It is exactly what the design rules forbid for the
tooltip ("it never replaces the accessible name"), and here it was happening
with the pill: a visual reinforcement had eaten the name.
Outside, the label still governs its control and the pill is still read: it
is in the same cell, next to it, and it is ordinary text.
*/}
{dependsOn == null ? (
<label className={modal ? frm.mrowLabel : frm.rowLabel} htmlFor={id}>
{label}
</label>
) : (
<div className={`${modal ? frm.mrowLabel : frm.rowLabel} ${frm.withPill}`}>
<label htmlFor={id}>{label}</label>
<Tag tone="acc">Needs {dependsOn}</Tag>
</div>
)}
<div className={modal ? frm.mrowCtl : frm.rowCtl}>
{children(id)}
{/* In the MODAL the help stays nested in the control's cell, which is
what makes it fall underneath. Its geometry is not touched: the dense-form
design measured a form, not a dialog. */}
{modal && help != null && <div className={frm.help}>{help}</div>}
</div>
{/* In the PANEL it is the control's sibling, and that is why it can take up
its own column. It is the structural change the third column asks for:
while it was inside the cell, no grid rule could get it out. */}
{!modal && help != null && <div className={`${frm.rowHelp} ${frm.help}`}>{help}</div>}
</div>
)
}
/**
* A row whose label governs no single control (checkbox and radio groups):
* upstream uses a `<label>` with no `for` there, because pointing it at one of the
* group's controls would lie about what it refers to.
*/
export function GroupRow({
label,
help,
modal = false,
role,
children,
}: {
label: string
help?: ReactNode
modal?: boolean
/*
Opt-in: makes the group a `radiogroup` NAMED by the row's label, so a screen
reader announces that label before the options and a test can find the group
by it. Upstream's markup does not do it; it changes no text and no layout.
*/
role?: 'radiogroup'
children: ReactNode
}) {
const labelId = useId()
return (
<div className={modal ? frm.mrow : frm.row}>
<div className={modal ? frm.mrowLabel : frm.rowLabel} id={role != null ? labelId : undefined}>
{label}
</div>
<div className={modal ? frm.mrowCtl : frm.rowCtl}>
<div className={frm.group} role={role} aria-labelledby={role != null ? labelId : undefined}>
{children}
</div>
{modal && help != null && <div className={frm.help}>{help}</div>}
</div>
{/* Same as in `Row`, and for the same reason: `GroupRow` draws on the SAME
`.row` grid, and twenty places pass it help. If its own stayed nested,
two rows of the same panel would put the same thing in two places and
the third column would be empty in every other one. */}
{!modal && help != null && <div className={`${frm.rowHelp} ${frm.help}`}>{help}</div>}
</div>
)
}
/** The standalone help, for when it does not hang off a row. */
export function HelpText({ children }: { children: ReactNode }) {
return <div className={frm.help}>{children}</div>
}