@jobber/components-native 0.115.0 → 0.115.1

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.
@@ -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({ ...props }: SelectPrimitiveRootProps): React.JSX.Element;
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.0",
3
+ "version": "0.115.1",
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": "9f20f1b3aa82b280fb9f24b5fd98be3da946c130"
126
+ "gitHead": "df5ca5cd691c56be40fad67fae7ea644724656cc"
127
127
  }
@@ -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 is dumb; the sugar adds the Atlantis behavior.**
84
- `SelectPrimitive` (`@jobber/components-native/primitives`) is a styling-only
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 { render, screen, within } from "@testing-library/react-native";
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
+ }
@@ -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
- function SelectPrimitiveRoot({ ...props }: SelectPrimitiveRootProps) {
35
- return <Primitive.Root {...props} />;
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
  /**