@jobber/components-native 0.108.1 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs/AtlantisThemeContext/AtlantisThemeContext.md +40 -0
- package/dist/docs/Icon/Icon.md +60 -53
- package/dist/docs/Select/Select.md +987 -0
- package/dist/docs/index.md +1 -0
- package/dist/package.json +3 -3
- package/dist/src/Select/Select.composable.test.js +38 -0
- package/dist/src/Select/Select.js +26 -0
- package/dist/src/Select/SelectComposableTypes.js +1 -0
- package/dist/src/Select/SelectRoot.js +85 -0
- package/dist/src/Select/SelectRoot.style.js +9 -0
- package/dist/src/Select/SelectRoot.test.js +157 -0
- package/dist/src/Select/components/SelectContent.js +51 -0
- package/dist/src/Select/components/SelectContent.test.js +51 -0
- package/dist/src/Select/components/SelectFieldContext.js +10 -0
- package/dist/src/Select/components/SelectItem.js +29 -0
- package/dist/src/Select/components/SelectItem.style.js +6 -0
- package/dist/src/Select/components/SelectItem.test.js +98 -0
- package/dist/src/Select/components/SelectLabel.js +29 -0
- package/dist/src/Select/components/SelectTrigger.js +40 -0
- package/dist/src/Select/components/SelectTrigger.style.js +26 -0
- package/dist/src/Select/index.js +2 -0
- package/dist/src/primitives/Portal/index.js +3 -1
- package/dist/src/primitives/Select/SelectPrimitive.js +4 -12
- package/dist/src/primitives/Select/SelectPrimitive.style.js +14 -9
- package/dist/src/primitives/Select/SelectPrimitive.test.js +2 -2
- package/dist/src/primitives/Select/index.js +1 -1
- package/dist/src/utils/meta/meta.json +11 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/Select/Select.composable.test.d.ts +1 -0
- package/dist/types/src/Select/Select.d.ts +18 -0
- package/dist/types/src/Select/SelectComposableTypes.d.ts +50 -0
- package/dist/types/src/Select/SelectRoot.d.ts +12 -0
- package/dist/types/src/Select/SelectRoot.style.d.ts +8 -0
- package/dist/types/src/Select/SelectRoot.test.d.ts +1 -0
- package/dist/types/src/Select/components/SelectContent.d.ts +8 -0
- package/dist/types/src/Select/components/SelectContent.test.d.ts +1 -0
- package/dist/types/src/Select/components/SelectFieldContext.d.ts +27 -0
- package/dist/types/src/Select/components/SelectItem.d.ts +32 -0
- package/dist/types/src/Select/components/SelectItem.style.d.ts +5 -0
- package/dist/types/src/Select/components/SelectItem.test.d.ts +1 -0
- package/dist/types/src/Select/components/SelectLabel.d.ts +22 -0
- package/dist/types/src/Select/components/SelectTrigger.d.ts +20 -0
- package/dist/types/src/Select/components/SelectTrigger.style.d.ts +21 -0
- package/dist/types/src/Select/index.d.ts +2 -1
- package/dist/types/src/primitives/Select/SelectPrimitive.d.ts +3 -1
- package/dist/types/src/primitives/Select/SelectPrimitive.style.d.ts +6 -3
- package/dist/types/src/primitives/Select/index.d.ts +1 -1
- package/dist/types/src/primitives/Select/types.d.ts +1 -1
- package/package.json +3 -3
- package/src/Select/Select.composable.stories.tsx +93 -0
- package/src/Select/Select.composable.test.tsx +56 -0
- package/src/Select/Select.guide.md +197 -0
- package/src/Select/Select.tsx +31 -0
- package/src/Select/SelectComposableTypes.ts +57 -0
- package/src/Select/SelectRoot.style.ts +10 -0
- package/src/Select/SelectRoot.test.tsx +245 -0
- package/src/Select/SelectRoot.tsx +120 -0
- package/src/Select/components/SelectContent.test.tsx +67 -0
- package/src/Select/components/SelectContent.tsx +75 -0
- package/src/Select/components/SelectFieldContext.tsx +46 -0
- package/src/Select/components/SelectItem.style.ts +7 -0
- package/src/Select/components/SelectItem.test.tsx +166 -0
- package/src/Select/components/SelectItem.tsx +78 -0
- package/src/Select/components/SelectLabel.tsx +57 -0
- package/src/Select/components/SelectTrigger.style.ts +29 -0
- package/src/Select/components/SelectTrigger.tsx +98 -0
- package/src/Select/docs/SelectComposableBasic.tsx +18 -0
- package/src/Select/docs/SelectComposableControlledWithRef.tsx +28 -0
- package/src/Select/docs/SelectComposableCustomMarker.tsx +25 -0
- package/src/Select/docs/SelectComposableDescription.tsx +18 -0
- package/src/Select/docs/SelectComposableDisabled.tsx +18 -0
- package/src/Select/docs/SelectComposableDisabledOptions.tsx +20 -0
- package/src/Select/docs/SelectComposableGroupedOptions.tsx +29 -0
- package/src/Select/docs/SelectComposableItemPrefixSuffix.tsx +34 -0
- package/src/Select/docs/SelectComposableLabelAbove.tsx +18 -0
- package/src/Select/docs/SelectComposableLabelInside.tsx +18 -0
- package/src/Select/docs/SelectComposableProvinces.tsx +36 -0
- package/src/Select/docs/SelectComposableReadOnly.tsx +18 -0
- package/src/Select/docs/SelectComposableValidationFlow.tsx +27 -0
- package/src/Select/docs/index.ts +14 -0
- package/src/Select/index.ts +10 -1
- package/src/primitives/Portal/index.ts +3 -1
- package/src/primitives/Select/SelectPrimitive.style.ts +18 -11
- package/src/primitives/Select/SelectPrimitive.test.tsx +2 -2
- package/src/primitives/Select/SelectPrimitive.tsx +6 -18
- package/src/primitives/Select/index.ts +4 -1
- package/src/primitives/Select/types.ts +1 -1
- package/src/utils/meta/meta.json +11 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Select Spec
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Select` lets a person choose a single option from a list. On mobile it renders
|
|
6
|
+
an anchored dropdown (never a bottom sheet or the native OS picker), styled with
|
|
7
|
+
Atlantis design tokens.
|
|
8
|
+
|
|
9
|
+
There are two APIs on the same `Select` export:
|
|
10
|
+
|
|
11
|
+
- The **composable API** (`Select.Root`, `Select.Trigger`, …) is the supported
|
|
12
|
+
long-term direction. It is a thin, styled passthrough over
|
|
13
|
+
[`@rn-primitives/select`](https://rnprimitives.com/select/).
|
|
14
|
+
- The **legacy props-driven API** (`<Select label … value … onChange>` with
|
|
15
|
+
`Option`) continues to work unchanged for existing usage.
|
|
16
|
+
|
|
17
|
+
## API Status
|
|
18
|
+
|
|
19
|
+
### Legacy props-driven API
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
<Select label="City" value={value} onChange={setValue}>
|
|
23
|
+
<Option value="tor">Toronto</Option>
|
|
24
|
+
<Option value="van">Vancouver</Option>
|
|
25
|
+
</Select>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Still supported for existing usage; renders through the native OS picker. Do not
|
|
29
|
+
use it for new work — prefer the composable API below.
|
|
30
|
+
|
|
31
|
+
### Composable API
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
<Select.Root onValueChange={setValue}>
|
|
35
|
+
<Select.Label>City</Select.Label>
|
|
36
|
+
<Select.Trigger>
|
|
37
|
+
<Select.Value placeholder="Select a city" />
|
|
38
|
+
</Select.Trigger>
|
|
39
|
+
<Select.Content>
|
|
40
|
+
<Select.Item value="tor" label="Toronto" />
|
|
41
|
+
<Select.Item value="van" label="Vancouver" />
|
|
42
|
+
</Select.Content>
|
|
43
|
+
</Select.Root>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Parts:
|
|
47
|
+
|
|
48
|
+
- `Select.Root` — field chrome (`labelPlacement`, `description` / `error`,
|
|
49
|
+
`invalid` / `status`, `readOnly`, `accessibilityLabel`) wrapped around the
|
|
50
|
+
state container.
|
|
51
|
+
- `Select.Trigger` — the styled field; renders the chevron and the open,
|
|
52
|
+
pressed, disabled, readOnly, and critical states. Forwards a `ref` exposing
|
|
53
|
+
imperative `open()` / `close()` (null when `readOnly`).
|
|
54
|
+
- `Select.Value` — shows the selected option's label, or the `placeholder`.
|
|
55
|
+
- `Select.Label` — the field label. Its content is authored as `children`; its
|
|
56
|
+
placement (above the trigger vs. a small label inside it) is set by
|
|
57
|
+
`labelPlacement` on `Select.Root`, **not** by where it is written. It renders
|
|
58
|
+
nothing where composed — it registers its content into `Select.Root`, which
|
|
59
|
+
places it in the chosen slot.
|
|
60
|
+
- `Select.Content` — the anchored dropdown surface (folds the portal, overlay,
|
|
61
|
+
and viewport internally).
|
|
62
|
+
- `Select.Item` — an option row; auto-composes its label and a selected
|
|
63
|
+
checkmark from `value` / `label` / `disabled`. Optional **slot props** add
|
|
64
|
+
content: `prefix` / `suffix` (pass a `Select.ItemPrefix` /
|
|
65
|
+
`Select.ItemSuffix`), and `indicator` overrides the default checkmark.
|
|
66
|
+
- `Select.ItemPrefix` / `Select.ItemSuffix` — styled leading / trailing slots to
|
|
67
|
+
pass into `Select.Item`'s `prefix` / `suffix` props.
|
|
68
|
+
- `Select.Group` + `Select.GroupLabel` — a labelled group of options (the
|
|
69
|
+
in-dropdown heading; distinct from the field `Select.Label`).
|
|
70
|
+
- `Select.Separator` — a divider between groups.
|
|
71
|
+
|
|
72
|
+
### Value model
|
|
73
|
+
|
|
74
|
+
Single-select only. The value is a `{ value, label }` object; `Select.Root`
|
|
75
|
+
takes `value` / `defaultValue` and `onValueChange((option) => void)`. There is
|
|
76
|
+
no multi-select, numeric value, or computed display value — `Select.Value` shows
|
|
77
|
+
the selected option's label.
|
|
78
|
+
|
|
79
|
+
## Composition model
|
|
80
|
+
|
|
81
|
+
A few decisions shape how the composable parts fit together:
|
|
82
|
+
|
|
83
|
+
- **The primitive is dumb; the sugar adds the Atlantis behavior.**
|
|
84
|
+
`SelectPrimitive` (`@jobber/components-native/primitives`) is a styling-only
|
|
85
|
+
passthrough with no field chrome, field state, chevron, read-only, or row
|
|
86
|
+
composition. Those live in the `Select.*` sugar: `Select.Root` owns the
|
|
87
|
+
field-state context, `Select.Trigger` composes the chevron / read-only /
|
|
88
|
+
`invalid` styling, `Select.Content` folds the overlay parts, and `Select.Item`
|
|
89
|
+
composes the row. (Most statics are these compositions; `Value` / `Group` /
|
|
90
|
+
`GroupLabel` / `Separator` are direct passthroughs.)
|
|
91
|
+
|
|
92
|
+
- **Content is composed; placement is a prop.** `Select.Label` bears the label
|
|
93
|
+
content as `children`; `labelPlacement` on `Select.Root` decides where it
|
|
94
|
+
lands. Because "above the field" and "inside the field border" are different
|
|
95
|
+
parents in the view tree, `Select.Label` can't render in place — it registers
|
|
96
|
+
its content into `Select.Root`, which renders it in the above slot, or
|
|
97
|
+
`Select.Trigger` renders it in the inside slot. So the part's JSX position is
|
|
98
|
+
irrelevant; only `labelPlacement` matters.
|
|
99
|
+
|
|
100
|
+
- **Accessibility names the trigger directly.** The accessible name is applied
|
|
101
|
+
as the trigger's `accessibilityLabel` — not via the rn-primitives `Label` / a
|
|
102
|
+
`nativeID`+`accessibilityLabelledBy` association (unsupported on iOS). It is
|
|
103
|
+
auto-derived from a string `Select.Label`; for non-string content (or a field
|
|
104
|
+
with no visible label), pass `accessibilityLabel` on `Select.Root`. The
|
|
105
|
+
visible label is hidden from assistive tech (the trigger already carries the
|
|
106
|
+
name), so it isn't announced twice — mirroring `InputText` /
|
|
107
|
+
`InputFieldWrapper`.
|
|
108
|
+
|
|
109
|
+
### Rendering above native-stack modals
|
|
110
|
+
|
|
111
|
+
`Select.Content` renders correctly inside iOS native-stack full-screen modals
|
|
112
|
+
(`presentation: "fullScreenModal" | "modal"`) with no per-screen setup — the
|
|
113
|
+
single app-root `AtlantisPortalHost` is enough on both platforms.
|
|
114
|
+
|
|
115
|
+
The problem it solves: on iOS a native-stack modal is a separate
|
|
116
|
+
`UIViewController` presented above the app's root window, so a host mounted at
|
|
117
|
+
the app root lives _behind_ it — a dropdown portalled into that host would be
|
|
118
|
+
hidden by the modal. (On Android the modal shares the window hierarchy, so the
|
|
119
|
+
root host already reaches it.) `Select.Content` handles this internally, so a
|
|
120
|
+
consumer writes the same `<Select.Content>…</Select.Content>` whether or not the
|
|
121
|
+
field is inside a modal:
|
|
122
|
+
|
|
123
|
+
- **`FullWindowOverlay`** (react-native-screens, iOS only) — renders the
|
|
124
|
+
dropdown into a native top-level `UIWindow` that sits above every view
|
|
125
|
+
controller, including modals. It decouples _where the dropdown mounts in the
|
|
126
|
+
React tree_ (still the root host) from _where it draws on screen_ (the top
|
|
127
|
+
window). On Android it is a passthrough — the root host already suffices.
|
|
128
|
+
- **`GestureHandlerRootView`** (inside that overlay window) — the new window has
|
|
129
|
+
no gesture context of its own, so a root view is re-established here to make
|
|
130
|
+
touches and scrolling work inside the dropdown.
|
|
131
|
+
- **`ScrollView`** (react-native-gesture-handler) + a **`maxHeight`** (a
|
|
132
|
+
fraction of the window height) — cap the dropdown and make long lists scroll
|
|
133
|
+
instead of overflowing. rn-primitives positions the dropdown but never caps
|
|
134
|
+
its height, so the cap comes from here; a window-height fraction adapts across
|
|
135
|
+
devices and mirrors the native picker, which grows toward the screen height
|
|
136
|
+
before scrolling. The gesture-handler ScrollView (rather than a plain React
|
|
137
|
+
Native one) is required because the primitive `Content` claims the JS touch
|
|
138
|
+
responder, which would otherwise stop scrolling.
|
|
139
|
+
- **Nesting order** — the scrollable `Content` nests _inside_ the tap-to-dismiss
|
|
140
|
+
overlay, so an item press (deeper in the tree) wins the responder over the
|
|
141
|
+
backdrop and selects, instead of falling through and dismissing.
|
|
142
|
+
|
|
143
|
+
This relies on `react-native-screens` and `react-native-gesture-handler`, which
|
|
144
|
+
a navigation-based app already has.
|
|
145
|
+
|
|
146
|
+
## Setup: adopting `Select` in an app
|
|
147
|
+
|
|
148
|
+
The dropdown renders through `@rn-primitives/portal`, so an app must do two
|
|
149
|
+
things before the composable `Select` works. (These are one-time host-app setup;
|
|
150
|
+
Atlantis does not do them for you.)
|
|
151
|
+
|
|
152
|
+
**1. Mount one `AtlantisPortalHost` at the app root.** Import it from
|
|
153
|
+
`@jobber/components-native/primitives` — it is the bundled
|
|
154
|
+
`@rn-primitives/portal` host, so mounting it (rather than a `PortalHost` from a
|
|
155
|
+
separate copy of the package) guarantees the dropdown resolves against the same
|
|
156
|
+
portal instance Atlantis renders into. Mount exactly one, as the last child of
|
|
157
|
+
the root layout:
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
import { AtlantisPortalHost } from "@jobber/components-native/primitives";
|
|
161
|
+
|
|
162
|
+
export function App() {
|
|
163
|
+
return (
|
|
164
|
+
<Providers>
|
|
165
|
+
<Navigation />
|
|
166
|
+
<AtlantisPortalHost />
|
|
167
|
+
</Providers>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This one host is sufficient even for fields inside native-stack full-screen
|
|
173
|
+
modals — `Select.Content` lifts its dropdown above the modal on iOS, so no
|
|
174
|
+
per-screen host is needed (see
|
|
175
|
+
[Rendering above native-stack modals](#rendering-above-native-stack-modals)).
|
|
176
|
+
|
|
177
|
+
**2. Let Jest transpile `@rn-primitives`.** The packages ship ESM, so the host
|
|
178
|
+
app's Jest config must not ignore them — add `@rn-primitives` to
|
|
179
|
+
`transformIgnorePatterns`:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
// jest.config.js
|
|
183
|
+
transformIgnorePatterns: [
|
|
184
|
+
"node_modules/(?!((jest-)?react-native|@react-native|@rn-primitives)/)",
|
|
185
|
+
];
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For Atlantis' own tests, wrap renders with the `PortalHostWrapper` test util so
|
|
189
|
+
the dropdown renders into the test tree:
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
render(ui, { wrapper: PortalHostWrapper });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
> `@rn-primitives/select` declares a transitive dependency on
|
|
196
|
+
> `@radix-ui/react-select`, which is web-only and imported only by the
|
|
197
|
+
> primitive's web platform entries — Metro never bundles it on native.
|
package/src/Select/Select.tsx
CHANGED
|
@@ -4,6 +4,16 @@ import { View } from "react-native";
|
|
|
4
4
|
import type { RegisterOptions } from "react-hook-form";
|
|
5
5
|
import { useStyles } from "./Select.style";
|
|
6
6
|
import { SelectInternalPicker } from "./components/SelectInternalPicker";
|
|
7
|
+
import { SelectRoot } from "./SelectRoot";
|
|
8
|
+
import { SelectTrigger } from "./components/SelectTrigger";
|
|
9
|
+
import { SelectLabel } from "./components/SelectLabel";
|
|
10
|
+
import { SelectContent } from "./components/SelectContent";
|
|
11
|
+
import {
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectItemPrefix,
|
|
14
|
+
SelectItemSuffix,
|
|
15
|
+
} from "./components/SelectItem";
|
|
16
|
+
import { SelectPrimitive } from "../primitives/Select";
|
|
7
17
|
import { InputFieldWrapper } from "../InputFieldWrapper";
|
|
8
18
|
import { Icon } from "../Icon";
|
|
9
19
|
import type { TextVariation } from "../Typography";
|
|
@@ -225,6 +235,27 @@ export function Select({
|
|
|
225
235
|
}
|
|
226
236
|
}
|
|
227
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The composable `Select.*` parts, attached as statics on the function
|
|
240
|
+
* declaration in this file — the docs site's prop generation
|
|
241
|
+
* (react-docgen-typescript) only discovers parts assigned here at compile
|
|
242
|
+
* time, not runtime attachments like `Object.assign`. Additive — the legacy
|
|
243
|
+
* props-driven `Select` + `Option` are unchanged (props, native picker,
|
|
244
|
+
* `ATL-Select` testID). See `Select.guide.md` for the parts and the
|
|
245
|
+
* composition model.
|
|
246
|
+
*/
|
|
247
|
+
Select.Root = SelectRoot;
|
|
248
|
+
Select.Trigger = SelectTrigger;
|
|
249
|
+
Select.Value = SelectPrimitive.Value;
|
|
250
|
+
Select.Label = SelectLabel;
|
|
251
|
+
Select.Content = SelectContent;
|
|
252
|
+
Select.Item = SelectItem;
|
|
253
|
+
Select.ItemPrefix = SelectItemPrefix;
|
|
254
|
+
Select.ItemSuffix = SelectItemSuffix;
|
|
255
|
+
Select.Group = SelectPrimitive.Group;
|
|
256
|
+
Select.GroupLabel = SelectPrimitive.Label;
|
|
257
|
+
Select.Separator = SelectPrimitive.Separator;
|
|
258
|
+
|
|
228
259
|
function getTextVariation({
|
|
229
260
|
invalid,
|
|
230
261
|
disabled,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { SelectPrimitiveRootProps } from "../primitives/Select";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for the composable `Select.Root` chrome — a presentational
|
|
6
|
+
* composition over `SelectPrimitive.Root`. Adds the optional field chrome
|
|
7
|
+
* (label, description / error) and the field-state flags (`invalid`,
|
|
8
|
+
* `status`, `readOnly`) that the styled trigger reads. Everything else passes
|
|
9
|
+
* through to `SelectPrimitive.Root` (and therefore `@rn-primitives/select`).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately RHF-free: no `name`, no `validations`.
|
|
12
|
+
*/
|
|
13
|
+
export interface SelectRootProps extends SelectPrimitiveRootProps {
|
|
14
|
+
/**
|
|
15
|
+
* Where the field label renders: `"inside"` the trigger (small, above the value)
|
|
16
|
+
* or `"above"` it. DS-owned default (`"inside"`); overridable per instance.
|
|
17
|
+
* The label content itself is authored via `Select.Label`.
|
|
18
|
+
*/
|
|
19
|
+
readonly labelPlacement?: "above" | "inside";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Explicit accessible name for the trigger. Auto-derived from a string
|
|
23
|
+
* `Select.Label`; supply this when the label content is a non-string
|
|
24
|
+
* `ReactNode`, or to name a field that has no visible `Select.Label`.
|
|
25
|
+
*/
|
|
26
|
+
readonly accessibilityLabel?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Supporting text rendered below the trigger. A string renders as neutral
|
|
30
|
+
* `HelperText`; a `ReactNode` renders verbatim. Suppressed when `error` is
|
|
31
|
+
* present.
|
|
32
|
+
*/
|
|
33
|
+
readonly description?: string | ReactNode;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Error message rendered below the trigger as critical `HelperText` (string)
|
|
37
|
+
* or verbatim (`ReactNode`). Takes priority over `description` and applies
|
|
38
|
+
* the critical trigger styling.
|
|
39
|
+
*/
|
|
40
|
+
readonly error?: string | ReactNode;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Applies the critical trigger styling without rendering an error message.
|
|
44
|
+
*/
|
|
45
|
+
readonly invalid?: boolean;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `critical` applies the critical trigger styling. Defaults to `neutral`.
|
|
49
|
+
*/
|
|
50
|
+
readonly status?: "neutral" | "critical";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders the trigger as non-interactive read-only presentation (subtle
|
|
54
|
+
* surface, no chevron, value as text). `disabled` takes precedence.
|
|
55
|
+
*/
|
|
56
|
+
readonly readOnly?: boolean;
|
|
57
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, within } from "@testing-library/react-native";
|
|
3
|
+
import type { TriggerRef } from "@rn-primitives/select";
|
|
4
|
+
import { SelectRoot } from "./SelectRoot";
|
|
5
|
+
import { SelectTrigger } from "./components/SelectTrigger";
|
|
6
|
+
import { SelectLabel } from "./components/SelectLabel";
|
|
7
|
+
import type { SelectRootProps } from "./SelectComposableTypes";
|
|
8
|
+
import { SelectPrimitive } from "../primitives/Select";
|
|
9
|
+
import { Text } from "../Text";
|
|
10
|
+
import { tokens } from "../utils/design";
|
|
11
|
+
|
|
12
|
+
function renderRoot(props: Partial<SelectRootProps> = {}) {
|
|
13
|
+
return render(
|
|
14
|
+
<SelectRoot {...props}>
|
|
15
|
+
<SelectTrigger>
|
|
16
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
17
|
+
</SelectTrigger>
|
|
18
|
+
</SelectRoot>,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("SelectRoot chrome", () => {
|
|
23
|
+
it("renders a composed label (visible, hidden from assistive tech)", () => {
|
|
24
|
+
render(
|
|
25
|
+
<SelectRoot>
|
|
26
|
+
<SelectLabel>City</SelectLabel>
|
|
27
|
+
<SelectTrigger>
|
|
28
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
29
|
+
</SelectTrigger>
|
|
30
|
+
</SelectRoot>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// The label renders but is hidden from a11y (the trigger carries the name).
|
|
34
|
+
expect(screen.queryByText("City")).toBeNull();
|
|
35
|
+
expect(
|
|
36
|
+
screen.getByText("City", { includeHiddenElements: true }),
|
|
37
|
+
).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders a string description as neutral helper text", () => {
|
|
41
|
+
renderRoot({ description: "Pick your city" });
|
|
42
|
+
|
|
43
|
+
expect(screen.getByText("Pick your city")).toBeDefined();
|
|
44
|
+
// Neutral helper text has no alert icon.
|
|
45
|
+
expect(screen.queryByTestId("alert")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders an error as critical helper text", () => {
|
|
49
|
+
renderRoot({ error: "This field is required" });
|
|
50
|
+
|
|
51
|
+
expect(screen.getByText("This field is required")).toBeDefined();
|
|
52
|
+
expect(screen.getByTestId("alert")).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("gives error priority over description", () => {
|
|
56
|
+
renderRoot({ description: "Pick your city", error: "Required" });
|
|
57
|
+
|
|
58
|
+
expect(screen.getByText("Required")).toBeDefined();
|
|
59
|
+
expect(screen.queryByText("Pick your city")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("treats an empty-string error as no error", () => {
|
|
63
|
+
renderRoot({ error: "", description: "Pick your city" });
|
|
64
|
+
|
|
65
|
+
// No critical styling, no error helper — the description shows instead.
|
|
66
|
+
expect(screen.getByTestId("ATL-Select-Trigger")).not.toHaveStyle({
|
|
67
|
+
borderColor: tokens["color-critical"],
|
|
68
|
+
});
|
|
69
|
+
expect(screen.queryByTestId("alert")).toBeNull();
|
|
70
|
+
expect(screen.getByText("Pick your city")).toBeDefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
["error", { error: "Required" }],
|
|
75
|
+
["status critical", { status: "critical" as const }],
|
|
76
|
+
["invalid", { invalid: true }],
|
|
77
|
+
])("applies critical trigger styling from %s", (_label, props) => {
|
|
78
|
+
renderRoot(props);
|
|
79
|
+
|
|
80
|
+
expect(screen.getByTestId("ATL-Select-Trigger")).toHaveStyle({
|
|
81
|
+
borderColor: tokens["color-critical"],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("makes the trigger read-only (no chevron) via the Root prop", () => {
|
|
86
|
+
renderRoot({ readOnly: true });
|
|
87
|
+
|
|
88
|
+
expect(screen.getByTestId("ATL-Select-Trigger")).toHaveStyle({
|
|
89
|
+
backgroundColor: tokens["color-surface--background--subtle"],
|
|
90
|
+
});
|
|
91
|
+
expect(screen.queryByTestId("arrowDown")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it.each([false, true])(
|
|
95
|
+
"uses a provided trigger testID verbatim (readOnly=%s)",
|
|
96
|
+
readOnly => {
|
|
97
|
+
render(
|
|
98
|
+
<SelectRoot readOnly={readOnly}>
|
|
99
|
+
<SelectTrigger testID="city">
|
|
100
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
101
|
+
</SelectTrigger>
|
|
102
|
+
</SelectRoot>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(screen.getByTestId("city")).toBeDefined();
|
|
106
|
+
expect(screen.queryByTestId("ATL-Select-Trigger")).toBeNull();
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
it("greys the value when disabled via the Root prop", () => {
|
|
111
|
+
renderRoot({
|
|
112
|
+
disabled: true,
|
|
113
|
+
defaultValue: { value: "tor", label: "Toronto" },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(screen.getByText("Toronto")).toHaveStyle({
|
|
117
|
+
color: tokens["color-disabled"],
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("SelectRoot label placement", () => {
|
|
123
|
+
const hidden = { includeHiddenElements: true } as const;
|
|
124
|
+
|
|
125
|
+
function renderComposed(props: Partial<SelectRootProps> = {}) {
|
|
126
|
+
return render(
|
|
127
|
+
<SelectRoot {...props}>
|
|
128
|
+
<SelectLabel>City</SelectLabel>
|
|
129
|
+
<SelectTrigger>
|
|
130
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
131
|
+
</SelectTrigger>
|
|
132
|
+
</SelectRoot>,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it("places the label inside the trigger by default (labelPlacement inside)", () => {
|
|
137
|
+
renderComposed();
|
|
138
|
+
|
|
139
|
+
const trigger = screen.getByTestId("ATL-Select-Trigger");
|
|
140
|
+
expect(within(trigger).getByText("City", hidden)).toBeDefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("places the label above the trigger with labelPlacement='above'", () => {
|
|
144
|
+
renderComposed({ labelPlacement: "above" });
|
|
145
|
+
|
|
146
|
+
// Rendered exactly once, and outside the trigger.
|
|
147
|
+
expect(screen.getAllByText("City", hidden)).toHaveLength(1);
|
|
148
|
+
const trigger = screen.getByTestId("ATL-Select-Trigger");
|
|
149
|
+
expect(within(trigger).queryByText("City", hidden)).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("ignores where Select.Label is written — placement follows the prop", () => {
|
|
153
|
+
render(
|
|
154
|
+
<SelectRoot labelPlacement="inside">
|
|
155
|
+
<SelectTrigger>
|
|
156
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
157
|
+
</SelectTrigger>
|
|
158
|
+
{/* Written last, but still renders inside the trigger. */}
|
|
159
|
+
<SelectLabel>City</SelectLabel>
|
|
160
|
+
</SelectRoot>,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const trigger = screen.getByTestId("ATL-Select-Trigger");
|
|
164
|
+
expect(within(trigger).getByText("City", hidden)).toBeDefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("auto-publishes a string Select.Label as the trigger's accessible name", () => {
|
|
168
|
+
renderComposed();
|
|
169
|
+
|
|
170
|
+
expect(
|
|
171
|
+
screen.getByTestId("ATL-Select-Trigger").props.accessibilityLabel,
|
|
172
|
+
).toBe("City");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("uses an explicit accessibilityLabel for non-string label content", () => {
|
|
176
|
+
render(
|
|
177
|
+
<SelectRoot accessibilityLabel="City">
|
|
178
|
+
<SelectLabel>
|
|
179
|
+
<Text>City</Text>
|
|
180
|
+
</SelectLabel>
|
|
181
|
+
<SelectTrigger>
|
|
182
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
183
|
+
</SelectTrigger>
|
|
184
|
+
</SelectRoot>,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(
|
|
188
|
+
screen.getByTestId("ATL-Select-Trigger").props.accessibilityLabel,
|
|
189
|
+
).toBe("City");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("warns when more than one Select.Label is composed", () => {
|
|
193
|
+
const warn = jest
|
|
194
|
+
.spyOn(console, "warn")
|
|
195
|
+
.mockImplementation(() => undefined);
|
|
196
|
+
|
|
197
|
+
render(
|
|
198
|
+
<SelectRoot>
|
|
199
|
+
<SelectLabel>City</SelectLabel>
|
|
200
|
+
<SelectLabel>Town</SelectLabel>
|
|
201
|
+
<SelectTrigger>
|
|
202
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
203
|
+
</SelectTrigger>
|
|
204
|
+
</SelectRoot>,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(warn).toHaveBeenCalledWith(
|
|
208
|
+
expect.stringContaining("multiple <Select.Label>"),
|
|
209
|
+
);
|
|
210
|
+
warn.mockRestore();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("SelectTrigger ref", () => {
|
|
215
|
+
it("forwards a ref exposing open/close", () => {
|
|
216
|
+
const triggerRef = React.createRef<TriggerRef>();
|
|
217
|
+
render(
|
|
218
|
+
<SelectRoot>
|
|
219
|
+
<SelectTrigger ref={triggerRef}>
|
|
220
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
221
|
+
</SelectTrigger>
|
|
222
|
+
</SelectRoot>,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(triggerRef.current).not.toBeNull();
|
|
226
|
+
expect(typeof triggerRef.current?.open).toBe("function");
|
|
227
|
+
expect(typeof triggerRef.current?.close).toBe("function");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does not attach a ref when readOnly (no interactive trigger)", () => {
|
|
231
|
+
const triggerRef = React.createRef<TriggerRef>();
|
|
232
|
+
render(
|
|
233
|
+
<SelectRoot readOnly defaultValue={{ value: "tor", label: "Toronto" }}>
|
|
234
|
+
<SelectTrigger ref={triggerRef}>
|
|
235
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
236
|
+
</SelectTrigger>
|
|
237
|
+
</SelectRoot>,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// readOnly mounts a static surface, so the ref stays null and an
|
|
241
|
+
// optional-chained open()/close() is a safe no-op.
|
|
242
|
+
expect(triggerRef.current).toBeNull();
|
|
243
|
+
expect(() => triggerRef.current?.open()).not.toThrow();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { View } from "react-native";
|
|
4
|
+
import { useStyles } from "./SelectRoot.style";
|
|
5
|
+
import type { SelectRootProps } from "./SelectComposableTypes";
|
|
6
|
+
import { SelectFieldContext } from "./components/SelectFieldContext";
|
|
7
|
+
import { FieldLabel } from "./components/SelectLabel";
|
|
8
|
+
import { HelperText } from "../primitives/HelperText";
|
|
9
|
+
import { SelectPrimitive } from "../primitives/Select";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Composable `Select.Root` chrome: renders the optional `description` / `error`
|
|
13
|
+
* below the field (via `HelperText`), owns the field-state context, and places the
|
|
14
|
+
* field label. The label content is authored via `Select.Label` (registered into
|
|
15
|
+
* this context); `labelPlacement` decides whether it renders in the above slot
|
|
16
|
+
* (here) or the inside slot (`Select.Trigger`). `error` takes priority over
|
|
17
|
+
* `description`. See `Select.guide.md` for the label / accessibility / layering
|
|
18
|
+
* model.
|
|
19
|
+
*/
|
|
20
|
+
export function SelectRoot({
|
|
21
|
+
labelPlacement = "inside",
|
|
22
|
+
accessibilityLabel: accessibilityLabelProp,
|
|
23
|
+
description,
|
|
24
|
+
error,
|
|
25
|
+
invalid,
|
|
26
|
+
status,
|
|
27
|
+
readOnly,
|
|
28
|
+
children,
|
|
29
|
+
...rootProps
|
|
30
|
+
}: SelectRootProps) {
|
|
31
|
+
const styles = useStyles();
|
|
32
|
+
// An empty / whitespace-only string error counts as "no error".
|
|
33
|
+
const hasError =
|
|
34
|
+
typeof error === "string" ? error.trim().length > 0 : error != null;
|
|
35
|
+
const isInvalid = invalid === true || status === "critical" || hasError;
|
|
36
|
+
|
|
37
|
+
// A composed `Select.Label` registers its content + (for a string) its
|
|
38
|
+
// accessible name here.
|
|
39
|
+
const [registered, setRegistered] = useState<{
|
|
40
|
+
content: ReactNode;
|
|
41
|
+
name?: string;
|
|
42
|
+
}>();
|
|
43
|
+
// Track how many `Select.Label`s are mounted to warn on unsupported
|
|
44
|
+
// multi-label usage (the registry holds a single label — the last one wins).
|
|
45
|
+
const labelCount = useRef(0);
|
|
46
|
+
const registerLabel = useCallback((content: ReactNode, name?: string) => {
|
|
47
|
+
labelCount.current += 1;
|
|
48
|
+
|
|
49
|
+
if (labelCount.current > 1) {
|
|
50
|
+
console.warn(
|
|
51
|
+
"Select: multiple <Select.Label> are composed — only one is supported; the last one wins.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
setRegistered({ content, name });
|
|
55
|
+
}, []);
|
|
56
|
+
const unregisterLabel = useCallback(() => {
|
|
57
|
+
labelCount.current = Math.max(0, labelCount.current - 1);
|
|
58
|
+
setRegistered(undefined);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const labelContent = registered?.content;
|
|
62
|
+
const accessibilityLabel = accessibilityLabelProp ?? registered?.name;
|
|
63
|
+
|
|
64
|
+
const fieldState = useMemo(
|
|
65
|
+
() => ({
|
|
66
|
+
labelPlacement,
|
|
67
|
+
labelContent,
|
|
68
|
+
accessibilityLabel,
|
|
69
|
+
invalid: isInvalid,
|
|
70
|
+
readOnly,
|
|
71
|
+
registerLabel,
|
|
72
|
+
unregisterLabel,
|
|
73
|
+
}),
|
|
74
|
+
[
|
|
75
|
+
labelPlacement,
|
|
76
|
+
labelContent,
|
|
77
|
+
accessibilityLabel,
|
|
78
|
+
isInvalid,
|
|
79
|
+
readOnly,
|
|
80
|
+
registerLabel,
|
|
81
|
+
unregisterLabel,
|
|
82
|
+
],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<View style={styles.container}>
|
|
87
|
+
<SelectFieldContext.Provider value={fieldState}>
|
|
88
|
+
<SelectPrimitive.Root style={styles.field} {...rootProps}>
|
|
89
|
+
{labelPlacement === "above" && <FieldLabel />}
|
|
90
|
+
{children}
|
|
91
|
+
</SelectPrimitive.Root>
|
|
92
|
+
</SelectFieldContext.Provider>
|
|
93
|
+
|
|
94
|
+
{renderBelowField(hasError ? error : undefined, description)}
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderBelowField(
|
|
100
|
+
error: SelectRootProps["error"],
|
|
101
|
+
description: SelectRootProps["description"],
|
|
102
|
+
): ReactNode {
|
|
103
|
+
if (error != null) {
|
|
104
|
+
return typeof error === "string" ? (
|
|
105
|
+
<HelperText message={error} status="critical" />
|
|
106
|
+
) : (
|
|
107
|
+
error
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (description != null) {
|
|
112
|
+
return typeof description === "string" ? (
|
|
113
|
+
<HelperText message={description} status="neutral" />
|
|
114
|
+
) : (
|
|
115
|
+
description
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|