<!-- https://getkonjo.com/design/patterns/sheet-picker · source: docs/design/patterns/sheet-picker.md -->

# Pickers, selects, and sheets

Chips, sheets, and the native picker — chosen by option count.

> Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them.

Real forms are largely pickers, and the blind test found the system had none. Konjo ships
`BottomSheet` (a `@gorhom/bottom-sheet` wrapper), `RankPicker` (inline) and `RankPickerSheet`
(modal); these are the rules they follow.

**Not `BeltRankPicker`.** It is deprecated and still has five consumers. It lists the nineteen
Cuong Nhu ranks from a constant, which [rank](../foundations/rank.md) forbids outright — a
screen renders whatever ladder the dojo has, and nineteen is a Cuong Nhu number. Reach for
`RankPicker`, which takes the ladder as data.

**Choose the control by the size of the choice set:**

| Options | Control |
|---|---|
| 2–6, short labels | **Chips**, wrapping, inline. No sheet. The choice is visible without a tap. |
| 7–20 | **Sheet** with a plain list of rows |
| 21+, or the user knows what they want | **Sheet with a search field pinned at the top** |
| A date or a time | The **native picker**. Never rebuild one. |

A chip set is always preferred where it fits, because it shows the options rather than hiding
them behind a tap that also hides the field the user is filling in.

**Sheet anatomy:**

```
        ▁▁▁▁                     grabber, controlSize.grabber*, radius.pill, border.control
Choose a rank                    title 22/700 · left-aligned, never centred
                            ↕16
[ ⌕ Search…              ]       only above 20 options; h44, border.control
                            ↕12
●  Green                     ✓   row ≥64pt · rowTitle · hairline inset · check on the right
●  1 Brown Stripe                the selected row carries a check, not a fill —
●  2 Brown Stripes               a filled row inside a sheet reads as a hero
```

`radius.sheet` 24 on the top corners only. The scrim behind is `overlay.dim`, and it is what
separates the sheet from the page — there is no shadow. The sheet is dismissible by swipe and
by tapping the scrim, and both must leave the form's state untouched.

**A picker never validates.** It returns a value; the field it feeds validates on blur like
any other. A sheet that shows its own error message is a sheet doing the form's job.

**Multi-select** shows a count in the trigger ("3 selected") and a checkmark per row; the
sheet stays open. Single-select closes on tap — no confirm button, because the tap *is* the
confirmation.


## The native date and time picker

"Never rebuild one" leaves open what hosts it, and a wheel has no row to tap, so the
close-on-selection rule above does not reach it.

- **It sits in the app's own `BottomSheet`**, with the same grabber, the same left-aligned
  `title` ("Choose a date"), the same `radius.sheet` corners and the same scrim — so it is the
  same object as every other picker on the screen rather than a system surface that arrives
  from nowhere. On Android, where the platform dialog is the strong convention, use it.
- **The wheel is inline inside that sheet**, never a nested modal.
- **Confirmation is the selection**: changing the value commits it and closes. There is no Done
  button — the same reason a row list has none.
- **Dismissing without choosing leaves the field untouched**, including when a wheel has been
  spun but not settled.
- **The trigger shows the value in the [content-format](../foundations/content-format.md)
  form**, never the platform's own string, so a date reads the same everywhere in the app.
- **Bounds come from the product, not the design.** A field with no stated bounds accepts any
  date; if the record has limits, they are a product fact and belong in
  [product-facts](../product-facts.md).

## An option that cannot be chosen

Sooner or later a list has a row the person may not pick — a rank below the one they hold, a
class already full, an instructor who has left. The system has no disabled text colour, on
purpose: `text.quaternary` is not a text colour, and greying a row out is how you make something
unreadable rather than unavailable.

So an unavailable row is **not dimmed**. It stays at full contrast and **says why**, in
`caption` / `text.secondary`, on the row:

```
●  Green Belt                    Current rank      not selectable, full contrast
●  Brown Belt                                      selectable
●  Black Belt              Requires a test         not selectable, reason stated
```

- `accessibilityState={{ disabled: true }}` so it is announced as unavailable rather than
  silently ignoring taps.
- **A reason is mandatory.** A row that does nothing and does not say why reads as a bug, and
  the person will tap it three times before deciding the app is broken.
- **If most rows are unavailable, filter instead.** A list where four of nineteen can be chosen
  is a list that should have been five rows long, with a line above it saying what was excluded.


## The sheet's own measurements

The anatomy sketch shows a sheet that has a search field. Two of the three pickers on a typical
form do not, and the gaps for that case were never written down.

```
        ▁▁▁▁                       grabber · controlSize.grabber* · radius.pill · border.control
                          ↕16
Choose a rank                      title 22/700, left-aligned
                          ↕16      ← to the first ROW when there is no search field
●  Green Belt                      (with a search field: ↕16 to it, then ↕12 to the first row)
```

- **`spacing.page` 20 horizontal**, the same gutter as a screen — a sheet is a surface, not a
  card, and its content lines up with the content behind it.
- **Grabber to title 16. Title to the first row 16**, or to a search field 16 and then 12 from
  the search field to the rows.
- **The bottom is `insets.bottom + spacing.section`**, like a modal — there is no tab bar
  under a sheet.
- **A supporting line under the title** — the discard sheet has one — is `caption` /
  `text.secondary`, 8 under the title, and the rows start 16 below it.

So the discard sheet, in full:

```
        ▁▁▁▁
                          ↕16
Discard this promotion?            title 22/700
                           ↕8
You'll lose what you've typed.     caption · text.secondary
                          ↕16
Keep editing                       row ≥ controlHeight.row, the SAFE option first
Discard                            row, accent.red
```

## A sheet whose options come from the server

Every picker on a real screen — students, ranks, instructors — loads its rows. `states.md`
scopes itself to screens and to sections; a sheet is neither, so this is its own rule.

**A sheet always opens.** It never waits for its data before appearing, and a slow load never
leaves the person tapping a trigger that seems dead. The sheet rises, and the rows arrive in it.

| | What the sheet shows |
|---|---|
| **Loading** | The grabber, the title and the search field render immediately — they do not depend on the rows. Under a second, the row area is blank. Past a second, three or four [skeleton](states.md#skeletons) rows at `controlHeight.row`. Never a centred spinner in an empty sheet. |
| **Empty** | The title stays, the search field is **removed** — there is nothing to search — and the row area carries the [empty anatomy](states.md) at section weight: what would be here, and why it is not. No filled primary. |
| **Failed** | Title stays. In the row area: "Couldn't load students", plain words about what failed, and **`[ Try again ]` as a bordered secondary** — the screen behind the sheet still owns the filled primary. |
| **Empty after a search** | Rows go, the **search field and its query stay**, and the message names the query: "No students match 'torez'." Same rule as [search and filter](search-filter.md). |

- **The sheet keeps its height across states** where it can, so it does not resize under the
  person's thumb as rows arrive.
- **Dismissing during any state is always allowed** and leaves the field untouched — a picker
  that traps someone while it retries is worse than one that failed.
- **Announce the outcome**: `accessibilityLiveRegion="polite"` on the row area, so a
  screen-reader user learns the rows arrived, or did not.
- **A failure inside a sheet never fails the form behind it.** The field simply has no value
  yet, and validation treats it as empty.
