@jobber/components-native 0.115.0 → 0.115.2-codexjob--9905233.7
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/ProgressIndicator/ProgressIndicator.md +3 -6
- package/dist/package.json +2 -2
- package/dist/src/Chip/Chip.js +45 -8
- package/dist/src/Chip/Chip.test.js +53 -0
- package/dist/src/Select/SelectRoot.test.js +30 -1
- package/dist/src/primitives/Select/SelectPrimitive.js +10 -3
- package/dist/src/primitives/Select/SelectPrimitive.test.js +30 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/primitives/Select/SelectPrimitive.d.ts +1 -1
- package/package.json +2 -2
- package/src/Chip/Chip.test.tsx +112 -0
- package/src/Chip/Chip.tsx +80 -29
- package/src/Select/Select.composable.stories.tsx +6 -0
- package/src/Select/Select.guide.md +9 -2
- package/src/Select/SelectRoot.test.tsx +58 -1
- package/src/Select/docs/SelectComposableDependentOptions.tsx +78 -0
- package/src/Select/docs/index.ts +1 -0
- package/src/primitives/Select/SelectPrimitive.test.tsx +55 -0
- package/src/primitives/Select/SelectPrimitive.tsx +15 -2
|
@@ -2,7 +2,7 @@ import React from "react";
|
|
|
2
2
|
import type { SelectPrimitiveContentProps, SelectPrimitiveGroupProps, SelectPrimitiveItemIndicatorProps, SelectPrimitiveItemProps, SelectPrimitiveItemTextProps, SelectPrimitiveLabelProps, SelectPrimitiveOverlayProps, SelectPrimitivePortalProps, SelectPrimitiveRootProps, SelectPrimitiveSeparatorProps, SelectPrimitiveTriggerProps, SelectPrimitiveValueProps, SelectPrimitiveViewportProps } from "./types";
|
|
3
3
|
/** Default trigger testID. Consumer-supplied `testID`s are used verbatim. */
|
|
4
4
|
export declare const SELECT_TRIGGER_DEFAULT_TEST_ID = "ATL-Select-Trigger";
|
|
5
|
-
declare function SelectPrimitiveRoot(
|
|
5
|
+
declare function SelectPrimitiveRoot(props: SelectPrimitiveRootProps): React.JSX.Element;
|
|
6
6
|
/**
|
|
7
7
|
* Styled trigger field. Renders only its `children` — compose
|
|
8
8
|
* `SelectPrimitive.Value` and any trailing icon (e.g. a chevron) here; the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jobber/components-native",
|
|
3
|
-
"version": "0.115.
|
|
3
|
+
"version": "0.115.2-codexjob--9905233.7+9905233c",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "React Native implementation of Atlantis",
|
|
6
6
|
"repository": {
|
|
@@ -123,5 +123,5 @@
|
|
|
123
123
|
"react-native-screens": ">=4.18.0",
|
|
124
124
|
"react-native-svg": ">=12.0.0"
|
|
125
125
|
},
|
|
126
|
-
"gitHead": "
|
|
126
|
+
"gitHead": "9905233c18ce012134f57ba140fabba5225e18a6"
|
|
127
127
|
}
|
package/src/Chip/Chip.test.tsx
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { allColors, darkTokens } from "@jobber/design";
|
|
2
3
|
import { fireEvent, render } from "@testing-library/react-native";
|
|
4
|
+
import { Path } from "react-native-svg";
|
|
3
5
|
import { Chip } from "./Chip";
|
|
6
|
+
import type { ChipProps } from "./Chip";
|
|
7
|
+
import { AtlantisThemeContextProvider } from "../AtlantisThemeContext";
|
|
8
|
+
import type { EffectiveTheme } from "../AtlantisThemeContext";
|
|
4
9
|
import { tokens } from "../utils/design";
|
|
5
10
|
|
|
6
11
|
it("renders an active Chip", () => {
|
|
@@ -131,3 +136,110 @@ describe("accent", () => {
|
|
|
131
136
|
});
|
|
132
137
|
});
|
|
133
138
|
});
|
|
139
|
+
|
|
140
|
+
describe("selection color transitions", () => {
|
|
141
|
+
function example(theme: EffectiveTheme, props: Partial<ChipProps> = {}) {
|
|
142
|
+
return (
|
|
143
|
+
<AtlantisThemeContextProvider theme={theme}>
|
|
144
|
+
<Chip label="Object" icon="request" isActive {...props} />
|
|
145
|
+
</AtlantisThemeContextProvider>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function expectColors(
|
|
150
|
+
view: ReturnType<typeof render>,
|
|
151
|
+
background: string,
|
|
152
|
+
text: string,
|
|
153
|
+
icon = text,
|
|
154
|
+
) {
|
|
155
|
+
expect(view.getByTestId("chipTest")).toHaveStyle({
|
|
156
|
+
backgroundColor: background,
|
|
157
|
+
});
|
|
158
|
+
expect(view.getByText("Object")).toHaveStyle({ color: text });
|
|
159
|
+
const paths = view.UNSAFE_root.findAllByType(Path);
|
|
160
|
+
expect(paths.length).toBeGreaterThan(0);
|
|
161
|
+
|
|
162
|
+
for (const path of paths) {
|
|
163
|
+
expect(path.props.fill).toBe(icon);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it.each(["request", "quote", "job", "invoice"] as const)(
|
|
168
|
+
"keeps the selected %s fill and white content when the theme changes",
|
|
169
|
+
accent => {
|
|
170
|
+
expect.hasAssertions();
|
|
171
|
+
const props = { accent, icon: accent };
|
|
172
|
+
const view = render(example("light", props));
|
|
173
|
+
|
|
174
|
+
for (const theme of ["light", "dark", "light"] as const) {
|
|
175
|
+
view.rerender(example(theme, props));
|
|
176
|
+
expectColors(
|
|
177
|
+
view,
|
|
178
|
+
allColors[`color-${accent}`],
|
|
179
|
+
allColors["color-text--reverse"],
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
it("changes the selected client fill and foreground together with the theme", () => {
|
|
186
|
+
expect.hasAssertions();
|
|
187
|
+
const props = { accent: "client", icon: "clients" } as const;
|
|
188
|
+
const view = render(example("light", props));
|
|
189
|
+
expectColors(
|
|
190
|
+
view,
|
|
191
|
+
allColors["color-client"],
|
|
192
|
+
allColors["color-text--reverse"],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
view.rerender(example("dark", props));
|
|
196
|
+
expectColors(view, darkTokens["color-client"], allColors["color-text"]);
|
|
197
|
+
|
|
198
|
+
view.rerender(example("light", props));
|
|
199
|
+
expectColors(
|
|
200
|
+
view,
|
|
201
|
+
allColors["color-client"],
|
|
202
|
+
allColors["color-text--reverse"],
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it.each(["request", "client"] as const)(
|
|
207
|
+
"restores the theme's normal colors when %s is deselected",
|
|
208
|
+
accent => {
|
|
209
|
+
const view = render(example("dark", { accent }));
|
|
210
|
+
|
|
211
|
+
view.rerender(example("dark", { accent, isActive: false }));
|
|
212
|
+
|
|
213
|
+
expectColors(
|
|
214
|
+
view,
|
|
215
|
+
darkTokens["color-interactive--background"],
|
|
216
|
+
darkTokens["color-text"],
|
|
217
|
+
darkTokens[`color-${accent}`],
|
|
218
|
+
);
|
|
219
|
+
expect(
|
|
220
|
+
view.getByTestId("chipTest").props.accessibilityState.checked,
|
|
221
|
+
).toBe(false);
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
it("preserves default selected colors when an accent is removed and the theme changes", () => {
|
|
226
|
+
expect.hasAssertions();
|
|
227
|
+
const view = render(example("dark", { accent: "request" }));
|
|
228
|
+
|
|
229
|
+
view.rerender(example("dark"));
|
|
230
|
+
expectColors(
|
|
231
|
+
view,
|
|
232
|
+
darkTokens["color-surface--reverse"],
|
|
233
|
+
darkTokens["color-text--reverse"],
|
|
234
|
+
darkTokens["color-surface"],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
view.rerender(example("light"));
|
|
238
|
+
expectColors(
|
|
239
|
+
view,
|
|
240
|
+
allColors["color-surface--reverse"],
|
|
241
|
+
allColors["color-text--reverse"],
|
|
242
|
+
allColors["color-surface"],
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/Chip/Chip.tsx
CHANGED
|
@@ -2,9 +2,11 @@ import React, { useMemo } from "react";
|
|
|
2
2
|
import type { AccessibilityRole } from "react-native";
|
|
3
3
|
import { Pressable, View } from "react-native";
|
|
4
4
|
import type { IconNames } from "@jobber/design";
|
|
5
|
+
import { allColors } from "@jobber/design";
|
|
5
6
|
import { useStyles } from "./Chip.style";
|
|
6
7
|
import { Icon } from "../Icon";
|
|
7
8
|
import { Typography } from "../Typography";
|
|
9
|
+
import type { TypographyProps } from "../Typography";
|
|
8
10
|
import type { AtlantisThemeContextValue } from "../AtlantisThemeContext";
|
|
9
11
|
import { useAtlantisTheme } from "../AtlantisThemeContext";
|
|
10
12
|
|
|
@@ -71,34 +73,58 @@ export function Chip({
|
|
|
71
73
|
accent,
|
|
72
74
|
}: ChipProps) {
|
|
73
75
|
const styles = useStyles();
|
|
74
|
-
const { tokens } = useAtlantisTheme();
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
76
|
+
const { tokens, effectiveTheme } = useAtlantisTheme();
|
|
77
|
+
const { chipStyle, iconCustomColor, labelStyle, dismissColor } =
|
|
78
|
+
useMemo(() => {
|
|
79
|
+
const accentColor = accent
|
|
80
|
+
? tokens[`color-${accent}`]
|
|
81
|
+
: tokens["color-surface--reverse"];
|
|
82
|
+
let backgroundColor = accentColor;
|
|
83
|
+
let iconColor = isActive ? tokens["color-surface"] : accentColor;
|
|
84
|
+
let selectedLabelStyle: TypographyProps<"base">["UNSAFE_style"];
|
|
85
|
+
|
|
86
|
+
if (isActive && accent) {
|
|
87
|
+
const selected = getSelectedAccentColors(accent, {
|
|
88
|
+
tokens,
|
|
89
|
+
effectiveTheme,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
backgroundColor = selected.background;
|
|
93
|
+
iconColor = selected.foreground;
|
|
94
|
+
selectedLabelStyle = {
|
|
95
|
+
textStyle: { color: selected.foreground },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const chip = [
|
|
100
|
+
styles.container,
|
|
101
|
+
{
|
|
102
|
+
backgroundColor:
|
|
103
|
+
inactiveBackgroundColor === "surface"
|
|
104
|
+
? tokens["color-surface"]
|
|
105
|
+
: tokens["color-interactive--background"],
|
|
106
|
+
},
|
|
107
|
+
isActive && { backgroundColor },
|
|
108
|
+
getBorderStyle(inactiveBackgroundColor, tokens),
|
|
109
|
+
];
|
|
110
|
+
const dismiss =
|
|
111
|
+
(isActive || inactiveBackgroundColor === "surface") &&
|
|
112
|
+
styles.activeDismissIcon;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
chipStyle: chip,
|
|
116
|
+
iconCustomColor: iconColor,
|
|
117
|
+
labelStyle: selectedLabelStyle,
|
|
118
|
+
dismissColor: dismiss,
|
|
119
|
+
};
|
|
120
|
+
}, [
|
|
121
|
+
accent,
|
|
122
|
+
isActive,
|
|
123
|
+
inactiveBackgroundColor,
|
|
124
|
+
styles,
|
|
125
|
+
tokens,
|
|
126
|
+
effectiveTheme,
|
|
127
|
+
]);
|
|
102
128
|
|
|
103
129
|
const accessibilityState = useMemo(() => {
|
|
104
130
|
const checkableRoles = ["radio", "switch", "togglebutton", "checkbox"];
|
|
@@ -133,7 +159,8 @@ export function Chip({
|
|
|
133
159
|
fontWeight="medium"
|
|
134
160
|
maxFontScaleSize={tokens["typography--fontSize-large"]}
|
|
135
161
|
maxLines="single"
|
|
136
|
-
reverseTheme={isActive}
|
|
162
|
+
reverseTheme={isActive && !accent}
|
|
163
|
+
UNSAFE_style={labelStyle}
|
|
137
164
|
>
|
|
138
165
|
{label}
|
|
139
166
|
</Typography>
|
|
@@ -148,6 +175,30 @@ export function Chip({
|
|
|
148
175
|
);
|
|
149
176
|
}
|
|
150
177
|
|
|
178
|
+
// Object labels and leading icons share the selected foreground.
|
|
179
|
+
function getSelectedAccentColors(
|
|
180
|
+
accent: AccentType,
|
|
181
|
+
{
|
|
182
|
+
tokens,
|
|
183
|
+
effectiveTheme,
|
|
184
|
+
}: Pick<AtlantisThemeContextValue, "tokens" | "effectiveTheme">,
|
|
185
|
+
) {
|
|
186
|
+
const colors = {
|
|
187
|
+
background: allColors[`color-${accent}`],
|
|
188
|
+
foreground: allColors["color-text--reverse"],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (accent === "client") {
|
|
192
|
+
colors.background = tokens["color-client"];
|
|
193
|
+
|
|
194
|
+
if (effectiveTheme === "dark") {
|
|
195
|
+
colors.foreground = allColors["color-text"];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return colors;
|
|
200
|
+
}
|
|
201
|
+
|
|
151
202
|
function getBorderStyle(
|
|
152
203
|
inactiveBackgroundColor: "surface" | "background",
|
|
153
204
|
tokens: AtlantisThemeContextValue["tokens"],
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SelectComposableBasic,
|
|
9
9
|
SelectComposableControlledWithRef,
|
|
10
10
|
SelectComposableCustomMarker,
|
|
11
|
+
SelectComposableDependentOptions,
|
|
11
12
|
SelectComposableDescription,
|
|
12
13
|
SelectComposableDisabled,
|
|
13
14
|
SelectComposableDisabledOptions,
|
|
@@ -86,6 +87,11 @@ export const ControlledWithRef: Story = {
|
|
|
86
87
|
render: SelectComposableControlledWithRef,
|
|
87
88
|
};
|
|
88
89
|
|
|
90
|
+
/** Changing a parent selection clears a now-invalid controlled child value. */
|
|
91
|
+
export const ControlledValueReset: Story = {
|
|
92
|
+
render: SelectComposableDependentOptions,
|
|
93
|
+
};
|
|
94
|
+
|
|
89
95
|
/** Read-only: non-interactive, value rendered as text with no chevron. */
|
|
90
96
|
export const ReadOnly: Story = { render: SelectComposableReadOnly };
|
|
91
97
|
|
|
@@ -76,12 +76,19 @@ takes `value` / `defaultValue` and `onValueChange((option) => void)`. There is
|
|
|
76
76
|
no multi-select, numeric value, or computed display value — `Select.Value` shows
|
|
77
77
|
the selected option's label.
|
|
78
78
|
|
|
79
|
+
Omitting `value` uses uncontrolled state. Explicitly passing `value={undefined}`
|
|
80
|
+
is a controlled empty value and shows the placeholder. `SelectPrimitive.Root`
|
|
81
|
+
normalizes that distinction before forwarding to `@rn-primitives/select`, which
|
|
82
|
+
otherwise treats `undefined` as uncontrolled and can reveal a stale selection
|
|
83
|
+
after a controlled value is cleared.
|
|
84
|
+
|
|
79
85
|
## Composition model
|
|
80
86
|
|
|
81
87
|
A few decisions shape how the composable parts fit together:
|
|
82
88
|
|
|
83
|
-
- **The primitive
|
|
84
|
-
`SelectPrimitive` (`@jobber/components-native/primitives`)
|
|
89
|
+
- **The primitive owns upstream state interop; the sugar adds field behavior.**
|
|
90
|
+
`SelectPrimitive` (`@jobber/components-native/primitives`) normalizes an
|
|
91
|
+
explicitly empty controlled value and otherwise remains a styling-focused
|
|
85
92
|
passthrough with no field chrome, field state, chevron, read-only, or row
|
|
86
93
|
composition. Those live in the `Select.*` sugar: `Select.Root` owns the
|
|
87
94
|
field-state context, `Select.Trigger` composes the chevron / read-only /
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
within,
|
|
7
|
+
} from "@testing-library/react-native";
|
|
8
|
+
import { Pressable } from "react-native";
|
|
3
9
|
import type { TriggerRef } from "@rn-primitives/select";
|
|
4
10
|
import { SelectRoot } from "./SelectRoot";
|
|
5
11
|
import { SelectTrigger } from "./components/SelectTrigger";
|
|
@@ -20,6 +26,57 @@ function renderRoot(props: Partial<SelectRootProps> = {}) {
|
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
describe("SelectRoot chrome", () => {
|
|
29
|
+
it("shows the placeholder when a controlled value is cleared to undefined", () => {
|
|
30
|
+
function ControlledSelect() {
|
|
31
|
+
const [value, setValue] = React.useState<
|
|
32
|
+
{ value: string; label: string } | undefined
|
|
33
|
+
>();
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<SelectRoot value={value} onValueChange={setValue}>
|
|
38
|
+
<SelectTrigger>
|
|
39
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
40
|
+
</SelectTrigger>
|
|
41
|
+
<SelectPrimitive.Item
|
|
42
|
+
testID="toronto-option"
|
|
43
|
+
value="tor"
|
|
44
|
+
label="Toronto"
|
|
45
|
+
/>
|
|
46
|
+
</SelectRoot>
|
|
47
|
+
<Pressable testID="clear-value" onPress={() => setValue(undefined)} />
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
render(<ControlledSelect />);
|
|
53
|
+
fireEvent.press(screen.getByTestId("toronto-option"));
|
|
54
|
+
expect(screen.getByText("Toronto")).toBeDefined();
|
|
55
|
+
|
|
56
|
+
fireEvent.press(screen.getByTestId("clear-value"));
|
|
57
|
+
|
|
58
|
+
expect(screen.queryByText("Toronto")).toBeNull();
|
|
59
|
+
expect(screen.getByText("Select an option")).toHaveStyle({
|
|
60
|
+
color: tokens["color-text--secondary"],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("preserves an uncontrolled default value across rerenders", () => {
|
|
65
|
+
const { rerender } = renderRoot({
|
|
66
|
+
defaultValue: { value: "tor", label: "Toronto" },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
rerender(
|
|
70
|
+
<SelectRoot defaultValue={{ value: "tor", label: "Toronto" }}>
|
|
71
|
+
<SelectTrigger>
|
|
72
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
73
|
+
</SelectTrigger>
|
|
74
|
+
</SelectRoot>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText("Toronto")).toBeDefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
23
80
|
it("renders a composed label (visible, hidden from assistive tech)", () => {
|
|
24
81
|
render(
|
|
25
82
|
<SelectRoot>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Select, Text } from "@jobber/components-native";
|
|
4
|
+
|
|
5
|
+
interface Option {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const UNITED_STATES = { value: "us", label: "United States" };
|
|
11
|
+
const COUNTRIES = [UNITED_STATES, { value: "ca", label: "Canada" }];
|
|
12
|
+
const REGIONS: Record<string, Option[]> = {
|
|
13
|
+
us: [
|
|
14
|
+
{ value: "california", label: "California" },
|
|
15
|
+
{ value: "new-york", label: "New York" },
|
|
16
|
+
],
|
|
17
|
+
ca: [
|
|
18
|
+
{ value: "alberta", label: "Alberta" },
|
|
19
|
+
{ value: "ontario", label: "Ontario" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Dependent options: changing country clears the controlled region value. */
|
|
24
|
+
export function SelectComposableDependentOptions() {
|
|
25
|
+
const [country, setCountry] = useState<Option | undefined>(UNITED_STATES);
|
|
26
|
+
const [region, setRegion] = useState<Option | undefined>();
|
|
27
|
+
const regions = country ? REGIONS[country.value] : [];
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<View style={{ gap: 16 }}>
|
|
31
|
+
<Text variation="subdued">
|
|
32
|
+
Select California, then change the country to Canada. State/Province
|
|
33
|
+
should return to its placeholder.
|
|
34
|
+
</Text>
|
|
35
|
+
|
|
36
|
+
<Select.Root
|
|
37
|
+
value={country}
|
|
38
|
+
onValueChange={option => {
|
|
39
|
+
setCountry(option as Option | undefined);
|
|
40
|
+
setRegion(undefined);
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<Select.Label>Country</Select.Label>
|
|
44
|
+
<Select.Trigger>
|
|
45
|
+
<Select.Value placeholder="Select a country" />
|
|
46
|
+
</Select.Trigger>
|
|
47
|
+
<Select.Content>
|
|
48
|
+
{COUNTRIES.map(option => (
|
|
49
|
+
<Select.Item
|
|
50
|
+
key={option.value}
|
|
51
|
+
value={option.value}
|
|
52
|
+
label={option.label}
|
|
53
|
+
/>
|
|
54
|
+
))}
|
|
55
|
+
</Select.Content>
|
|
56
|
+
</Select.Root>
|
|
57
|
+
|
|
58
|
+
<Select.Root
|
|
59
|
+
value={region}
|
|
60
|
+
onValueChange={option => setRegion(option as Option | undefined)}
|
|
61
|
+
>
|
|
62
|
+
<Select.Label>State/Province</Select.Label>
|
|
63
|
+
<Select.Trigger>
|
|
64
|
+
<Select.Value placeholder="Select a state/province" />
|
|
65
|
+
</Select.Trigger>
|
|
66
|
+
<Select.Content>
|
|
67
|
+
{regions.map(option => (
|
|
68
|
+
<Select.Item
|
|
69
|
+
key={option.value}
|
|
70
|
+
value={option.value}
|
|
71
|
+
label={option.label}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</Select.Content>
|
|
75
|
+
</Select.Root>
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
package/src/Select/docs/index.ts
CHANGED
|
@@ -12,5 +12,6 @@ export { SelectComposableItemPrefixSuffix } from "./SelectComposableItemPrefixSu
|
|
|
12
12
|
export { SelectComposableCustomMarker } from "./SelectComposableCustomMarker";
|
|
13
13
|
export { SelectComposableDisabledOptions } from "./SelectComposableDisabledOptions";
|
|
14
14
|
export { SelectComposableControlledWithRef } from "./SelectComposableControlledWithRef";
|
|
15
|
+
export { SelectComposableDependentOptions } from "./SelectComposableDependentOptions";
|
|
15
16
|
export { SelectComposableReadOnly } from "./SelectComposableReadOnly";
|
|
16
17
|
export { SelectComposableDisabled } from "./SelectComposableDisabled";
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
userEvent,
|
|
6
6
|
within,
|
|
7
7
|
} from "@testing-library/react-native";
|
|
8
|
+
import { Pressable } from "react-native";
|
|
8
9
|
import { darkTokens } from "@jobber/design";
|
|
9
10
|
import type { TriggerRef } from "@rn-primitives/select";
|
|
10
11
|
import { PortalHost } from "@rn-primitives/portal";
|
|
@@ -67,6 +68,60 @@ function renderSelect({
|
|
|
67
68
|
);
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
describe("SelectPrimitive.Root", () => {
|
|
72
|
+
it("does not select an empty-string item when a controlled value is cleared", async () => {
|
|
73
|
+
function ControlledSelect() {
|
|
74
|
+
const [value, setValue] = React.useState<
|
|
75
|
+
{ value: string; label: string } | undefined
|
|
76
|
+
>({ value: "tor", label: "Toronto" });
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
<SelectPrimitive.Root value={value} onValueChange={setValue}>
|
|
81
|
+
<SelectPrimitive.Trigger>
|
|
82
|
+
<SelectPrimitive.Value placeholder="Select an option" />
|
|
83
|
+
</SelectPrimitive.Trigger>
|
|
84
|
+
<SelectPrimitive.Portal>
|
|
85
|
+
<SelectPrimitive.Overlay />
|
|
86
|
+
<SelectPrimitive.Content>
|
|
87
|
+
<SelectPrimitive.Viewport>
|
|
88
|
+
<SelectPrimitive.Item
|
|
89
|
+
testID="empty-option"
|
|
90
|
+
value=""
|
|
91
|
+
label="Empty"
|
|
92
|
+
>
|
|
93
|
+
<SelectPrimitive.ItemText />
|
|
94
|
+
<SelectPrimitive.ItemIndicator>
|
|
95
|
+
<Icon name="checkmark" />
|
|
96
|
+
</SelectPrimitive.ItemIndicator>
|
|
97
|
+
</SelectPrimitive.Item>
|
|
98
|
+
<SelectPrimitive.Item value="tor" label="Toronto">
|
|
99
|
+
<SelectPrimitive.ItemText />
|
|
100
|
+
<SelectPrimitive.ItemIndicator>
|
|
101
|
+
<Icon name="checkmark" />
|
|
102
|
+
</SelectPrimitive.ItemIndicator>
|
|
103
|
+
</SelectPrimitive.Item>
|
|
104
|
+
</SelectPrimitive.Viewport>
|
|
105
|
+
</SelectPrimitive.Content>
|
|
106
|
+
</SelectPrimitive.Portal>
|
|
107
|
+
</SelectPrimitive.Root>
|
|
108
|
+
<Pressable testID="clear-value" onPress={() => setValue(undefined)} />
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
render(<ControlledSelect />, { wrapper: PortalHostWrapper });
|
|
114
|
+
|
|
115
|
+
await user.press(screen.getByTestId("clear-value"));
|
|
116
|
+
await openSelect();
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
screen.getByTestId("empty-option").props.accessibilityState,
|
|
120
|
+
).toMatchObject({ checked: false });
|
|
121
|
+
expect(screen.queryByTestId("checkmark")).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
70
125
|
describe("SelectPrimitive.Trigger", () => {
|
|
71
126
|
it("renders with the default testID", () => {
|
|
72
127
|
renderSelect();
|
|
@@ -31,8 +31,21 @@ function useIsDarkTheme() {
|
|
|
31
31
|
return useAtlantisTheme().effectiveTheme === "dark";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// @rn-primitives/select treats `undefined` as uncontrolled. Keep an explicitly
|
|
35
|
+
// controlled empty value defined so clearing the prop cannot reveal stale
|
|
36
|
+
// internal state. Its undefined fields cannot match a public string item value.
|
|
37
|
+
const EMPTY_CONTROLLED_VALUE = {} as NonNullable<
|
|
38
|
+
SelectPrimitiveRootProps["value"]
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
function SelectPrimitiveRoot(props: SelectPrimitiveRootProps) {
|
|
42
|
+
const isControlled = Object.prototype.hasOwnProperty.call(props, "value");
|
|
43
|
+
const value =
|
|
44
|
+
isControlled && props.value === undefined
|
|
45
|
+
? EMPTY_CONTROLLED_VALUE
|
|
46
|
+
: props.value;
|
|
47
|
+
|
|
48
|
+
return <Primitive.Root {...props} value={value} />;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
/**
|