diff --git a/src/form/input-number.stories.tsx b/src/form/input-number.stories.tsx new file mode 100644 index 0000000..77aa11d --- /dev/null +++ b/src/form/input-number.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { InputNumber } from "./input-number"; + +const meta: Meta = { + title: "form/InputNumber", + component: InputNumber, +}; + +export default meta; + +type Story = StoryObj; + +const Host = ({ + initial = 2000, + ...rest +}: { + initial?: number; + min?: number; + max?: number; + step?: number | string; + disabled?: boolean; +}) => { + const [value, setValue] = useState(initial); + return ( +
+ +

value = {value}

+
+ ); +}; + +/** + * 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: () => }; + +export const WithBounds: Story = { + render: () => , +}; + +/** Commits WHILE TYPING, so out-of-range keystrokes are rejected rather than clamped later. */ +export const Clamping: Story = { + render: () => , +}; + +export const Disabled: Story = { render: () => }; diff --git a/src/form/input-number.tsx b/src/form/input-number.tsx index 2e713ff..38614b7 100644 --- a/src/form/input-number.tsx +++ b/src/form/input-number.tsx @@ -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( - ({ value, onChange, min, max, onBlur, onFocus, ...rest }, ref) => { + ({ value, onChange, min, max, onBlur, onFocus, className, ...rest }, ref) => { const [draft, setDraft] = React.useState(String(value)); const [focused, setFocused] = React.useState(false); @@ -37,6 +38,16 @@ const InputNumber = React.forwardRef( 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} diff --git a/src/form/input-text.stories.tsx b/src/form/input-text.stories.tsx new file mode 100644 index 0000000..519676e --- /dev/null +++ b/src/form/input-text.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@/button"; +import { InputText } from "./input-text"; + +const meta: Meta = { + title: "form/InputText", + component: InputText, + args: { placeholder: "/roms/snes" }, +}; + +export default meta; + +type Story = StoryObj; + +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) => ( +
+
+ + +
+
+ + +
+
+ ), +}; diff --git a/src/form/select.stories.tsx b/src/form/select.stories.tsx new file mode 100644 index 0000000..f47dabe --- /dev/null +++ b/src/form/select.stories.tsx @@ -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 = { + title: "form/Select", + component: Select, +}; + +export default meta; + +type Story = StoryObj; + +const Providers = ({ size }: { size?: "sm" | "default" }) => ( + +); + +export const Default: Story = { render: () => }; + +export const Small: Story = { render: () => }; + +/** Nothing chosen — the placeholder has to be legible, not a background tone. */ +export const Placeholder: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; + +export const Grouped: Story = { + render: () => ( + + ), +}; + +/** + * 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: () => ( +
+ + + +
+ ), +}; diff --git a/src/form/select.tsx b/src/form/select.tsx index 916847d..20773f8 100644 --- a/src/form/select.tsx +++ b/src/form/select.tsx @@ -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} - + {/* 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. */} + );