# Select Spec

## Purpose

`Select` lets a person choose a single option from a list. On mobile it renders
an anchored dropdown (never a bottom sheet or the native OS picker), styled with
Atlantis design tokens.

There are two APIs on the same `Select` export:

- The **composable API** (`Select.Root`, `Select.Trigger`, …) is the supported
  long-term direction. It is a thin, styled passthrough over
  [`@rn-primitives/select`](https://rnprimitives.com/select/).
- The **legacy props-driven API** (`<Select label … value … onChange>` with
  `Option`) continues to work unchanged for existing usage.

## API Status

### Legacy props-driven API

```tsx
<Select label="City" value={value} onChange={setValue}>
  <Option value="tor">Toronto</Option>
  <Option value="van">Vancouver</Option>
</Select>
```

Still supported for existing usage; renders through the native OS picker. Do not
use it for new work — prefer the composable API below.

### Composable API

```tsx
<Select.Root onValueChange={setValue}>
  <Select.Label>City</Select.Label>
  <Select.Trigger>
    <Select.Value placeholder="Select a city" />
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="tor" label="Toronto" />
    <Select.Item value="van" label="Vancouver" />
  </Select.Content>
</Select.Root>
```

Parts:

- `Select.Root` — field chrome (`labelPlacement`, `description` / `error`,
  `invalid` / `status`, `readOnly`, `accessibilityLabel`) wrapped around the
  state container.
- `Select.Trigger` — the styled field; renders the chevron and the open,
  pressed, disabled, readOnly, and critical states. Forwards a `ref` exposing
  imperative `open()` / `close()` (null when `readOnly`).
- `Select.Value` — shows the selected option's label, or the `placeholder`.
- `Select.Label` — the field label. Its content is authored as `children`; its
  placement (above the trigger vs. a small label inside it) is set by
  `labelPlacement` on `Select.Root`, **not** by where it is written. It renders
  nothing where composed — it registers its content into `Select.Root`, which
  places it in the chosen slot.
- `Select.Content` — the anchored dropdown surface (folds the portal, overlay,
  and viewport internally).
- `Select.Item` — an option row; auto-composes its label and a selected
  checkmark from `value` / `label` / `disabled`. Optional **slot props** add
  content: `prefix` / `suffix` (pass a `Select.ItemPrefix` /
  `Select.ItemSuffix`), and `indicator` overrides the default checkmark.
- `Select.ItemPrefix` / `Select.ItemSuffix` — styled leading / trailing slots to
  pass into `Select.Item`'s `prefix` / `suffix` props.
- `Select.Group` + `Select.GroupLabel` — a labelled group of options (the
  in-dropdown heading; distinct from the field `Select.Label`).
- `Select.Separator` — a divider between groups.

### Value model

Single-select only. The value is a `{ value, label }` object; `Select.Root`
takes `value` / `defaultValue` and `onValueChange((option) => void)`. There is
no multi-select, numeric value, or computed display value — `Select.Value` shows
the selected option's label.

Omitting `value` uses uncontrolled state. Explicitly passing `value={undefined}`
is a controlled empty value and shows the placeholder. `SelectPrimitive.Root`
normalizes that distinction before forwarding to `@rn-primitives/select`, which
otherwise treats `undefined` as uncontrolled and can reveal a stale selection
after a controlled value is cleared.

## Composition model

A few decisions shape how the composable parts fit together:

- **The primitive owns upstream state interop; the sugar adds field behavior.**
  `SelectPrimitive` (`@jobber/components-native/primitives`) normalizes an
  explicitly empty controlled value and otherwise remains a styling-focused
  passthrough with no field chrome, field state, chevron, read-only, or row
  composition. Those live in the `Select.*` sugar: `Select.Root` owns the
  field-state context, `Select.Trigger` composes the chevron / read-only /
  `invalid` styling, `Select.Content` folds the overlay parts, and `Select.Item`
  composes the row. (Most statics are these compositions; `Value` / `Group` /
  `GroupLabel` / `Separator` are direct passthroughs.)

- **Content is composed; placement is a prop.** `Select.Label` bears the label
  content as `children`; `labelPlacement` on `Select.Root` decides where it
  lands. Because "above the field" and "inside the field border" are different
  parents in the view tree, `Select.Label` can't render in place — it registers
  its content into `Select.Root`, which renders it in the above slot, or
  `Select.Trigger` renders it in the inside slot. So the part's JSX position is
  irrelevant; only `labelPlacement` matters.

- **Accessibility names the trigger directly.** The accessible name is applied
  as the trigger's `accessibilityLabel` — not via the rn-primitives `Label` / a
  `nativeID`+`accessibilityLabelledBy` association (unsupported on iOS). It is
  auto-derived from a string `Select.Label`; for non-string content (or a field
  with no visible label), pass `accessibilityLabel` on `Select.Root`. The
  visible label is hidden from assistive tech (the trigger already carries the
  name), so it isn't announced twice — mirroring `InputText` /
  `InputFieldWrapper`.

- **The dropdown uses an elevated dark surface.** Native does not have the web
  `data-elevation="elevated"` token remapping layer, so the primitive applies a
  local dark-only elevated surface to the dropdown and its pressed option rows.
  The selected row remains checkmark-only; it does not receive a persistent
  selected background.

### Rendering above native-stack modals

`Select.Content` renders correctly inside iOS native-stack full-screen modals
(`presentation: "fullScreenModal" | "modal"`) with no per-screen setup — the
single app-root `AtlantisPortalHost` is enough on both platforms.

The problem it solves: on iOS a native-stack modal is a separate
`UIViewController` presented above the app's root window, so a host mounted at
the app root lives _behind_ it — a dropdown portalled into that host would be
hidden by the modal. (On Android the modal shares the window hierarchy, so the
root host already reaches it.) `Select.Content` handles this internally, so a
consumer writes the same `<Select.Content>…</Select.Content>` whether or not the
field is inside a modal:

- **`FullWindowOverlay`** (react-native-screens, iOS only) — renders the
  dropdown into a native top-level `UIWindow` that sits above every view
  controller, including modals. It decouples _where the dropdown mounts in the
  React tree_ (still the root host) from _where it draws on screen_ (the top
  window). On Android it is a passthrough — the root host already suffices.
- **`GestureHandlerRootView`** (inside that overlay window) — the new window has
  no gesture context of its own, so a root view is re-established here to make
  touches and scrolling work inside the dropdown.
- **`ScrollView`** (react-native-gesture-handler) + a **`maxHeight`** (a
  fraction of the window height) — cap the dropdown and make long lists scroll
  instead of overflowing. rn-primitives positions the dropdown but never caps
  its height, so the cap comes from here; a window-height fraction adapts across
  devices and mirrors the native picker, which grows toward the screen height
  before scrolling. The gesture-handler ScrollView (rather than a plain React
  Native one) is required because the primitive `Content` claims the JS touch
  responder, which would otherwise stop scrolling.
- **Nesting order** — the scrollable `Content` nests _inside_ the tap-to-dismiss
  overlay, so an item press (deeper in the tree) wins the responder over the
  backdrop and selects, instead of falling through and dismissing.

This relies on `react-native-screens` and `react-native-gesture-handler`, which
a navigation-based app already has.

## Setup: adopting `Select` in an app

The dropdown renders through `@rn-primitives/portal`, so an app must do two
things before the composable `Select` works. (These are one-time host-app setup;
Atlantis does not do them for you.)

**1. Mount one `AtlantisPortalHost` at the app root.** Import it from
`@jobber/components-native/primitives` — it is the bundled
`@rn-primitives/portal` host, so mounting it (rather than a `PortalHost` from a
separate copy of the package) guarantees the dropdown resolves against the same
portal instance Atlantis renders into. Mount exactly one, as the last child of
the root layout:

```tsx
import { AtlantisPortalHost } from "@jobber/components-native/primitives";

export function App() {
  return (
    <Providers>
      <Navigation />
      <AtlantisPortalHost />
    </Providers>
  );
}
```

This one host is sufficient even for fields inside native-stack full-screen
modals — `Select.Content` lifts its dropdown above the modal on iOS, so no
per-screen host is needed (see
[Rendering above native-stack modals](#rendering-above-native-stack-modals)).

**2. Let Jest transpile `@rn-primitives`.** The packages ship ESM, so the host
app's Jest config must not ignore them — add `@rn-primitives` to
`transformIgnorePatterns`:

```js
// jest.config.js
transformIgnorePatterns: [
  "node_modules/(?!((jest-)?react-native|@react-native|@rn-primitives)/)",
];
```

For Atlantis' own tests, wrap renders with the `PortalHostWrapper` test util so
the dropdown renders into the test tree:

```tsx
render(ui, { wrapper: PortalHostWrapper });
```

> `@rn-primitives/select` declares a transitive dependency on
> `@radix-ui/react-select`, which is web-only and imported only by the
> primitive's web platform entries — Metro never bundles it on native.
