fix(form): Select and InputNumber were unusable on a dark consumer palette

Three defects, all of the same shape: a token that happens to look right on THIS
library's own palette and wrong on a consumer's.

Select trigger
  - `border-main` and `focus-visible:ring-main/50` resolve to --main, which is the
    FOREGROUND. On the Punktfunk console theme that is a near-white border and a 3px
    near-white focus ring — reported from the field as "a really bright outline".
  - `data-placeholder:text-secondary` and the chevron's `text-secondary`: --secondary is
    a SURFACE colour there, so the chevron and placeholder were painted in a background
    tone at 50% opacity and all but vanished. That is what made the control stop reading
    as a select at all.
  - Retuned to InputText's vocabulary — border-input, ring-ring at ring-1, rounded-md,
    px-3 — because a select sits beside text inputs in every form we ship and the two
    must not look like different widgets. The chevron now carries its own colour and no
    opacity knock-down.

InputNumber
  - The spinner arrows are drawn by the BROWSER. With no declared color-scheme the UA
    paints them for a light UI: near-black arrows on a near-black field, i.e. invisible.
    Declared via arbitrary properties rather than Tailwind's scheme-* so it holds
    whatever Tailwind version a consumer builds with.

Why none of this was caught here: src/styles/theme.css has NO dark palette — 134 lines,
zero dark tokens — so every story in this Storybook renders light, and all three failures
need a dark consumer palette to appear. Components already use `dark:` variants for a
palette this workbench cannot show. Worth fixing separately; it is the actual root cause.

Also adds the stories these three never had (Select, InputNumber, InputText). form/Select
→ "In a form row" and form/InputText → "With a button" are the ones that make a height or
ring mismatch obvious on sight.
This commit is contained in:
2026-08-09 12:34:08 +02:00
parent b466cf1669
commit d97a7aea18
5 changed files with 243 additions and 7 deletions
+59
View File
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { InputNumber } from "./input-number";
const meta: Meta<typeof InputNumber> = {
title: "form/InputNumber",
component: InputNumber,
};
export default meta;
type Story = StoryObj<typeof InputNumber>;
const Host = ({
initial = 2000,
...rest
}: {
initial?: number;
min?: number;
max?: number;
step?: number | string;
disabled?: boolean;
}) => {
const [value, setValue] = useState(initial);
return (
<div className="w-56 space-y-2">
<InputNumber
value={value}
onChange={setValue}
aria-label="Number"
{...rest}
/>
<p className="text-muted-foreground text-xs">value = {value}</p>
</div>
);
};
/**
* Hover the field: the browser's spinner arrows appear on the right.
*
* They are drawn by the UA, not by us, so an input that declares no `color-scheme` gets light-UI
* arrows — near-black on a dark field, which reads as no arrows at all.
*
* ⚠ This Storybook cannot show that failure: `src/styles/theme.css` has no dark palette, so every
* story here renders light. The bug was found in a consumer (the Punktfunk console theme) and has
* to be re-checked there. Same blind spot hid the Select's foreground-coloured border and ring.
*/
export const Default: Story = { render: () => <Host /> };
export const WithBounds: Story = {
render: () => <Host initial={5} min={0} max={10} />,
};
/** Commits WHILE TYPING, so out-of-range keystrokes are rejected rather than clamped later. */
export const Clamping: Story = {
render: () => <Host initial={50} min={0} max={100} step={5} />,
};
export const Disabled: Story = { render: () => <Host disabled /> };
+12 -1
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { InputText } from "./input-text";
type Props = Omit<
@@ -13,7 +14,7 @@ type Props = Omit<
};
const InputNumber = React.forwardRef<HTMLInputElement, Props>(
({ value, onChange, min, max, onBlur, onFocus, ...rest }, ref) => {
({ value, onChange, min, max, onBlur, onFocus, className, ...rest }, ref) => {
const [draft, setDraft] = React.useState<string>(String(value));
const [focused, setFocused] = React.useState(false);
@@ -37,6 +38,16 @@ const InputNumber = React.forwardRef<HTMLInputElement, Props>(
ref={ref}
type="number"
inputMode="numeric"
// The spinner arrows are drawn by the BROWSER, not by us — and a control with no
// declared `color-scheme` gets painted for a light UI, so on a dark palette they are
// near-black arrows on a near-black field and read as missing entirely. Declaring the
// scheme is what makes the UA repaint them; nothing in our own CSS can reach inside.
// Arbitrary properties rather than Tailwind's `scheme-*` so this holds regardless of
// which Tailwind version a consumer builds with.
className={cn(
"[color-scheme:light] dark:[color-scheme:dark]",
className,
)}
min={min}
max={max}
value={draft}
+45
View File
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@/button";
import { InputText } from "./input-text";
const meta: Meta<typeof InputText> = {
title: "form/InputText",
component: InputText,
args: { placeholder: "/roms/snes" },
};
export default meta;
type Story = StoryObj<typeof InputText>;
export const Default: Story = {};
export const WithValue: Story = { args: { defaultValue: "/roms/n64" } };
export const Disabled: Story = { args: { disabled: true } };
export const Invalid: Story = {
args: { "aria-invalid": true, defaultValue: "not a path" },
};
/**
* The reference row for control heights.
*
* `InputText` is `h-input-height`, and `Button` has a `size="input"` variant that matches it —
* that variant exists precisely so a button beside a field lines up. The default button size is
* `h-9` and will NOT line up, which is the mismatch this story makes obvious.
*/
export const WithAButton: Story = {
render: (args) => (
<div className="w-full max-w-xl space-y-3">
<div className="flex items-center gap-2">
<InputText {...args} aria-label="Matching" />
<Button size="input">Correct size="input"</Button>
</div>
<div className="flex items-center gap-2">
<InputText {...args} aria-label="Mismatched" />
<Button>Wrong default size</Button>
</div>
</div>
),
};
+107
View File
@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@/button";
import { InputText } from "./input-text";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "./select";
const meta: Meta<typeof Select> = {
title: "form/Select",
component: Select,
};
export default meta;
type Story = StoryObj<typeof Select>;
const Providers = ({ size }: { size?: "sm" | "default" }) => (
<Select defaultValue="steamgriddb">
<SelectTrigger size={size} className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">auto</SelectItem>
<SelectItem value="steamgriddb">steamgriddb</SelectItem>
<SelectItem value="libretro">libretro</SelectItem>
</SelectContent>
</Select>
);
export const Default: Story = { render: () => <Providers /> };
export const Small: Story = { render: () => <Providers size="sm" /> };
/** Nothing chosen — the placeholder has to be legible, not a background tone. */
export const Placeholder: Story = {
render: () => (
<Select>
<SelectTrigger className="w-56">
<SelectValue placeholder="Pick a provider…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">auto</SelectItem>
<SelectItem value="steamgriddb">steamgriddb</SelectItem>
</SelectContent>
</Select>
),
};
export const Disabled: Story = {
render: () => (
<Select disabled defaultValue="auto">
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">auto</SelectItem>
</SelectContent>
</Select>
),
};
export const Grouped: Story = {
render: () => (
<Select defaultValue="snes9x">
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Nintendo</SelectLabel>
<SelectItem value="snes9x">snes9x</SelectItem>
<SelectItem value="mesen">mesen</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Sony</SelectLabel>
<SelectItem value="beetle-psx">beetle-psx</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
),
};
/**
* The story this component needed and never had.
*
* A select is almost never alone — it sits in a row with text inputs and buttons, and that row is
* where it has to look like it belongs. Three regressions are visible here the moment they return:
* a trigger taller or shorter than the field beside it, a border or focus ring in a different
* colour from the input's, and a chevron so faint the control stops reading as a select.
*
* Tab through it: every focus ring in the row should be the same colour and weight.
*/
export const InAFormRow: Story = {
render: () => (
<div className="flex w-full max-w-2xl items-center gap-2">
<InputText placeholder="/roms/snes" aria-label="Directory" />
<Providers />
<Button size="input">Add</Button>
</div>
),
};
+20 -6
View File
@@ -63,11 +63,22 @@ function SelectTrigger({
: style
}
className={cn(
"border-main data-placeholder:text-secondary [&_svg:not([class*='text-'])]:text-secondary focus-visible:border-ring",
"focus-visible:ring-main/50 aria-invalid:ring-error/20 dark:aria-invalid:ring-error/40 aria-invalid:border-error",
"dark:bg-neutral-accent/30 dark:hover:bg-neutral-accent/50 flex w-fit items-center justify-between gap-2 rounded-lg",
"border bg-transparent px-4 py-2 text-sm text-main whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none",
"focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-input-height data-[size=sm]:h-8",
// Deliberately the SAME token vocabulary as InputText — a select sits next to text
// inputs in every form we ship, and the two must not read as different widgets.
//
// Three tokens here used to be wrong in a way that only shows on a consumer palette:
// `border-main` and `ring-main` resolve to the FOREGROUND (near-white in the console
// theme), so the field wore a near-white border and a 3px near-white focus ring; and
// `text-secondary` is a SURFACE colour there, so the chevron and the placeholder were
// painted in a background tone and all but vanished — which is what made this stop
// looking like a select at all. @unom/ui's own palette happens to make `main` a dark
// tone, so its Storybook never showed any of it.
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring",
"aria-invalid:ring-error/20 dark:aria-invalid:ring-error/40 aria-invalid:border-error",
"dark:bg-neutral-accent/30 dark:hover:bg-neutral-accent/50 flex w-fit items-center justify-between gap-2 rounded-md",
"border bg-transparent px-3 py-2 text-sm text-main whitespace-nowrap shadow-sm transition-[color,box-shadow,background-color] outline-none",
"disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-input-height data-[size=sm]:h-8",
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center",
"*:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
mat.enabled && "material",
@@ -77,7 +88,10 @@ function SelectTrigger({
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
{/* The chevron is the only thing that says "this opens a list" — it carries its own
colour (so the trigger's `:not([class*='text-'])` rule leaves it alone) and no
opacity knock-down, because at 50% on a muted tone it was invisible. */}
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);