src/components/entry-unit-toggle.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { Fragment, type ReactNode } from "react";
import {
UNIT_SYSTEMS,
unitLabel,
unitWord,
type EntryDimension,
type UnitSystem,
} from "@/lib/units";
import { cn } from "@/lib/utils";
export interface EntryUnitToggleProps {
/** Which measurement this governs, which is what decides the two labels. */
dimension: EntryDimension;
/** The system currently on, highlighted between the two. */
entryUnits: UnitSystem;
/** Flips to the other system. */
onToggle: () => void;
}
/**
* A two-segment switch for one dimension's *entry* units - "m | ft", "bar | psi".
*
* One `<button>` rather than two or a radiogroup: there are exactly two systems,
* so pressing it can only mean "the other one", and a single control keeps the
* form's tab order from growing a stop per unit. It sits beside a `FormLabel`
* rather than inside one - interactive content in a `<label>` misroutes clicks -
* and outside `FormControl`, so the field's own labelling is untouched and this
* carries its own name as the secondary control of a composite field.
*
* That name has to *contain* the visible text before it names the action (WCAG
* 2.5.3, Label in Name: a speech-input user says what they can see), which is
* also the repo's own convention wherever an `aria-label` overrides visible text.
*/
export function EntryUnitToggle({
dimension,
entryUnits,
onToggle,
}: EntryUnitToggleProps) {
const next: UnitSystem = entryUnits === "metric" ? "imperial" : "metric";
const visible = UNIT_SYSTEMS.map((system) =>
unitLabel(dimension, system),
).join(" | ");
return (
<button
// Explicit, because this renders inside the dive `<form>` where a button's
// default type submits - the same guard the gear controls beside it carry.
type="button"
onClick={onToggle}
aria-label={`${visible} — switch ${dimension} entry to ${unitWord(dimension, next)}`}
// Inline rather than flex, and the separator carries its own spaces, so the
// button's text content is the "m | ft" the `aria-label` above quotes -
// a gap drawn in CSS would leave the two disagreeing about what is visible.
//
// On touch the hit area grows up and out, and down only to the foot of the
// 24px label row - the 3px under this 18px toggle centred in it. The field's
// input starts there, and a pressure field's is not positioned, so anything
// reaching below the row would take its taps.
className="relative inline-block rounded border px-1.5 py-0.5 text-xs leading-none text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring touch:after:absolute touch:after:-inset-x-3 touch:after:-top-[13px] touch:after:-bottom-[3px]"
>
{UNIT_SYSTEMS.map((system, index) => (
<Fragment key={system}>
{index > 0 && (
<span className="text-muted-foreground/40">{" | "}</span>
)}
{/* The off system is `text-muted-foreground` at full strength, not the
`/60` it was: `--muted-foreground` is picked to clear AA on both
surfaces it lands on (see globals.css), and 60% of it is 12px text
at roughly half that - axe flagged one per toggle, six per dive
form. What separates the two halves is the weight and the
foreground/muted split, which is the same pair the rest of the app
uses for primary against secondary text; the opacity was only ever
adding to a distinction that already carried on its own. */}
<span
className={cn(
system === entryUnits
? "font-medium text-foreground"
: "text-muted-foreground",
)}
>
{unitLabel(dimension, system)}
</span>
</Fragment>
))}
</button>
);
}
/**
* A field's label row with its unit toggle parked at the right-hand end.
*
* The toggle is positioned rather than laid out beside the label, and that is the
* whole point of this component: **any flex or grid parent blockifies its
* children**, and `FormLabel` is a `<label>`, which is `display: inline` by
* default. Inside `flex items-center justify-between` its box shrinks from the
* 17px inline content area to the 14px line box its `leading-none` declares,
* while the 18px toggle becomes the row's height instead — so a field with a
* toggle sits its input 2px lower and its label text 2px higher than the field
* beside it in the same grid row.
*
* Out of flow, the toggle leaves the label inline in an ordinary block, so the row
* is the same line box as a field without a toggle and the text keeps its
* baseline, and the toggle has no say in the row's height. It is centred on that
* line box. Nothing here needs to know the toggle's height — which is what stops
* this drifting the next time the control's padding moves.
*/
export function EntryUnitLabelRow({
children,
...toggle
}: EntryUnitToggleProps & { children: ReactNode }) {
return (
// `mb-0` is load-bearing, and the reason is worth stating because it looks
// like a no-op. `FormItem` spaces its children with `space-y-2`, which is a
// *margin-bottom* on every child but the last - and vertical margins have no
// effect on an inline box, so a bare `FormLabel` silently drops it and its
// input sits 3px under the text rather than 11px. Every form in the app looks
// that way. Wrapping the label in anything block-level collects the 8px the
// label was never given, which would push this field's input below its
// neighbour's just as surely as the flex row did.
<div className="relative mb-0">
{children}
{/* `inset-y-0` + `items-center` rather than a translate, so the toggle
centres on whatever the label row turns out to be - including the two
lines it becomes if the label wraps on a narrow viewport. */}
<span className="absolute inset-y-0 right-0 flex items-center">
<EntryUnitToggle {...toggle} />
</span>
</div>
);
}