@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.
- package/dist/docs/Select/Select.md +987 -0
- package/dist/docs/index.md +1 -0
- package/dist/package.json +2 -2
- 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 +2 -2
- 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,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,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
|
+
}
|