import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as SelectStories from "./Select.stories";

<Meta of={SelectStories} />

# Select (deprecated)

> **Deprecated 2026-08-20. Nothing new gets a native `Select`.**
>
> Use **`Picker`** — a `Listbox` with the trigger already attached, and a near drop-in for
> this component — or **`Combobox`** when the list is long enough to want typing.
>
> ```tsx
> // Before.
> <Select label="Quality" onChange={setQuality} options={QUALITY_OPTIONS} value={quality} />
>
> // After.
> <Picker label="Quality" onChange={setQuality} options={QUALITY_OPTIONS} value={quality} />
> ```
>
> The [2026-08-10 demotion](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-10-listbox-and-combobox-are-the-default-and-select-is-demoted.md)
> left four platform cases where native was still the right call — the mobile wheel picker,
> autofill, `:invalid`, and a form that posts with no JS. **Not one of them has ever applied
> to an app in this fleet**, and the exception is what agents kept reading as permission. It
> is closed: a new native `Select` is a new decision with the reason written down, not a
> call-site choice. `Listbox` has no `<optgroup>` equivalent, and that gap is a change to
> `Listbox` rather than a reason to keep this.
>
> It stays exported so existing call sites keep compiling while they convert, and it will be
> removed in the next major of `@charcuterie/ui`.

Everything below is how the component works, for the call sites still on it.

Eighteen sites in the fleet, and they split cleanly in two: **twelve native `<select>`s**
that differ only in their Tailwind classes, and **six hand-rolled `role="listbox"`
controls**. This component is the first twelve. The other six are a different pattern —
see below.

<Canvas of={SelectStories.Playground} />

## Props

<Controls />

## Sizing: `className` is the wrapper, `controlClassName` is the select

The chevron has to live somewhere, so this renders a wrapper around the native
`<select>` and positions the chevron against **that wrapper**. `className` therefore
lands on the wrapper and the `<select>` stays `w-full` inside it:

```tsx
<Select className="w-44" controlClassName="font-mono" label="Operation" options={OPS} />
```

means "this control is 176px" — chevron included — which is what a caller writing a
width already assumes.

It did not used to. `className` reached only the inner element while the wrapper stayed
`w-full`, so the control shrank and the chevron stayed pinned to the row's right edge:

| | left | right | width |
| --- | --- | --- | --- |
| wrapper | 55 | 1128.7 | 1073.6 |
| select | 55 | 231 | 176 |
| chevron | 1100.7 | 1116.7 | — |

**869.6px** between the control and its own chevron, measured in mux-magic's DSL rules
builder, with the control's own text clipping at the same time (`setScriptInfo` →
`setScriptInfc`). The width never worked; `ui@2.16.0`'s `tailwind-merge` made half of it
work, which is what made the other half visible
([#112](https://github.com/Sawtaytoes/charcuterie/issues/112)).

`Select.test.tsx` asserts this as a **measurement** rather than a class-name check —
every class-name assertion passed while the chevron was 869.6px away, because `w-44`
really was present, on the element nobody was looking at.

<Canvas of={SelectStories.Sized} />

### What goes where

| | Goes to | For |
| --- | --- | --- |
| `className` | the wrapper | width, margin (`ms-auto`), display, position — the outer box |
| `controlClassName` | the `<select>` | `font-mono` on the option text, a `text-*`, a `bg-*` |

A width in `controlClassName` is the bug again by hand: it desynchronises the inner
element from the wrapper the chevron is measured against.

## There is no state kind here, and that is the finding

Every other interactive component in this package owns its state through
`@charcuterie/logic`. This one owns **none**, because the platform already does — a
`<select>` holds the chosen option in the DOM, and putting a `useSinglePicker` beside it
would create the exact conflict the state layer exists to prevent:

> Two things believing they hold `value`.

That is the Radix argument from `Popover`, pointed at ourselves. So `value` seeds
`defaultValue` and changes are reported outward.

The consequence worth knowing: **`Select` is uncontrolled and `Tabs` is not**, and a reader
should be able to tell why without asking. A tab bar's selection has no DOM representation,
so something has to hold it. A select's does.

<Canvas of={SelectStories.AllVariants} />

## Native, and what that rules out

| Native gives you | A hand-rolled listbox rebuilds |
| --- | --- |
| Type-ahead | a keystroke buffer and a timer |
| Home / End / PageUp / PageDown | four more key handlers |
| The mobile wheel picker | nothing that feels native |
| Form submission and `:invalid` | a hidden input |
| Autofill | — |

What it cannot do is render a rich option — an icon, two lines, a badge — or filter.
mux-magic has six controls that need one of those (`CommandPicker`, `PathPicker`,
`LinkPicker`, `EnumPicker`, `AssFieldPicker`, `RenameTargetPicker`), and **every one of them
is a `Combobox`**: a text input that filters a listbox. Different pattern, different ARIA
contract, P2 in the plan.

Building a bare `Listbox` here that none of the six could adopt would have shipped a
component with no caller — the mistake this milestone was explicitly checking for.

## The placeholder is a disabled option

Not an empty-valued one. An enabled `<option value="">` looks identical, is selectable, and
silently posts `""`. Disabled, it reads as a prompt and can never be submitted.

<Canvas of={SelectStories.AllStates} />

## `appearance-none` takes the arrow away

Removing the UA's own chevron and forgetting to put one back leaves a control that looks
exactly like a text input. The replacement is `aria-hidden` decoration in the same grid
cell, with `pe-9` reserving room for it.

## Naming, once

Either `label` (which becomes `aria-label`) **or** a surrounding `Field` — never both. Two
naming mechanisms firing at once means the accessible name is whichever won, which is not a
thing a reader can predict.
