@jobber/components-native 0.113.7 → 0.114.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/AtlantisThemeContext/AtlantisThemeContext.md +27 -10
- package/dist/docs/Icon/Icon.md +17 -0
- package/dist/package.json +3 -3
- package/dist/src/AtlantisThemeContext/AtlantisThemeContext.js +35 -12
- package/dist/src/AtlantisThemeContext/AtlantisThemeContext.test.js +103 -10
- package/dist/src/BottomSheet/BottomSheet.js +11 -7
- package/dist/src/BottomSheet/BottomSheet.test.js +16 -0
- package/dist/src/ContentOverlay/ContentOverlay.js +2 -2
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/AtlantisThemeContext/AtlantisThemeContext.d.ts +1 -1
- package/dist/types/src/AtlantisThemeContext/index.d.ts +1 -1
- package/dist/types/src/AtlantisThemeContext/types.d.ts +19 -4
- package/package.json +3 -3
- package/src/AtlantisThemeContext/AtlantisThemeContext.guide.md +53 -0
- package/src/AtlantisThemeContext/AtlantisThemeContext.test.tsx +169 -10
- package/src/AtlantisThemeContext/AtlantisThemeContext.tsx +52 -11
- package/src/AtlantisThemeContext/docs/AtlantisThemeContextBasicExample.tsx +8 -2
- package/src/AtlantisThemeContext/docs/AtlantisThemeContextForceThemeForProviderExample.tsx +8 -4
- package/src/AtlantisThemeContext/index.ts +1 -0
- package/src/AtlantisThemeContext/types.ts +23 -4
- package/src/BottomSheet/BottomSheet.test.tsx +30 -0
- package/src/BottomSheet/BottomSheet.tsx +26 -12
- package/src/ContentOverlay/ContentOverlay.tsx +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { AtlantisThemeContextProviderProps, AtlantisThemeContextValue } from "./types";
|
|
3
|
-
export declare function AtlantisThemeContextProvider({ children, dangerouslyOverrideTheme, }: AtlantisThemeContextProviderProps): React.JSX.Element;
|
|
3
|
+
export declare function AtlantisThemeContextProvider({ children, theme, onThemeChange, dangerouslyOverrideTheme, }: AtlantisThemeContextProviderProps): React.JSX.Element;
|
|
4
4
|
export declare function useAtlantisTheme(): AtlantisThemeContextValue;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { AtlantisThemeContextProvider, useAtlantisTheme, } from "./AtlantisThemeContext";
|
|
2
|
-
export type { Theme, AtlantisThemeContextProviderProps, AtlantisThemeContextValue, } from "./types";
|
|
2
|
+
export type { EffectiveTheme, Theme, AtlantisThemeContextProviderProps, AtlantisThemeContextValue, } from "./types";
|
|
3
3
|
export { buildThemedStyles } from "./buildThemedStyles";
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { iosTokens } from "@jobber/design";
|
|
2
2
|
export interface AtlantisThemeContextValue {
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
4
|
+
* The selected theme.
|
|
5
5
|
*/
|
|
6
6
|
readonly theme: Theme;
|
|
7
|
+
/**
|
|
8
|
+
* The resolved theme used to select tokens.
|
|
9
|
+
*/
|
|
10
|
+
readonly effectiveTheme: EffectiveTheme;
|
|
7
11
|
/**
|
|
8
12
|
* The design tokens for the current theme.
|
|
9
13
|
*/
|
|
@@ -18,10 +22,21 @@ export interface AtlantisThemeContextProviderProps {
|
|
|
18
22
|
* The children to render.
|
|
19
23
|
*/
|
|
20
24
|
readonly children: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Control the selected theme for this provider.
|
|
27
|
+
*/
|
|
28
|
+
readonly theme?: Theme;
|
|
29
|
+
/**
|
|
30
|
+
* Called when children request a selected theme change through setTheme.
|
|
31
|
+
*/
|
|
32
|
+
readonly onThemeChange?: (theme: Theme) => void;
|
|
21
33
|
/**
|
|
22
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.
|
|
23
|
-
* This is dangerous because
|
|
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.
|
|
24
38
|
*/
|
|
25
|
-
readonly dangerouslyOverrideTheme?:
|
|
39
|
+
readonly dangerouslyOverrideTheme?: EffectiveTheme;
|
|
26
40
|
}
|
|
27
|
-
export type Theme = "
|
|
41
|
+
export type Theme = "system" | EffectiveTheme;
|
|
42
|
+
export type EffectiveTheme = "light" | "dark";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jobber/components-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "React Native implementation of Atlantis",
|
|
6
6
|
"repository": {
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@babel/runtime": "^7.29.2",
|
|
75
75
|
"@gorhom/bottom-sheet": "^5.2.8",
|
|
76
|
-
"@jobber/design": "0.
|
|
76
|
+
"@jobber/design": "0.114.0",
|
|
77
77
|
"@jobber/hooks": "2.21.1",
|
|
78
78
|
"@react-native-community/datetimepicker": "^8.4.5",
|
|
79
79
|
"@react-native/babel-preset": "^0.82.1",
|
|
@@ -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": "d79c88a2241ded3f29e879cd3c0d5a1e2dceb568"
|
|
127
127
|
}
|
|
@@ -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.
|
|
@@ -2,21 +2,43 @@ import React from "react";
|
|
|
2
2
|
import { act, renderHook } from "@testing-library/react-native";
|
|
3
3
|
import { darkTokens, iosTokens } from "@jobber/design";
|
|
4
4
|
import merge from "lodash/merge";
|
|
5
|
+
import { type ColorSchemeName, useColorScheme } from "react-native";
|
|
5
6
|
import {
|
|
6
7
|
AtlantisThemeContextProvider,
|
|
7
8
|
useAtlantisTheme,
|
|
8
9
|
} from "./AtlantisThemeContext";
|
|
9
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
AtlantisThemeContextProviderProps,
|
|
12
|
+
EffectiveTheme,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
jest.mock("react-native", () => {
|
|
16
|
+
const actual = jest.requireActual("react-native");
|
|
17
|
+
|
|
18
|
+
Object.defineProperty(actual, "useColorScheme", {
|
|
19
|
+
configurable: true,
|
|
20
|
+
value: jest.fn(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return actual;
|
|
24
|
+
});
|
|
10
25
|
|
|
11
26
|
const expectedDarkTokens = merge({}, iosTokens, darkTokens);
|
|
12
27
|
const expectedLightTokens = iosTokens;
|
|
28
|
+
const mockUseColorScheme = useColorScheme as jest.MockedFunction<
|
|
29
|
+
typeof useColorScheme
|
|
30
|
+
>;
|
|
13
31
|
|
|
14
32
|
function Wrapper({
|
|
15
33
|
children,
|
|
34
|
+
theme,
|
|
35
|
+
onThemeChange,
|
|
16
36
|
dangerouslyOverrideTheme,
|
|
17
37
|
}: AtlantisThemeContextProviderProps) {
|
|
18
38
|
return (
|
|
19
39
|
<AtlantisThemeContextProvider
|
|
40
|
+
theme={theme}
|
|
41
|
+
onThemeChange={onThemeChange}
|
|
20
42
|
dangerouslyOverrideTheme={dangerouslyOverrideTheme}
|
|
21
43
|
>
|
|
22
44
|
{children}
|
|
@@ -40,6 +62,10 @@ function WrapperWithOverride({
|
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
describe("ThemeContext", () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
mockUseColorScheme.mockReturnValue("light");
|
|
67
|
+
});
|
|
68
|
+
|
|
43
69
|
it("defaults to the light theme", () => {
|
|
44
70
|
const { result } = renderHook(useAtlantisTheme, {
|
|
45
71
|
wrapper: (props: AtlantisThemeContextProviderProps) => (
|
|
@@ -48,6 +74,7 @@ describe("ThemeContext", () => {
|
|
|
48
74
|
});
|
|
49
75
|
|
|
50
76
|
expect(result.current.theme).toBe("light");
|
|
77
|
+
expect(result.current.effectiveTheme).toBe("light");
|
|
51
78
|
expect(result.current.tokens).toEqual(expectedLightTokens);
|
|
52
79
|
});
|
|
53
80
|
|
|
@@ -60,35 +87,166 @@ describe("ThemeContext", () => {
|
|
|
60
87
|
|
|
61
88
|
await act(async () => result.current.setTheme("dark"));
|
|
62
89
|
expect(result.current.theme).toBe("dark");
|
|
90
|
+
expect(result.current.effectiveTheme).toBe("dark");
|
|
63
91
|
expect(result.current.tokens).toEqual(expectedDarkTokens);
|
|
64
92
|
|
|
65
93
|
await act(async () => result.current.setTheme("light"));
|
|
66
94
|
expect(result.current.theme).toBe("light");
|
|
95
|
+
expect(result.current.effectiveTheme).toBe("light");
|
|
96
|
+
expect(result.current.tokens).toEqual(expectedLightTokens);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("resolves the system theme from the OS color scheme", async () => {
|
|
100
|
+
mockUseColorScheme.mockReturnValue("dark");
|
|
101
|
+
|
|
102
|
+
const { result, rerender } = renderHook(useAtlantisTheme, {
|
|
103
|
+
wrapper: (props: AtlantisThemeContextProviderProps) => (
|
|
104
|
+
<Wrapper {...props} />
|
|
105
|
+
),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await act(async () => result.current.setTheme("system"));
|
|
109
|
+
expect(result.current.theme).toBe("system");
|
|
110
|
+
expect(result.current.effectiveTheme).toBe("dark");
|
|
111
|
+
expect(result.current.tokens).toEqual(expectedDarkTokens);
|
|
112
|
+
|
|
113
|
+
mockUseColorScheme.mockReturnValue("light");
|
|
114
|
+
rerender({});
|
|
115
|
+
|
|
116
|
+
expect(result.current.theme).toBe("system");
|
|
117
|
+
expect(result.current.effectiveTheme).toBe("light");
|
|
118
|
+
expect(result.current.tokens).toEqual(expectedLightTokens);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("uses a controlled selected theme", () => {
|
|
122
|
+
mockUseColorScheme.mockReturnValue("dark");
|
|
123
|
+
|
|
124
|
+
const { result } = renderHook(useAtlantisTheme, {
|
|
125
|
+
wrapper: (props: AtlantisThemeContextProviderProps) => (
|
|
126
|
+
<Wrapper {...props} theme="system" />
|
|
127
|
+
),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(result.current.theme).toBe("system");
|
|
131
|
+
expect(result.current.effectiveTheme).toBe("dark");
|
|
132
|
+
expect(result.current.tokens).toEqual(expectedDarkTokens);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reports selected theme changes from a controlled provider", async () => {
|
|
136
|
+
const onThemeChange = jest.fn();
|
|
137
|
+
const { result } = renderHook(useAtlantisTheme, {
|
|
138
|
+
wrapper: (props: AtlantisThemeContextProviderProps) => (
|
|
139
|
+
<Wrapper {...props} theme="light" onThemeChange={onThemeChange} />
|
|
140
|
+
),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await act(async () => result.current.setTheme("system"));
|
|
144
|
+
|
|
145
|
+
expect(onThemeChange).toHaveBeenCalledWith("system");
|
|
146
|
+
expect(result.current.theme).toBe("light");
|
|
147
|
+
expect(result.current.effectiveTheme).toBe("light");
|
|
148
|
+
expect(result.current.tokens).toEqual(expectedLightTokens);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("falls back to light when system color scheme is unavailable", async () => {
|
|
152
|
+
mockUseColorScheme.mockReturnValue(
|
|
153
|
+
null as unknown as ReturnType<typeof useColorScheme>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const { result } = renderHook(useAtlantisTheme, {
|
|
157
|
+
wrapper: (props: AtlantisThemeContextProviderProps) => (
|
|
158
|
+
<Wrapper {...props} />
|
|
159
|
+
),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await act(async () => result.current.setTheme("system"));
|
|
163
|
+
expect(result.current.theme).toBe("system");
|
|
164
|
+
expect(result.current.effectiveTheme).toBe("light");
|
|
67
165
|
expect(result.current.tokens).toEqual(expectedLightTokens);
|
|
68
166
|
});
|
|
69
167
|
|
|
70
168
|
describe("when theme is forced for provider", () => {
|
|
71
|
-
it("
|
|
169
|
+
it("preserves the forced effective theme when the selected theme changes", async () => {
|
|
72
170
|
const { result } = renderHook(useAtlantisTheme, {
|
|
73
171
|
wrapper: (props: AtlantisThemeContextProviderProps) => (
|
|
74
172
|
<WrapperWithOverride {...props} dangerouslyOverrideTheme="light" />
|
|
75
173
|
),
|
|
76
174
|
});
|
|
77
175
|
|
|
78
|
-
// Update the
|
|
176
|
+
// Update the selected theme through the forced subtree.
|
|
79
177
|
await act(async () => result.current.setTheme("dark"));
|
|
80
178
|
|
|
81
|
-
|
|
82
|
-
expect(result.current.
|
|
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");
|
|
218
|
+
expect(result.current.effectiveTheme).toBe("light");
|
|
83
219
|
expect(result.current.tokens).toEqual(expectedLightTokens);
|
|
84
220
|
});
|
|
85
221
|
|
|
86
222
|
it.each([
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
223
|
+
{
|
|
224
|
+
defaultTheme: "light",
|
|
225
|
+
colorScheme: "dark",
|
|
226
|
+
expectedEffectiveTheme: "light",
|
|
227
|
+
expectedTokens: expectedLightTokens,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
defaultTheme: "dark",
|
|
231
|
+
colorScheme: "light",
|
|
232
|
+
expectedEffectiveTheme: "dark",
|
|
233
|
+
expectedTokens: expectedDarkTokens,
|
|
234
|
+
},
|
|
235
|
+
] as {
|
|
236
|
+
defaultTheme: EffectiveTheme;
|
|
237
|
+
colorScheme: ColorSchemeName;
|
|
238
|
+
expectedEffectiveTheme: EffectiveTheme;
|
|
239
|
+
expectedTokens: typeof iosTokens;
|
|
240
|
+
}[])(
|
|
90
241
|
"provides the dangerouslyOverrideTheme $defaultTheme tokens",
|
|
91
|
-
({
|
|
242
|
+
({
|
|
243
|
+
defaultTheme,
|
|
244
|
+
colorScheme,
|
|
245
|
+
expectedEffectiveTheme,
|
|
246
|
+
expectedTokens,
|
|
247
|
+
}) => {
|
|
248
|
+
mockUseColorScheme.mockReturnValue(colorScheme);
|
|
249
|
+
|
|
92
250
|
const { result } = renderHook(useAtlantisTheme, {
|
|
93
251
|
wrapper: (props: AtlantisThemeContextProviderProps) => (
|
|
94
252
|
<WrapperWithOverride
|
|
@@ -98,7 +256,8 @@ describe("ThemeContext", () => {
|
|
|
98
256
|
),
|
|
99
257
|
});
|
|
100
258
|
|
|
101
|
-
expect(result.current.theme).toBe(
|
|
259
|
+
expect(result.current.theme).toBe("light");
|
|
260
|
+
expect(result.current.effectiveTheme).toBe(expectedEffectiveTheme);
|
|
102
261
|
expect(result.current.tokens).toEqual(expectedTokens);
|
|
103
262
|
},
|
|
104
263
|
);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { androidTokens, darkTokens, iosTokens } from "@jobber/design";
|
|
2
|
-
import React, { createContext, useContext, useState } from "react";
|
|
2
|
+
import React, { createContext, useCallback, useContext, useState } from "react";
|
|
3
3
|
import merge from "lodash/merge";
|
|
4
|
-
import { Platform } from "react-native";
|
|
4
|
+
import { Platform, useColorScheme } from "react-native";
|
|
5
5
|
import type {
|
|
6
6
|
AtlantisThemeContextProviderProps,
|
|
7
7
|
AtlantisThemeContextValue,
|
|
8
|
+
EffectiveTheme,
|
|
8
9
|
Theme,
|
|
9
10
|
} from "./types";
|
|
10
11
|
|
|
@@ -16,34 +17,65 @@ const lightTokens = Platform.select({
|
|
|
16
17
|
|
|
17
18
|
const completeDarkTokens = merge({}, lightTokens, darkTokens);
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
+
const defaultAtlantisThemeContextValue: AtlantisThemeContextValue = {
|
|
20
21
|
theme: "light",
|
|
22
|
+
effectiveTheme: "light",
|
|
21
23
|
tokens: lightTokens,
|
|
22
24
|
setTheme: () => {
|
|
23
25
|
console.error(
|
|
24
26
|
"useAtlantisTheme accessed outside of AtlantisThemeContextProvider",
|
|
25
27
|
);
|
|
26
28
|
},
|
|
27
|
-
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const AtlantisThemeContext = createContext<
|
|
32
|
+
AtlantisThemeContextValue | undefined
|
|
33
|
+
>(undefined);
|
|
28
34
|
|
|
29
35
|
export function AtlantisThemeContextProvider({
|
|
30
36
|
children,
|
|
37
|
+
theme,
|
|
38
|
+
onThemeChange,
|
|
31
39
|
dangerouslyOverrideTheme,
|
|
32
40
|
}: AtlantisThemeContextProviderProps) {
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
41
|
+
const parentThemeContext = useContext(AtlantisThemeContext);
|
|
42
|
+
const [localTheme, setLocalTheme] = useState<Theme | undefined>(undefined);
|
|
43
|
+
const colorScheme = useColorScheme();
|
|
44
|
+
|
|
45
|
+
const setTheme = useCallback(
|
|
46
|
+
(nextTheme: Theme) => {
|
|
47
|
+
onThemeChange?.(nextTheme);
|
|
48
|
+
|
|
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;
|
|
57
|
+
}
|
|
36
58
|
|
|
37
|
-
|
|
59
|
+
setLocalTheme(nextTheme);
|
|
60
|
+
},
|
|
61
|
+
[dangerouslyOverrideTheme, onThemeChange, parentThemeContext, theme],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const currentTheme =
|
|
65
|
+
theme ?? localTheme ?? parentThemeContext?.theme ?? "light";
|
|
66
|
+
const effectiveTheme =
|
|
67
|
+
dangerouslyOverrideTheme ??
|
|
68
|
+
resolveEffectiveTheme(currentTheme, colorScheme);
|
|
38
69
|
const currentTokens =
|
|
39
|
-
|
|
70
|
+
effectiveTheme === "dark" ? completeDarkTokens : lightTokens;
|
|
40
71
|
|
|
41
72
|
return (
|
|
42
73
|
<AtlantisThemeContext.Provider
|
|
43
74
|
value={{
|
|
44
75
|
theme: currentTheme,
|
|
76
|
+
effectiveTheme,
|
|
45
77
|
tokens: currentTokens,
|
|
46
|
-
setTheme
|
|
78
|
+
setTheme,
|
|
47
79
|
}}
|
|
48
80
|
>
|
|
49
81
|
{children}
|
|
@@ -52,5 +84,14 @@ export function AtlantisThemeContextProvider({
|
|
|
52
84
|
}
|
|
53
85
|
|
|
54
86
|
export function useAtlantisTheme() {
|
|
55
|
-
return useContext(AtlantisThemeContext);
|
|
87
|
+
return useContext(AtlantisThemeContext) ?? defaultAtlantisThemeContextValue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveEffectiveTheme(
|
|
91
|
+
theme: Theme,
|
|
92
|
+
colorScheme: ReturnType<typeof useColorScheme>,
|
|
93
|
+
): EffectiveTheme {
|
|
94
|
+
if (theme !== "system") return theme;
|
|
95
|
+
|
|
96
|
+
return colorScheme === "dark" ? "dark" : "light";
|
|
56
97
|
}
|
|
@@ -14,15 +14,21 @@ function ChildrenComponent({
|
|
|
14
14
|
}: {
|
|
15
15
|
readonly message?: string;
|
|
16
16
|
}) {
|
|
17
|
-
const { theme, tokens, setTheme } = useAtlantisTheme();
|
|
17
|
+
const { theme, effectiveTheme, tokens, setTheme } = useAtlantisTheme();
|
|
18
18
|
|
|
19
19
|
return (
|
|
20
20
|
<View style={{ backgroundColor: tokens["color-surface"] }}>
|
|
21
21
|
<Content>
|
|
22
22
|
<Text>{message}</Text>
|
|
23
|
-
<Text>{`
|
|
23
|
+
<Text>{`Selected theme: ${theme}`}</Text>
|
|
24
|
+
<Text>{`Effective theme: ${effectiveTheme}`}</Text>
|
|
24
25
|
<Text>Tokens can be accessed using tokens[token-name]</Text>
|
|
25
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>
|
|
31
|
+
<Button label="Set system theme" onPress={() => setTheme("system")} />
|
|
26
32
|
<Button label="Set dark theme" onPress={() => setTheme("dark")} />
|
|
27
33
|
<Button label="Set light theme" onPress={() => setTheme("light")} />
|
|
28
34
|
</Content>
|
|
@@ -14,15 +14,17 @@ function ChildrenComponent({
|
|
|
14
14
|
}: {
|
|
15
15
|
readonly message?: string;
|
|
16
16
|
}) {
|
|
17
|
-
const { theme, tokens, setTheme } = useAtlantisTheme();
|
|
17
|
+
const { theme, effectiveTheme, tokens, setTheme } = useAtlantisTheme();
|
|
18
18
|
|
|
19
19
|
return (
|
|
20
20
|
<View style={{ backgroundColor: tokens["color-surface"] }}>
|
|
21
21
|
<Content>
|
|
22
22
|
<Text>{message}</Text>
|
|
23
|
-
<Text>{`
|
|
23
|
+
<Text>{`Selected theme: ${theme}`}</Text>
|
|
24
|
+
<Text>{`Effective theme: ${effectiveTheme}`}</Text>
|
|
24
25
|
<Text>Tokens can be accessed using tokens[token-name]</Text>
|
|
25
26
|
<Text>{`For example color-surface: ${tokens["color-surface"]}`}</Text>
|
|
27
|
+
<Button label="Set system theme" onPress={() => setTheme("system")} />
|
|
26
28
|
<Button label="Set dark theme" onPress={() => setTheme("dark")} />
|
|
27
29
|
<Button label="Set light theme" onPress={() => setTheme("light")} />
|
|
28
30
|
</Content>
|
|
@@ -31,12 +33,14 @@ function ChildrenComponent({
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
function ForcedDarkThemeComponent() {
|
|
34
|
-
const { tokens } = useAtlantisTheme();
|
|
36
|
+
const { effectiveTheme, theme, tokens } = useAtlantisTheme();
|
|
35
37
|
|
|
36
38
|
return (
|
|
37
39
|
<View style={{ backgroundColor: tokens["color-surface"] }}>
|
|
38
40
|
<Content>
|
|
39
41
|
<Text>This will always be a dark theme</Text>
|
|
42
|
+
<Text>{`Selected theme: ${theme}`}</Text>
|
|
43
|
+
<Text>{`Effective theme: ${effectiveTheme}`}</Text>
|
|
40
44
|
</Content>
|
|
41
45
|
</View>
|
|
42
46
|
);
|
|
@@ -47,7 +51,7 @@ export function AtlantisThemeContextForceThemeForProviderExample(
|
|
|
47
51
|
) {
|
|
48
52
|
return (
|
|
49
53
|
<AtlantisThemeContextProvider {...props}>
|
|
50
|
-
<ChildrenComponent message="It is possible to
|
|
54
|
+
<ChildrenComponent message="It is possible to force a subtree's rendered theme while preserving the selected theme semantics." />
|
|
51
55
|
<AtlantisThemeContextProvider dangerouslyOverrideTheme="dark">
|
|
52
56
|
<ForcedDarkThemeComponent />
|
|
53
57
|
</AtlantisThemeContextProvider>
|
|
@@ -2,10 +2,15 @@ import type { iosTokens } from "@jobber/design";
|
|
|
2
2
|
|
|
3
3
|
export interface AtlantisThemeContextValue {
|
|
4
4
|
/**
|
|
5
|
-
* The
|
|
5
|
+
* The selected theme.
|
|
6
6
|
*/
|
|
7
7
|
readonly theme: Theme;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The resolved theme used to select tokens.
|
|
11
|
+
*/
|
|
12
|
+
readonly effectiveTheme: EffectiveTheme;
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* The design tokens for the current theme.
|
|
11
16
|
*/
|
|
@@ -23,11 +28,25 @@ export interface AtlantisThemeContextProviderProps {
|
|
|
23
28
|
*/
|
|
24
29
|
readonly children: React.ReactNode;
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Control the selected theme for this provider.
|
|
33
|
+
*/
|
|
34
|
+
readonly theme?: Theme;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Called when children request a selected theme change through setTheme.
|
|
38
|
+
*/
|
|
39
|
+
readonly onThemeChange?: (theme: Theme) => void;
|
|
40
|
+
|
|
26
41
|
/**
|
|
27
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.
|
|
28
|
-
* This is dangerous because
|
|
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.
|
|
29
46
|
*/
|
|
30
|
-
readonly dangerouslyOverrideTheme?:
|
|
47
|
+
readonly dangerouslyOverrideTheme?: EffectiveTheme;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
|
-
export type Theme = "
|
|
50
|
+
export type Theme = "system" | EffectiveTheme;
|
|
51
|
+
|
|
52
|
+
export type EffectiveTheme = "light" | "dark";
|
|
@@ -4,6 +4,10 @@ import { AccessibilityInfo, View } from "react-native";
|
|
|
4
4
|
import { BottomSheet } from ".";
|
|
5
5
|
import type { BottomSheetRef } from "./BottomSheet";
|
|
6
6
|
import { waitForUntestableRender } from "../utils/test/wait";
|
|
7
|
+
import {
|
|
8
|
+
AtlantisThemeContextProvider,
|
|
9
|
+
useAtlantisTheme,
|
|
10
|
+
} from "../AtlantisThemeContext";
|
|
7
11
|
import { Text } from "../Text";
|
|
8
12
|
|
|
9
13
|
jest.unmock("../hooks/useIsScreenReaderEnabled");
|
|
@@ -51,6 +55,26 @@ it("renders a BottomSheet", async () => {
|
|
|
51
55
|
expect(await findByText("BottomSheet")).toBeDefined();
|
|
52
56
|
});
|
|
53
57
|
|
|
58
|
+
it("preserves the effective Atlantis theme for children rendered through the portal", async () => {
|
|
59
|
+
const { findByText } = render(
|
|
60
|
+
<AtlantisThemeContextProvider theme="dark">
|
|
61
|
+
<BottomSheet ref={ref}>
|
|
62
|
+
<ThemeProbe />
|
|
63
|
+
</BottomSheet>
|
|
64
|
+
</AtlantisThemeContextProvider>,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await waitForUntestableRender(
|
|
68
|
+
"Wait for AccessibilityInfo.isScreenReaderEnabled to resolve",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await act(async () => {
|
|
72
|
+
ref.current?.open();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(await findByText("theme:dark")).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
54
78
|
it("renders a BottomSheet with a header", async () => {
|
|
55
79
|
const header = "Hello this is header";
|
|
56
80
|
const { findByText } = setup({ heading: header });
|
|
@@ -144,3 +168,9 @@ describe("when there is a screen reader enabled", () => {
|
|
|
144
168
|
});
|
|
145
169
|
});
|
|
146
170
|
});
|
|
171
|
+
|
|
172
|
+
function ThemeProbe() {
|
|
173
|
+
const { effectiveTheme } = useAtlantisTheme();
|
|
174
|
+
|
|
175
|
+
return <Text>{`theme:${effectiveTheme}`}</Text>;
|
|
176
|
+
}
|