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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 22:58:20 +02:00
co-authored by Claude Opus 5
parent da8431a57f
commit 394c5f74df
3 changed files with 121 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* A native `<select>`, styled to Input's exact metrics.
*
* Native on purpose. This app is a fast-entry tool, and a real `<select>` gets
* the platform picker on a phone, type-to-select on a keyboard and the OS's own
* accessibility affordances for free — a Radix listbox would trade all of that
* for a nicer-looking menu. `src/components/ui/select.tsx` is still there for
* anywhere that genuinely needs a rich menu; nothing does yet.
*
* It exists because eight call sites had each hand-rolled the same control and
* arrived 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`. So a select and the Input beside it in
* the same row disagreed on radius (4px vs 6px), on border colour (the row
* hairline vs the 3:1 field border) and on text indent (4 or 8px vs 12px).
*
* `className` lands on the `<select>` itself and there is no wrapper element,
* so every layout class a call site already used — `w-full`, `flex-1`, `h-8`,
* `w-40` — keeps behaving exactly as it did. That is also why the chevron is a
* background image from `--budget-select-chevron` rather than an absolutely
* positioned icon: no wrapper to position it against.
*/
function NativeSelect({ className, ...props }: React.ComponentProps<"select">) {
return (
<select
data-slot="native-select"
className={cn(
// Matches Input line for line, except for the right padding that keeps
// the longest option clear of the chevron.
"h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-transparent bg-no-repeat py-1 pr-8 pl-3 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm dark:bg-input/30",
"bg-[image:var(--budget-select-chevron)] bg-[length:0.875rem] bg-[position:right_0.625rem_center]",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { NativeSelect };
+11
View File
@@ -55,6 +55,14 @@
--surplus: #1f6640; /* carried-forward surplus */
--overspend: #a5382a; /* negative balances only */
/* The chevron a native <select> draws for itself is the OS widget's, not
ours, so `appearance: none` removes it and this paints it back. It has to
be a data URI in a token rather than an inline SVG in the component: the
stroke colour cannot be `currentColor` inside a background-image, so the
glyph is re-declared per theme instead. Same path and stroke weight as
lucide's chevron-down, which is the icon family the rest of the app uses. */
--budget-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23546575' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
/* Elevation is light and cool, never a grey drop shadow. */
--budget-shadow-card: 0 1px 2px rgb(22 33 43 / 0.04), 0 8px 24px -12px rgb(22 33 43 / 0.12);
--budget-shadow-raised: 0 1px 1px rgb(22 33 43 / 0.05), 0 2px 6px -2px rgb(22 33 43 / 0.08);
@@ -101,6 +109,9 @@
--surplus: #57bb83;
--overspend: #f0866a;
/* Same glyph, lifted to the dark theme's muted ink. */
--budget-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a7b8' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
/* Dark elevation reads as a lighter surface plus a hairline, not a shadow:
shadows are invisible on a dark ground. */
--budget-shadow-card: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 12px 32px -16px rgb(0 0 0 / 0.7);
+65
View File
@@ -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>
);
}