@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.
Files changed (88) hide show
  1. package/dist/docs/AtlantisThemeContext/AtlantisThemeContext.md +40 -0
  2. package/dist/docs/Icon/Icon.md +60 -53
  3. package/dist/docs/Select/Select.md +987 -0
  4. package/dist/docs/index.md +1 -0
  5. package/dist/package.json +3 -3
  6. package/dist/src/Select/Select.composable.test.js +38 -0
  7. package/dist/src/Select/Select.js +26 -0
  8. package/dist/src/Select/SelectComposableTypes.js +1 -0
  9. package/dist/src/Select/SelectRoot.js +85 -0
  10. package/dist/src/Select/SelectRoot.style.js +9 -0
  11. package/dist/src/Select/SelectRoot.test.js +157 -0
  12. package/dist/src/Select/components/SelectContent.js +51 -0
  13. package/dist/src/Select/components/SelectContent.test.js +51 -0
  14. package/dist/src/Select/components/SelectFieldContext.js +10 -0
  15. package/dist/src/Select/components/SelectItem.js +29 -0
  16. package/dist/src/Select/components/SelectItem.style.js +6 -0
  17. package/dist/src/Select/components/SelectItem.test.js +98 -0
  18. package/dist/src/Select/components/SelectLabel.js +29 -0
  19. package/dist/src/Select/components/SelectTrigger.js +40 -0
  20. package/dist/src/Select/components/SelectTrigger.style.js +26 -0
  21. package/dist/src/Select/index.js +2 -0
  22. package/dist/src/primitives/Portal/index.js +3 -1
  23. package/dist/src/primitives/Select/SelectPrimitive.js +4 -12
  24. package/dist/src/primitives/Select/SelectPrimitive.style.js +14 -9
  25. package/dist/src/primitives/Select/SelectPrimitive.test.js +2 -2
  26. package/dist/src/primitives/Select/index.js +1 -1
  27. package/dist/src/utils/meta/meta.json +11 -0
  28. package/dist/tsconfig.build.tsbuildinfo +1 -1
  29. package/dist/types/src/Select/Select.composable.test.d.ts +1 -0
  30. package/dist/types/src/Select/Select.d.ts +18 -0
  31. package/dist/types/src/Select/SelectComposableTypes.d.ts +50 -0
  32. package/dist/types/src/Select/SelectRoot.d.ts +12 -0
  33. package/dist/types/src/Select/SelectRoot.style.d.ts +8 -0
  34. package/dist/types/src/Select/SelectRoot.test.d.ts +1 -0
  35. package/dist/types/src/Select/components/SelectContent.d.ts +8 -0
  36. package/dist/types/src/Select/components/SelectContent.test.d.ts +1 -0
  37. package/dist/types/src/Select/components/SelectFieldContext.d.ts +27 -0
  38. package/dist/types/src/Select/components/SelectItem.d.ts +32 -0
  39. package/dist/types/src/Select/components/SelectItem.style.d.ts +5 -0
  40. package/dist/types/src/Select/components/SelectItem.test.d.ts +1 -0
  41. package/dist/types/src/Select/components/SelectLabel.d.ts +22 -0
  42. package/dist/types/src/Select/components/SelectTrigger.d.ts +20 -0
  43. package/dist/types/src/Select/components/SelectTrigger.style.d.ts +21 -0
  44. package/dist/types/src/Select/index.d.ts +2 -1
  45. package/dist/types/src/primitives/Select/SelectPrimitive.d.ts +3 -1
  46. package/dist/types/src/primitives/Select/SelectPrimitive.style.d.ts +6 -3
  47. package/dist/types/src/primitives/Select/index.d.ts +1 -1
  48. package/dist/types/src/primitives/Select/types.d.ts +1 -1
  49. package/package.json +3 -3
  50. package/src/Select/Select.composable.stories.tsx +93 -0
  51. package/src/Select/Select.composable.test.tsx +56 -0
  52. package/src/Select/Select.guide.md +197 -0
  53. package/src/Select/Select.tsx +31 -0
  54. package/src/Select/SelectComposableTypes.ts +57 -0
  55. package/src/Select/SelectRoot.style.ts +10 -0
  56. package/src/Select/SelectRoot.test.tsx +245 -0
  57. package/src/Select/SelectRoot.tsx +120 -0
  58. package/src/Select/components/SelectContent.test.tsx +67 -0
  59. package/src/Select/components/SelectContent.tsx +75 -0
  60. package/src/Select/components/SelectFieldContext.tsx +46 -0
  61. package/src/Select/components/SelectItem.style.ts +7 -0
  62. package/src/Select/components/SelectItem.test.tsx +166 -0
  63. package/src/Select/components/SelectItem.tsx +78 -0
  64. package/src/Select/components/SelectLabel.tsx +57 -0
  65. package/src/Select/components/SelectTrigger.style.ts +29 -0
  66. package/src/Select/components/SelectTrigger.tsx +98 -0
  67. package/src/Select/docs/SelectComposableBasic.tsx +18 -0
  68. package/src/Select/docs/SelectComposableControlledWithRef.tsx +28 -0
  69. package/src/Select/docs/SelectComposableCustomMarker.tsx +25 -0
  70. package/src/Select/docs/SelectComposableDescription.tsx +18 -0
  71. package/src/Select/docs/SelectComposableDisabled.tsx +18 -0
  72. package/src/Select/docs/SelectComposableDisabledOptions.tsx +20 -0
  73. package/src/Select/docs/SelectComposableGroupedOptions.tsx +29 -0
  74. package/src/Select/docs/SelectComposableItemPrefixSuffix.tsx +34 -0
  75. package/src/Select/docs/SelectComposableLabelAbove.tsx +18 -0
  76. package/src/Select/docs/SelectComposableLabelInside.tsx +18 -0
  77. package/src/Select/docs/SelectComposableProvinces.tsx +36 -0
  78. package/src/Select/docs/SelectComposableReadOnly.tsx +18 -0
  79. package/src/Select/docs/SelectComposableValidationFlow.tsx +27 -0
  80. package/src/Select/docs/index.ts +14 -0
  81. package/src/Select/index.ts +10 -1
  82. package/src/primitives/Portal/index.ts +3 -1
  83. package/src/primitives/Select/SelectPrimitive.style.ts +18 -11
  84. package/src/primitives/Select/SelectPrimitive.test.tsx +2 -2
  85. package/src/primitives/Select/SelectPrimitive.tsx +6 -18
  86. package/src/primitives/Select/index.ts +4 -1
  87. package/src/primitives/Select/types.ts +1 -1
  88. 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.
@@ -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,10 @@
1
+ import { buildThemedStyles } from "../AtlantisThemeContext";
2
+
3
+ export const useStyles = buildThemedStyles(tokens => ({
4
+ container: {
5
+ gap: tokens["space-smaller"],
6
+ },
7
+ field: {
8
+ gap: tokens["space-small"],
9
+ },
10
+ }));
@@ -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
+ }