@jobber/components-native 0.108.2 → 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 (86) hide show
  1. package/dist/docs/Select/Select.md +987 -0
  2. package/dist/docs/index.md +1 -0
  3. package/dist/package.json +2 -2
  4. package/dist/src/Select/Select.composable.test.js +38 -0
  5. package/dist/src/Select/Select.js +26 -0
  6. package/dist/src/Select/SelectComposableTypes.js +1 -0
  7. package/dist/src/Select/SelectRoot.js +85 -0
  8. package/dist/src/Select/SelectRoot.style.js +9 -0
  9. package/dist/src/Select/SelectRoot.test.js +157 -0
  10. package/dist/src/Select/components/SelectContent.js +51 -0
  11. package/dist/src/Select/components/SelectContent.test.js +51 -0
  12. package/dist/src/Select/components/SelectFieldContext.js +10 -0
  13. package/dist/src/Select/components/SelectItem.js +29 -0
  14. package/dist/src/Select/components/SelectItem.style.js +6 -0
  15. package/dist/src/Select/components/SelectItem.test.js +98 -0
  16. package/dist/src/Select/components/SelectLabel.js +29 -0
  17. package/dist/src/Select/components/SelectTrigger.js +40 -0
  18. package/dist/src/Select/components/SelectTrigger.style.js +26 -0
  19. package/dist/src/Select/index.js +2 -0
  20. package/dist/src/primitives/Portal/index.js +3 -1
  21. package/dist/src/primitives/Select/SelectPrimitive.js +4 -12
  22. package/dist/src/primitives/Select/SelectPrimitive.style.js +14 -9
  23. package/dist/src/primitives/Select/SelectPrimitive.test.js +2 -2
  24. package/dist/src/primitives/Select/index.js +1 -1
  25. package/dist/src/utils/meta/meta.json +11 -0
  26. package/dist/tsconfig.build.tsbuildinfo +1 -1
  27. package/dist/types/src/Select/Select.composable.test.d.ts +1 -0
  28. package/dist/types/src/Select/Select.d.ts +18 -0
  29. package/dist/types/src/Select/SelectComposableTypes.d.ts +50 -0
  30. package/dist/types/src/Select/SelectRoot.d.ts +12 -0
  31. package/dist/types/src/Select/SelectRoot.style.d.ts +8 -0
  32. package/dist/types/src/Select/SelectRoot.test.d.ts +1 -0
  33. package/dist/types/src/Select/components/SelectContent.d.ts +8 -0
  34. package/dist/types/src/Select/components/SelectContent.test.d.ts +1 -0
  35. package/dist/types/src/Select/components/SelectFieldContext.d.ts +27 -0
  36. package/dist/types/src/Select/components/SelectItem.d.ts +32 -0
  37. package/dist/types/src/Select/components/SelectItem.style.d.ts +5 -0
  38. package/dist/types/src/Select/components/SelectItem.test.d.ts +1 -0
  39. package/dist/types/src/Select/components/SelectLabel.d.ts +22 -0
  40. package/dist/types/src/Select/components/SelectTrigger.d.ts +20 -0
  41. package/dist/types/src/Select/components/SelectTrigger.style.d.ts +21 -0
  42. package/dist/types/src/Select/index.d.ts +2 -1
  43. package/dist/types/src/primitives/Select/SelectPrimitive.d.ts +3 -1
  44. package/dist/types/src/primitives/Select/SelectPrimitive.style.d.ts +6 -3
  45. package/dist/types/src/primitives/Select/index.d.ts +1 -1
  46. package/dist/types/src/primitives/Select/types.d.ts +1 -1
  47. package/package.json +2 -2
  48. package/src/Select/Select.composable.stories.tsx +93 -0
  49. package/src/Select/Select.composable.test.tsx +56 -0
  50. package/src/Select/Select.guide.md +197 -0
  51. package/src/Select/Select.tsx +31 -0
  52. package/src/Select/SelectComposableTypes.ts +57 -0
  53. package/src/Select/SelectRoot.style.ts +10 -0
  54. package/src/Select/SelectRoot.test.tsx +245 -0
  55. package/src/Select/SelectRoot.tsx +120 -0
  56. package/src/Select/components/SelectContent.test.tsx +67 -0
  57. package/src/Select/components/SelectContent.tsx +75 -0
  58. package/src/Select/components/SelectFieldContext.tsx +46 -0
  59. package/src/Select/components/SelectItem.style.ts +7 -0
  60. package/src/Select/components/SelectItem.test.tsx +166 -0
  61. package/src/Select/components/SelectItem.tsx +78 -0
  62. package/src/Select/components/SelectLabel.tsx +57 -0
  63. package/src/Select/components/SelectTrigger.style.ts +29 -0
  64. package/src/Select/components/SelectTrigger.tsx +98 -0
  65. package/src/Select/docs/SelectComposableBasic.tsx +18 -0
  66. package/src/Select/docs/SelectComposableControlledWithRef.tsx +28 -0
  67. package/src/Select/docs/SelectComposableCustomMarker.tsx +25 -0
  68. package/src/Select/docs/SelectComposableDescription.tsx +18 -0
  69. package/src/Select/docs/SelectComposableDisabled.tsx +18 -0
  70. package/src/Select/docs/SelectComposableDisabledOptions.tsx +20 -0
  71. package/src/Select/docs/SelectComposableGroupedOptions.tsx +29 -0
  72. package/src/Select/docs/SelectComposableItemPrefixSuffix.tsx +34 -0
  73. package/src/Select/docs/SelectComposableLabelAbove.tsx +18 -0
  74. package/src/Select/docs/SelectComposableLabelInside.tsx +18 -0
  75. package/src/Select/docs/SelectComposableProvinces.tsx +36 -0
  76. package/src/Select/docs/SelectComposableReadOnly.tsx +18 -0
  77. package/src/Select/docs/SelectComposableValidationFlow.tsx +27 -0
  78. package/src/Select/docs/index.ts +14 -0
  79. package/src/Select/index.ts +10 -1
  80. package/src/primitives/Portal/index.ts +3 -1
  81. package/src/primitives/Select/SelectPrimitive.style.ts +18 -11
  82. package/src/primitives/Select/SelectPrimitive.test.tsx +2 -2
  83. package/src/primitives/Select/SelectPrimitive.tsx +6 -18
  84. package/src/primitives/Select/index.ts +4 -1
  85. package/src/primitives/Select/types.ts +1 -1
  86. package/src/utils/meta/meta.json +11 -0
@@ -0,0 +1,67 @@
1
+ import React from "react";
2
+ import { render, screen, userEvent } from "@testing-library/react-native";
3
+ import { SelectContent } from "./SelectContent";
4
+ import { SelectItem } from "./SelectItem";
5
+ import { SelectTrigger } from "./SelectTrigger";
6
+ import { SelectRoot } from "../SelectRoot";
7
+ import { SelectPrimitive } from "../../primitives/Select";
8
+ import { PortalHostWrapper } from "../../utils/test";
9
+
10
+ const user = userEvent.setup();
11
+
12
+ beforeAll(() => {
13
+ const MockNativeMethods = require("react-native/jest/MockNativeMethods");
14
+ const mockedMethods = MockNativeMethods.default ?? MockNativeMethods;
15
+ mockedMethods.measure.mockImplementation(
16
+ (
17
+ callback: (
18
+ x: number,
19
+ y: number,
20
+ width: number,
21
+ height: number,
22
+ pageX: number,
23
+ pageY: number,
24
+ ) => void,
25
+ ) => {
26
+ callback(0, 0, 320, 48, 20, 100);
27
+ },
28
+ );
29
+ });
30
+
31
+ function renderContent() {
32
+ return render(
33
+ <SelectRoot>
34
+ <SelectTrigger>
35
+ <SelectPrimitive.Value placeholder="Select an option" />
36
+ </SelectTrigger>
37
+ <SelectContent>
38
+ <SelectItem value="tor" label="Toronto" />
39
+ <SelectItem value="van" label="Vancouver" />
40
+ </SelectContent>
41
+ </SelectRoot>,
42
+ { wrapper: PortalHostWrapper },
43
+ );
44
+ }
45
+
46
+ describe("Select.Content", () => {
47
+ it("renders nothing until the trigger opens it", async () => {
48
+ renderContent();
49
+
50
+ expect(screen.queryByText("Toronto")).toBeNull();
51
+
52
+ await user.press(screen.getByTestId("ATL-Select-Trigger"));
53
+
54
+ expect(screen.getByText("Toronto")).toBeDefined();
55
+ });
56
+
57
+ it("folds the portal, overlay, and viewport into one part", async () => {
58
+ renderContent();
59
+ await user.press(screen.getByTestId("ATL-Select-Trigger"));
60
+
61
+ // The single Select.Content renders the tap-outside overlay plus the items
62
+ // (portaled + scrolled via the folded Viewport).
63
+ expect(screen.getByTestId("ATL-Select-Overlay")).toBeDefined();
64
+ expect(screen.getByText("Toronto")).toBeDefined();
65
+ expect(screen.getByText("Vancouver")).toBeDefined();
66
+ });
67
+ });
@@ -0,0 +1,75 @@
1
+ import React from "react";
2
+ import type { PropsWithChildren, ReactElement } from "react";
3
+ import { Platform, StyleSheet, useWindowDimensions } from "react-native";
4
+ import {
5
+ GestureHandlerRootView,
6
+ ScrollView,
7
+ } from "react-native-gesture-handler";
8
+ import { FullWindowOverlay } from "react-native-screens";
9
+ import { SelectPrimitive } from "../../primitives/Select";
10
+ import type { SelectPrimitiveContentProps } from "../../primitives/Select";
11
+
12
+ /**
13
+ * iOS: draw the dropdown in a top-level window so it sits above native-stack
14
+ * modals the app-root host can't reach. GestureHandlerRootView restores gestures
15
+ * there so the inner ScrollView scrolls. Passthrough elsewhere.
16
+ */
17
+ function ModalSafeOverlay({ children }: PropsWithChildren): ReactElement {
18
+ if (Platform.OS !== "ios") {
19
+ return <>{children}</>;
20
+ }
21
+
22
+ return (
23
+ <FullWindowOverlay>
24
+ <GestureHandlerRootView style={styles.overlayRoot}>
25
+ {children}
26
+ </GestureHandlerRootView>
27
+ </FullWindowOverlay>
28
+ );
29
+ }
30
+
31
+ export type SelectContentProps = SelectPrimitiveContentProps;
32
+
33
+ // Cap the dropdown height (rn-primitives positions it but never caps it) so long
34
+ // lists scroll; a window fraction adapts across devices.
35
+ const MAX_HEIGHT_RATIO = 0.55;
36
+
37
+ /**
38
+ * Folds the primitive Portal / Overlay / Content / Viewport into one dropdown
39
+ * part. Positioning lives in the primitive `Content`; props pass through.
40
+ */
41
+ export function SelectContent({
42
+ children,
43
+ ...props
44
+ }: SelectContentProps): ReactElement {
45
+ const { height } = useWindowDimensions();
46
+ const maxHeight = Math.round(height * MAX_HEIGHT_RATIO);
47
+
48
+ return (
49
+ <SelectPrimitive.Portal>
50
+ <ModalSafeOverlay>
51
+ {/* Content nests inside Overlay so an item press beats the backdrop's
52
+ tap-to-dismiss and selects instead of closing. */}
53
+ <SelectPrimitive.Overlay>
54
+ <SelectPrimitive.Content {...props}>
55
+ <SelectPrimitive.Viewport>
56
+ <ScrollView
57
+ style={{ maxHeight }}
58
+ bounces={false}
59
+ keyboardShouldPersistTaps="handled"
60
+ >
61
+ {children}
62
+ </ScrollView>
63
+ </SelectPrimitive.Viewport>
64
+ </SelectPrimitive.Content>
65
+ </SelectPrimitive.Overlay>
66
+ </ModalSafeOverlay>
67
+ </SelectPrimitive.Portal>
68
+ );
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ overlayRoot: {
73
+ flex: 1,
74
+ },
75
+ });
@@ -0,0 +1,46 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ /** Where the field label renders relative to the trigger. */
5
+ export type LabelPlacement = "above" | "inside";
6
+
7
+ /**
8
+ * Sugar-owned field state, provided by `Select.Root` and read by the sugar parts
9
+ * (`Select.Trigger`, `Select.Label`). `Select.Label` registers its content here
10
+ * (rather than rendering in place); `Select.Root` / `Select.Trigger` render it in
11
+ * the slot `labelPlacement` selects. See `Select.guide.md` for the model.
12
+ */
13
+ export interface SelectFieldState {
14
+ /** Chosen label placement — above the trigger or inside it. */
15
+ readonly labelPlacement: LabelPlacement;
16
+
17
+ /** The label content to render — the registered `Select.Label` children. */
18
+ readonly labelContent?: ReactNode;
19
+
20
+ /** Resolved accessible name applied to the trigger. */
21
+ readonly accessibilityLabel?: string;
22
+
23
+ /** Resolved critical state (from `invalid` / `status` / `error`). */
24
+ readonly invalid?: boolean;
25
+
26
+ /** Render the trigger as a static, non-pressable read-only display. */
27
+ readonly readOnly?: boolean;
28
+
29
+ /** Register the label content (+ its accessible name, when it is a string). */
30
+ registerLabel(content: ReactNode, accessibleName?: string): void;
31
+
32
+ /** Clear a previously registered label (on `Select.Label` unmount). */
33
+ unregisterLabel(): void;
34
+ }
35
+
36
+ const noop = (): void => undefined;
37
+
38
+ export const SelectFieldContext = createContext<SelectFieldState>({
39
+ labelPlacement: "inside",
40
+ registerLabel: noop,
41
+ unregisterLabel: noop,
42
+ });
43
+
44
+ export function useSelectFieldState(): SelectFieldState {
45
+ return useContext(SelectFieldContext);
46
+ }
@@ -0,0 +1,7 @@
1
+ import { buildThemedStyles } from "../../AtlantisThemeContext";
2
+
3
+ export const useStyles = buildThemedStyles(() => ({
4
+ slot: {
5
+ justifyContent: "center",
6
+ },
7
+ }));
@@ -0,0 +1,166 @@
1
+ import React from "react";
2
+ import type { ReactNode } from "react";
3
+ import { render, screen, userEvent } from "@testing-library/react-native";
4
+ import { SelectItem, SelectItemPrefix, SelectItemSuffix } from "./SelectItem";
5
+ import { SelectTrigger } from "./SelectTrigger";
6
+ import { SelectRoot } from "../SelectRoot";
7
+ import { SelectPrimitive } from "../../primitives/Select";
8
+ import { Icon } from "../../Icon";
9
+ import { Text } from "../../Text";
10
+ import { PortalHostWrapper } from "../../utils/test";
11
+ import { tokens } from "../../utils/design";
12
+
13
+ const user = userEvent.setup();
14
+
15
+ beforeAll(() => {
16
+ const MockNativeMethods = require("react-native/jest/MockNativeMethods");
17
+ const mockedMethods = MockNativeMethods.default ?? MockNativeMethods;
18
+ mockedMethods.measure.mockImplementation(
19
+ (
20
+ callback: (
21
+ x: number,
22
+ y: number,
23
+ width: number,
24
+ height: number,
25
+ pageX: number,
26
+ pageY: number,
27
+ ) => void,
28
+ ) => {
29
+ callback(0, 0, 320, 48, 20, 100);
30
+ },
31
+ );
32
+ });
33
+
34
+ function renderItems(
35
+ items: ReactNode,
36
+ defaultValue?: { value: string; label: string },
37
+ onValueChange?: (option?: { value: string; label: string }) => void,
38
+ ) {
39
+ return render(
40
+ <SelectRoot defaultValue={defaultValue} onValueChange={onValueChange}>
41
+ <SelectTrigger>
42
+ <SelectPrimitive.Value placeholder="Select an option" />
43
+ </SelectTrigger>
44
+ <SelectPrimitive.Portal>
45
+ <SelectPrimitive.Overlay />
46
+ <SelectPrimitive.Content>
47
+ <SelectPrimitive.Viewport>{items}</SelectPrimitive.Viewport>
48
+ </SelectPrimitive.Content>
49
+ </SelectPrimitive.Portal>
50
+ </SelectRoot>,
51
+ { wrapper: PortalHostWrapper },
52
+ );
53
+ }
54
+
55
+ async function openSelect() {
56
+ await user.press(screen.getByTestId("ATL-Select-Trigger"));
57
+ }
58
+
59
+ describe("Select.Item", () => {
60
+ it("renders the option label from the label prop", async () => {
61
+ renderItems(<SelectItem value="tor" label="Toronto" />);
62
+ await openSelect();
63
+
64
+ expect(screen.getByText("Toronto")).toBeDefined();
65
+ });
66
+
67
+ it("shows the default checkmark on the selected row only", async () => {
68
+ renderItems(
69
+ <>
70
+ <SelectItem value="tor" label="Toronto" />
71
+ <SelectItem value="van" label="Vancouver" />
72
+ </>,
73
+ { value: "tor", label: "Toronto" },
74
+ );
75
+ await openSelect();
76
+
77
+ // The indicator self-hides on unselected rows, so only the selected row's
78
+ // checkmark is in the tree.
79
+ expect(screen.getAllByTestId("checkmark")).toHaveLength(1);
80
+ });
81
+
82
+ it("renders prefix and suffix slots around the label", async () => {
83
+ renderItems(
84
+ <SelectItem
85
+ value="tor"
86
+ label="Toronto"
87
+ prefix={
88
+ <SelectItemPrefix>
89
+ <Icon name="home" />
90
+ </SelectItemPrefix>
91
+ }
92
+ suffix={
93
+ <SelectItemSuffix>
94
+ <Text>ON</Text>
95
+ </SelectItemSuffix>
96
+ }
97
+ />,
98
+ );
99
+ await openSelect();
100
+
101
+ expect(screen.getByTestId("home")).toBeDefined();
102
+ expect(screen.getByText("ON")).toBeDefined();
103
+ expect(screen.getByText("Toronto")).toBeDefined();
104
+ });
105
+
106
+ it("shows the suffix alongside the checkmark on the selected row", async () => {
107
+ renderItems(
108
+ <SelectItem
109
+ value="tor"
110
+ label="Toronto"
111
+ suffix={
112
+ <SelectItemSuffix>
113
+ <Text>ON</Text>
114
+ </SelectItemSuffix>
115
+ }
116
+ />,
117
+ { value: "tor", label: "Toronto" },
118
+ );
119
+ await openSelect();
120
+
121
+ // The suffix coexists with the selected-state marker (it doesn't replace it).
122
+ expect(screen.getByText("ON")).toBeDefined();
123
+ expect(screen.getByTestId("checkmark")).toBeDefined();
124
+ });
125
+
126
+ it("overrides the default checkmark via the indicator prop", async () => {
127
+ renderItems(
128
+ <SelectItem
129
+ value="tor"
130
+ label="Toronto"
131
+ indicator={<Icon name="starFill" />}
132
+ />,
133
+ { value: "tor", label: "Toronto" },
134
+ );
135
+ await openSelect();
136
+
137
+ expect(screen.getByTestId("starFill")).toBeDefined();
138
+ expect(screen.queryByTestId("checkmark")).toBeNull();
139
+ });
140
+
141
+ it("selects an option on press (selection flows through the sugar)", async () => {
142
+ const onValueChange = jest.fn();
143
+ renderItems(
144
+ <SelectItem value="tor" label="Toronto" />,
145
+ undefined,
146
+ onValueChange,
147
+ );
148
+ await openSelect();
149
+
150
+ await user.press(screen.getByText("Toronto"));
151
+
152
+ expect(onValueChange).toHaveBeenCalledWith({
153
+ value: "tor",
154
+ label: "Toronto",
155
+ });
156
+ });
157
+
158
+ it("greys a disabled option's label", async () => {
159
+ renderItems(<SelectItem value="van" label="Vancouver" disabled />);
160
+ await openSelect();
161
+
162
+ expect(screen.getByText("Vancouver")).toHaveStyle({
163
+ color: tokens["color-disabled"],
164
+ });
165
+ });
166
+ });
@@ -0,0 +1,78 @@
1
+ import React from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { View } from "react-native";
4
+ import { useStyles } from "./SelectItem.style";
5
+ import { SelectPrimitive } from "../../primitives/Select";
6
+ import { Icon } from "../../Icon";
7
+
8
+ export interface SelectItemProps {
9
+ /** The option's value. */
10
+ readonly value: string;
11
+ /** The option's label — rendered as the row text and used as the value label. */
12
+ readonly label: string;
13
+ /** Disables selection and greys the label. */
14
+ readonly disabled?: boolean;
15
+ /** Close the dropdown when this option is pressed (default behaviour). */
16
+ readonly closeOnPress?: boolean;
17
+ /** Leading content, before the label — typically a `Select.ItemPrefix`. */
18
+ readonly prefix?: ReactNode;
19
+ /** Trailing content, after the label — typically a `Select.ItemSuffix`. */
20
+ readonly suffix?: ReactNode;
21
+ /** Overrides the default checkmark marker shown on the selected row. */
22
+ readonly indicator?: ReactNode;
23
+ }
24
+
25
+ /**
26
+ * An option row. Auto-composes the label + a default checkmark from its
27
+ * `value` / `label` / `disabled` props, and places the `prefix` / `suffix` slot
28
+ * content around the label (prefix → label → suffix → marker). `indicator`
29
+ * overrides the default checkmark; `disabled` greys the label and blocks
30
+ * selection.
31
+ */
32
+ export function SelectItem({
33
+ value,
34
+ label,
35
+ disabled,
36
+ closeOnPress,
37
+ prefix,
38
+ suffix,
39
+ indicator,
40
+ }: SelectItemProps): ReactElement {
41
+ return (
42
+ <SelectPrimitive.Item
43
+ value={value}
44
+ label={label}
45
+ disabled={disabled}
46
+ closeOnPress={closeOnPress}
47
+ >
48
+ {prefix}
49
+ <SelectPrimitive.ItemText disabled={disabled} />
50
+ {suffix}
51
+ <SelectPrimitive.ItemIndicator>
52
+ {indicator ?? <Icon name="checkmark" />}
53
+ </SelectPrimitive.ItemIndicator>
54
+ </SelectPrimitive.Item>
55
+ );
56
+ }
57
+
58
+ export interface SelectItemSlotProps {
59
+ readonly children?: ReactNode;
60
+ }
61
+
62
+ /** Styled leading slot — pass to `Select.Item`'s `prefix` prop. */
63
+ export function SelectItemPrefix({
64
+ children,
65
+ }: SelectItemSlotProps): ReactElement {
66
+ const styles = useStyles();
67
+
68
+ return <View style={styles.slot}>{children}</View>;
69
+ }
70
+
71
+ /** Styled trailing slot — pass to `Select.Item`'s `suffix` prop. */
72
+ export function SelectItemSuffix({
73
+ children,
74
+ }: SelectItemSlotProps): ReactElement {
75
+ const styles = useStyles();
76
+
77
+ return <View style={styles.slot}>{children}</View>;
78
+ }
@@ -0,0 +1,57 @@
1
+ import React, { useLayoutEffect } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { useSelectFieldState } from "./SelectFieldContext";
4
+ import { Text } from "../../Text";
5
+
6
+ export interface SelectLabelProps {
7
+ /**
8
+ * The label content. Its placement (above the trigger vs. inside it) is set by
9
+ * the `labelPlacement` prop on `Select.Root`, not by where this is written.
10
+ */
11
+ readonly children?: ReactNode;
12
+ }
13
+
14
+ /**
15
+ * The field label. Rather than render in place, it **registers** its `children`
16
+ * (and, when a string, the trigger's accessible name) into `Select.Root`, which
17
+ * renders the content in the slot `labelPlacement` selects. Renders nothing where
18
+ * it is composed. See `Select.guide.md`.
19
+ */
20
+ export function SelectLabel({ children }: SelectLabelProps): null {
21
+ const { registerLabel, unregisterLabel } = useSelectFieldState();
22
+
23
+ useLayoutEffect(() => {
24
+ registerLabel(
25
+ children,
26
+ typeof children === "string" ? children : undefined,
27
+ );
28
+
29
+ return unregisterLabel;
30
+ }, [children, registerLabel, unregisterLabel]);
31
+
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Internal renderer for the registered label content, shared by the above slot
37
+ * (`Select.Root`) and the inside slot (`Select.Trigger`). Sizes itself from
38
+ * `labelPlacement` (smaller when `inside`). Hidden from assistive tech — the
39
+ * trigger carries the accessible name.
40
+ */
41
+ export function FieldLabel(): ReactElement | null {
42
+ const { labelContent, labelPlacement } = useSelectFieldState();
43
+ if (labelContent == null) return null;
44
+
45
+ return typeof labelContent === "string" ? (
46
+ <Text
47
+ variation="subdued"
48
+ level={labelPlacement === "inside" ? "textSupporting" : undefined}
49
+ maxLines="single"
50
+ hideFromScreenReader
51
+ >
52
+ {labelContent}
53
+ </Text>
54
+ ) : (
55
+ <>{labelContent}</>
56
+ );
57
+ }
@@ -0,0 +1,29 @@
1
+ import { buildThemedStyles } from "../../AtlantisThemeContext";
2
+
3
+ export const useStyles = buildThemedStyles(tokens => ({
4
+ content: {
5
+ flex: 1,
6
+ justifyContent: "center",
7
+ },
8
+
9
+ // Inside-label placement: the Value's flex fills the column vertically and
10
+ // would otherwise pin the label flush to the top. This top inset gives the
11
+ // label the same breathing room as the shared input chrome's mini-label.
12
+ contentInside: {
13
+ paddingTop: tokens["space-small"] - tokens["space-smallest"],
14
+ },
15
+
16
+ // Static read-only surface — mirrors the trigger shape with a subtle fill, no chevron.
17
+ readOnly: {
18
+ flexDirection: "row",
19
+ alignItems: "center",
20
+ gap: tokens["space-small"],
21
+ minHeight: tokens["space-largest"] + tokens["space-small"],
22
+ paddingLeft: tokens["space-base"],
23
+ paddingRight: tokens["space-small"],
24
+ backgroundColor: tokens["color-surface--background--subtle"],
25
+ borderWidth: tokens["border-base"],
26
+ borderColor: tokens["color-border"],
27
+ borderRadius: tokens["radius-base"],
28
+ },
29
+ }));
@@ -0,0 +1,98 @@
1
+ import React from "react";
2
+ import type { ReactElement, ReactNode, Ref } from "react";
3
+ import { Keyboard, View } from "react-native";
4
+ import { type TriggerRef, useRootContext } from "@rn-primitives/select";
5
+ import { useStyles } from "./SelectTrigger.style";
6
+ import { useSelectFieldState } from "./SelectFieldContext";
7
+ import { FieldLabel } from "./SelectLabel";
8
+ import {
9
+ SELECT_TRIGGER_DEFAULT_TEST_ID,
10
+ SelectPrimitive,
11
+ } from "../../primitives/Select";
12
+ import { Icon } from "../../Icon";
13
+
14
+ export interface SelectTriggerProps {
15
+ /** Trigger content — typically `Select.Value` (and optionally a label part). */
16
+ readonly children?: ReactNode;
17
+ /** Used verbatim to locate the trigger in end-to-end tests. Defaults to `ATL-Select-Trigger`. */
18
+ readonly testID?: string;
19
+ /**
20
+ * Imperative handle exposing `open()` / `close()`. Stays `null` when the field
21
+ * is `readOnly` (no interactive trigger is mounted).
22
+ */
23
+ readonly ref?: Ref<TriggerRef>;
24
+ }
25
+
26
+ /**
27
+ * The styled field trigger: composes the chevron, the resolved `invalid` styling,
28
+ * the accessible name, the inside label placement, and the static `readOnly`
29
+ * presentation over `SelectPrimitive.Trigger`. State comes from `Select.Root`
30
+ * (field context) and the primitive root context.
31
+ */
32
+ export function SelectTrigger({
33
+ children,
34
+ testID,
35
+ ref,
36
+ }: SelectTriggerProps): ReactElement {
37
+ const styles = useStyles();
38
+ const { invalid, readOnly, accessibilityLabel, labelPlacement } =
39
+ useSelectFieldState();
40
+ const { open, value, disabled } = useRootContext();
41
+
42
+ const isDisabled = disabled ?? false;
43
+ // `disabled` wins over `readOnly`.
44
+ const isReadOnly = !isDisabled && (readOnly ?? false);
45
+
46
+ const isInsideLabel = labelPlacement === "inside";
47
+
48
+ const content = (
49
+ <View style={[styles.content, isInsideLabel && styles.contentInside]}>
50
+ {isInsideLabel && <FieldLabel />}
51
+ {children}
52
+ </View>
53
+ );
54
+
55
+ if (isReadOnly) {
56
+ return (
57
+ <View
58
+ testID={testID ?? SELECT_TRIGGER_DEFAULT_TEST_ID}
59
+ accessible={true}
60
+ accessibilityLabel={accessibilityLabel}
61
+ accessibilityValue={value?.label ? { text: value.label } : undefined}
62
+ style={styles.readOnly}
63
+ >
64
+ {content}
65
+ </View>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <SelectPrimitive.Trigger
71
+ ref={ref}
72
+ testID={testID}
73
+ invalid={invalid}
74
+ accessibilityLabel={accessibilityLabel}
75
+ // Dismiss any open keyboard so it doesn't cover the dropdown (matches the
76
+ // native picker's on-device behaviour; rn-primitives doesn't do this).
77
+ onPressIn={() => Keyboard.dismiss()}
78
+ >
79
+ {content}
80
+ <Chevron open={open} disabled={isDisabled} />
81
+ </SelectPrimitive.Trigger>
82
+ );
83
+ }
84
+
85
+ /** Trailing chevron: tinted `iconSecondary`, greyed when disabled, rotated when open. */
86
+ function Chevron({
87
+ open,
88
+ disabled,
89
+ }: {
90
+ readonly open: boolean;
91
+ readonly disabled: boolean;
92
+ }): ReactElement {
93
+ return (
94
+ <View style={{ transform: [{ rotate: open ? "180deg" : "0deg" }] }}>
95
+ <Icon name="arrowDown" color={disabled ? "disabled" : "iconSecondary"} />
96
+ </View>
97
+ );
98
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { Select } from "@jobber/components-native";
3
+ import { ProvinceItems } from "./SelectComposableProvinces";
4
+
5
+ /** Default placement: the composed `Select.Label` renders inside the trigger. */
6
+ export function SelectComposableBasic() {
7
+ return (
8
+ <Select.Root>
9
+ <Select.Label>Province</Select.Label>
10
+ <Select.Trigger>
11
+ <Select.Value placeholder="Select a province" />
12
+ </Select.Trigger>
13
+ <Select.Content>
14
+ <ProvinceItems />
15
+ </Select.Content>
16
+ </Select.Root>
17
+ );
18
+ }
@@ -0,0 +1,28 @@
1
+ import React, { useRef } from "react";
2
+ import { View } from "react-native";
3
+ import type { TriggerRef } from "@rn-primitives/select";
4
+ import { Button, Select } from "@jobber/components-native";
5
+ import { ProvinceItems } from "./SelectComposableProvinces";
6
+
7
+ /** The trigger ref exposes imperative `open()` / `close()`. */
8
+ export function SelectComposableControlledWithRef() {
9
+ const triggerRef = useRef<TriggerRef>(null);
10
+
11
+ return (
12
+ <View style={{ gap: 16 }}>
13
+ <Select.Root>
14
+ <Select.Label>Province</Select.Label>
15
+ <Select.Trigger ref={triggerRef}>
16
+ <Select.Value placeholder="Select a province" />
17
+ </Select.Trigger>
18
+ <Select.Content>
19
+ <ProvinceItems />
20
+ </Select.Content>
21
+ </Select.Root>
22
+ <Button
23
+ label="Open the dropdown"
24
+ onPress={() => triggerRef.current?.open()}
25
+ />
26
+ </View>
27
+ );
28
+ }