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:
@@ -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 /> };
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user