fix(form): Select and InputNumber were unusable on a dark consumer palette (0.9.2) #1
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@unom/ui",
|
"name": "@unom/ui",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "ui elements for unom",
|
"description": "ui elements for unom",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
|||||||
@@ -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 * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { InputText } from "./input-text";
|
import { InputText } from "./input-text";
|
||||||
|
|
||||||
type Props = Omit<
|
type Props = Omit<
|
||||||
@@ -13,7 +14,7 @@ type Props = Omit<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const InputNumber = React.forwardRef<HTMLInputElement, Props>(
|
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 [draft, setDraft] = React.useState<string>(String(value));
|
||||||
const [focused, setFocused] = React.useState(false);
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
|
||||||
@@ -37,6 +38,16 @@ const InputNumber = React.forwardRef<HTMLInputElement, Props>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
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}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
value={draft}
|
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
|
: style
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-main data-placeholder:text-secondary [&_svg:not([class*='text-'])]:text-secondary focus-visible:border-ring",
|
// Deliberately the SAME token vocabulary as InputText — a select sits next to text
|
||||||
"focus-visible:ring-main/50 aria-invalid:ring-error/20 dark:aria-invalid:ring-error/40 aria-invalid:border-error",
|
// inputs in every form we ship, and the two must not read as different widgets.
|
||||||
"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",
|
// Three tokens here used to be wrong in a way that only shows on a consumer palette:
|
||||||
"focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-input-height data-[size=sm]:h-8",
|
// `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]: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",
|
"*:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
mat.enabled && "material",
|
mat.enabled && "material",
|
||||||
@@ -77,7 +88,10 @@ function SelectTrigger({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<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.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user