feat(ui): add a shared native select and a segmented control
Two patterns the app had been rebuilding per call site. NativeSelect: eight `<select>` elements had each been hand-styled, arriving at four different answers — `h-8 rounded border px-1 text-xs`, `h-9 rounded border px-2 text-sm`, `h-9 flex-1 rounded ...`, and one correct `h-9 rounded-md border-input px-2`. Measured, that is two radii (4px against the system's 6px), two border colours (the row hairline against the 3:1 field border) and three text indents against Input's 12px. In the purchase dialog a select sat directly beside the Total input in a visibly different control language. It stays a native select on purpose: the platform picker on a phone and type-to-select on a keyboard are worth more here than a prettier menu, and entry speed is a stated product principle. The chevron is a background image from a new `--budget-select-chevron` token rather than a positioned icon, because that needs no wrapper element — so `className` lands on the select itself and every call site's existing `w-full`, `flex-1`, `h-8` and `w-40` keeps behaving as it did. The token is declared per theme since a background-image stroke cannot be `currentColor`. Segmented: four screens had built the same view switch from two loose buttons, the selected one filled and the other outlined, separated by a gap. That reads as one primary action beside one unrelated button rather than as two states of a single control. One track, no gap, `aria-pressed` per segment. Neither is wired up yet; the call sites follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
da8431a57f
commit
394c5f74df
@@ -0,0 +1,65 @@
|
||||
interface Option<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-or-three-way view switch: spend/balance, by post/by month, one/many,
|
||||
* percentages/amounts.
|
||||
*
|
||||
* Four screens had each built this from two loose `<Button>`s — the selected
|
||||
* one `variant="default"`, the other `variant="outline"`, separated by
|
||||
* `gap-1`. Rendered, that reads as one filled button sitting next to one
|
||||
* unrelated outline button, not as two states of a single control: the gap
|
||||
* says "these are separate", and the filled teal says "this is the primary
|
||||
* action on the card" when it only means "this is the view you are on".
|
||||
*
|
||||
* One track, one border, no gap, and `aria-pressed` per segment, so the thing
|
||||
* on screen and the thing a screen reader hears are the same control.
|
||||
*/
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: T;
|
||||
options: readonly Option<T>[];
|
||||
onChange: (next: T) => void;
|
||||
/** Names the group for a screen reader; there is no visible legend. */
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
// `w-fit justify-self-start` because this also sits as a direct child of
|
||||
// the purchase dialog's grid, where the default `stretch` alignment
|
||||
// pulled the track across the whole dialog. Both are inert in the flex
|
||||
// rows it sits in elsewhere.
|
||||
className="inline-flex w-fit shrink-0 justify-self-start rounded-md border border-input p-0.5"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={[
|
||||
// The inner radius is one step tighter than the track's, so the
|
||||
// selected segment nests inside it instead of overhanging.
|
||||
"h-7 rounded-[0.25rem] px-3 text-xs font-medium whitespace-nowrap transition-colors",
|
||||
selected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-budget-ink-muted hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user