@jobber/components-native 0.113.4-implement--94d34f8.2 → 0.113.4-implement--97f86be.3

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.
@@ -32,7 +32,9 @@ export interface AtlantisThemeContextProviderProps {
32
32
  readonly onThemeChange?: (theme: Theme) => void;
33
33
  /**
34
34
  * Force the theme for this provider to always be the same as the provided theme. Useful for sections that should remain the same theme regardless of the rest of the application's theme.
35
- * This is dangerous because the children in this provider will not be able to change the theme.
35
+ * This is dangerous because it forces the subtree's rendered tokens while preserving the selected
36
+ * theme semantics from the nearest non-forced provider. Calls to setTheme inside the forced subtree
37
+ * still update the selected theme upstream.
36
38
  */
37
39
  readonly dangerouslyOverrideTheme?: EffectiveTheme;
38
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jobber/components-native",
3
- "version": "0.113.4-implement--94d34f8.2+94d34f81",
3
+ "version": "0.113.4-implement--97f86be.3+97f86be1",
4
4
  "license": "MIT",
5
5
  "description": "React Native implementation of Atlantis",
6
6
  "repository": {
@@ -124,5 +124,5 @@
124
124
  "react-native-screens": ">=4.18.0",
125
125
  "react-native-svg": ">=12.0.0"
126
126
  },
127
- "gitHead": "94d34f81c5579bf7aec109cd129a94ea91aadf56"
127
+ "gitHead": "97f86be1230044c71581dea90ac836ab6a65f580"
128
128
  }
@@ -0,0 +1,53 @@
1
+ # AtlantisThemeContext
2
+
3
+ ## What this owns
4
+
5
+ `AtlantisThemeContext` is the native Atlantis boundary for theme semantics and
6
+ token resolution.
7
+
8
+ It owns:
9
+
10
+ - the selected theme (`"system" | "light" | "dark"`)
11
+ - the effective theme used to resolve tokens (`"light" | "dark"`)
12
+ - mapping the selected theme to the current platform token set
13
+ - subtree overrides for Atlantis-owned surfaces that need fixed visual tokens
14
+
15
+ It does **not** own:
16
+
17
+ - preference persistence
18
+ - settings UI
19
+ - product rollout or split configuration
20
+
21
+ Those concerns belong to the consuming app.
22
+
23
+ ## Selected theme vs effective theme
24
+
25
+ The `theme` value represents the user's selected preference. It may be
26
+ `"system"`, which means "follow the OS setting".
27
+
28
+ The `effectiveTheme` value is the resolved runtime theme Atlantis uses to choose
29
+ tokens. This is always `"light"` or `"dark"`.
30
+
31
+ Consumers that need to persist or display the user's choice should use `theme`.
32
+ Consumers that need to style non-Atlantis surfaces should usually use
33
+ `effectiveTheme`.
34
+
35
+ ## Controlled vs uncontrolled usage
36
+
37
+ `AtlantisThemeContextProvider` supports both controlled and uncontrolled usage.
38
+
39
+ - Use `theme` and `onThemeChange` when the app owns the selected theme state.
40
+ - Omit `theme` when the provider should manage theme locally.
41
+
42
+ App-level providers should generally be controlled so the selected preference
43
+ can be stored outside Atlantis.
44
+
45
+ ## Forced theme subtrees
46
+
47
+ `dangerouslyOverrideTheme` forces the subtree to render with a specific
48
+ effective theme. This is useful for portals, overlays, or any Atlantis-owned
49
+ surface that visually leaves the normal React subtree.
50
+
51
+ Important: a forced subtree preserves the selected theme semantics from the
52
+ nearest non-forced provider. Calling `setTheme` inside the forced subtree still
53
+ updates the upstream selected theme; only the rendered tokens stay forced.
@@ -166,18 +166,55 @@ describe("ThemeContext", () => {
166
166
  });
167
167
 
168
168
  describe("when theme is forced for provider", () => {
169
- it("ignores updates to the global theme", async () => {
169
+ it("preserves the forced effective theme when the selected theme changes", async () => {
170
170
  const { result } = renderHook(useAtlantisTheme, {
171
171
  wrapper: (props: AtlantisThemeContextProviderProps) => (
172
172
  <WrapperWithOverride {...props} dangerouslyOverrideTheme="light" />
173
173
  ),
174
174
  });
175
175
 
176
- // Update the global theme
176
+ // Update the selected theme through the forced subtree.
177
177
  await act(async () => result.current.setTheme("dark"));
178
178
 
179
- // This hook shouldn't be affected by it because it's set to the light theme
180
- expect(result.current.theme).toBe("light");
179
+ expect(result.current.theme).toBe("dark");
180
+ expect(result.current.effectiveTheme).toBe("light");
181
+ expect(result.current.tokens).toEqual(expectedLightTokens);
182
+ });
183
+
184
+ it("preserves the parent selected theme while forcing the effective theme", () => {
185
+ mockUseColorScheme.mockReturnValue("dark");
186
+
187
+ const { result } = renderHook(useAtlantisTheme, {
188
+ wrapper: (props: AtlantisThemeContextProviderProps) => (
189
+ <Wrapper theme="system">
190
+ <AtlantisThemeContextProvider
191
+ dangerouslyOverrideTheme="light"
192
+ {...props}
193
+ />
194
+ </Wrapper>
195
+ ),
196
+ });
197
+
198
+ expect(result.current.theme).toBe("system");
199
+ expect(result.current.effectiveTheme).toBe("light");
200
+ expect(result.current.tokens).toEqual(expectedLightTokens);
201
+ });
202
+
203
+ it("forwards selected theme changes through a forced subtree", async () => {
204
+ const { result } = renderHook(useAtlantisTheme, {
205
+ wrapper: (props: AtlantisThemeContextProviderProps) => (
206
+ <Wrapper>
207
+ <AtlantisThemeContextProvider
208
+ dangerouslyOverrideTheme="light"
209
+ {...props}
210
+ />
211
+ </Wrapper>
212
+ ),
213
+ });
214
+
215
+ await act(async () => result.current.setTheme("dark"));
216
+
217
+ expect(result.current.theme).toBe("dark");
181
218
  expect(result.current.effectiveTheme).toBe("light");
182
219
  expect(result.current.tokens).toEqual(expectedLightTokens);
183
220
  });
@@ -219,7 +256,7 @@ describe("ThemeContext", () => {
219
256
  ),
220
257
  });
221
258
 
222
- expect(result.current.theme).toBe(defaultTheme);
259
+ expect(result.current.theme).toBe("light");
223
260
  expect(result.current.effectiveTheme).toBe(expectedEffectiveTheme);
224
261
  expect(result.current.tokens).toEqual(expectedTokens);
225
262
  },
@@ -17,7 +17,7 @@ const lightTokens = Platform.select({
17
17
 
18
18
  const completeDarkTokens = merge({}, lightTokens, darkTokens);
19
19
 
20
- const AtlantisThemeContext = createContext<AtlantisThemeContextValue>({
20
+ const defaultAtlantisThemeContextValue: AtlantisThemeContextValue = {
21
21
  theme: "light",
22
22
  effectiveTheme: "light",
23
23
  tokens: lightTokens,
@@ -26,7 +26,11 @@ const AtlantisThemeContext = createContext<AtlantisThemeContextValue>({
26
26
  "useAtlantisTheme accessed outside of AtlantisThemeContextProvider",
27
27
  );
28
28
  },
29
- });
29
+ };
30
+
31
+ const AtlantisThemeContext = createContext<
32
+ AtlantisThemeContextValue | undefined
33
+ >(undefined);
30
34
 
31
35
  export function AtlantisThemeContextProvider({
32
36
  children,
@@ -34,22 +38,34 @@ export function AtlantisThemeContextProvider({
34
38
  onThemeChange,
35
39
  dangerouslyOverrideTheme,
36
40
  }: AtlantisThemeContextProviderProps) {
37
- const [uncontrolledTheme, setUncontrolledTheme] = useState<Theme>("light");
41
+ const parentThemeContext = useContext(AtlantisThemeContext);
42
+ const [localTheme, setLocalTheme] = useState<Theme | undefined>(undefined);
38
43
  const colorScheme = useColorScheme();
39
44
 
40
45
  const setTheme = useCallback(
41
46
  (nextTheme: Theme) => {
42
47
  onThemeChange?.(nextTheme);
43
48
 
44
- if (theme === undefined) {
45
- setUncontrolledTheme(nextTheme);
49
+ if (theme !== undefined) return;
50
+
51
+ // A forced visual override should preserve the parent subtree's selected
52
+ // theme semantics while still rendering with the forced tokens.
53
+ if (dangerouslyOverrideTheme !== undefined && parentThemeContext) {
54
+ parentThemeContext.setTheme(nextTheme);
55
+
56
+ return;
46
57
  }
58
+
59
+ setLocalTheme(nextTheme);
47
60
  },
48
- [onThemeChange, theme],
61
+ [dangerouslyOverrideTheme, onThemeChange, parentThemeContext, theme],
49
62
  );
50
63
 
51
- const currentTheme = dangerouslyOverrideTheme ?? theme ?? uncontrolledTheme;
52
- const effectiveTheme = resolveEffectiveTheme(currentTheme, colorScheme);
64
+ const currentTheme =
65
+ theme ?? localTheme ?? parentThemeContext?.theme ?? "light";
66
+ const effectiveTheme =
67
+ dangerouslyOverrideTheme ??
68
+ resolveEffectiveTheme(currentTheme, colorScheme);
53
69
  const currentTokens =
54
70
  effectiveTheme === "dark" ? completeDarkTokens : lightTokens;
55
71
 
@@ -68,7 +84,7 @@ export function AtlantisThemeContextProvider({
68
84
  }
69
85
 
70
86
  export function useAtlantisTheme() {
71
- return useContext(AtlantisThemeContext);
87
+ return useContext(AtlantisThemeContext) ?? defaultAtlantisThemeContextValue;
72
88
  }
73
89
 
74
90
  function resolveEffectiveTheme(
@@ -24,6 +24,10 @@ function ChildrenComponent({
24
24
  <Text>{`Effective theme: ${effectiveTheme}`}</Text>
25
25
  <Text>Tokens can be accessed using tokens[token-name]</Text>
26
26
  <Text>{`For example color-surface: ${tokens["color-surface"]}`}</Text>
27
+ <Text>
28
+ Use the selected theme for preference UI and the effective theme for
29
+ non-Atlantis surfaces.
30
+ </Text>
27
31
  <Button label="Set system theme" onPress={() => setTheme("system")} />
28
32
  <Button label="Set dark theme" onPress={() => setTheme("dark")} />
29
33
  <Button label="Set light theme" onPress={() => setTheme("light")} />
@@ -33,12 +33,14 @@ function ChildrenComponent({
33
33
  }
34
34
 
35
35
  function ForcedDarkThemeComponent() {
36
- const { tokens } = useAtlantisTheme();
36
+ const { effectiveTheme, theme, tokens } = useAtlantisTheme();
37
37
 
38
38
  return (
39
39
  <View style={{ backgroundColor: tokens["color-surface"] }}>
40
40
  <Content>
41
41
  <Text>This will always be a dark theme</Text>
42
+ <Text>{`Selected theme: ${theme}`}</Text>
43
+ <Text>{`Effective theme: ${effectiveTheme}`}</Text>
42
44
  </Content>
43
45
  </View>
44
46
  );
@@ -49,7 +51,7 @@ export function AtlantisThemeContextForceThemeForProviderExample(
49
51
  ) {
50
52
  return (
51
53
  <AtlantisThemeContextProvider {...props}>
52
- <ChildrenComponent message="It is possible to have a provider ignore Theme Changes" />
54
+ <ChildrenComponent message="It is possible to force a subtree's rendered theme while preserving the selected theme semantics." />
53
55
  <AtlantisThemeContextProvider dangerouslyOverrideTheme="dark">
54
56
  <ForcedDarkThemeComponent />
55
57
  </AtlantisThemeContextProvider>
@@ -40,7 +40,9 @@ export interface AtlantisThemeContextProviderProps {
40
40
 
41
41
  /**
42
42
  * Force the theme for this provider to always be the same as the provided theme. Useful for sections that should remain the same theme regardless of the rest of the application's theme.
43
- * This is dangerous because the children in this provider will not be able to change the theme.
43
+ * This is dangerous because it forces the subtree's rendered tokens while preserving the selected
44
+ * theme semantics from the nearest non-forced provider. Calls to setTheme inside the forced subtree
45
+ * still update the selected theme upstream.
44
46
  */
45
47
  readonly dangerouslyOverrideTheme?: EffectiveTheme;
46
48
  }