Skip to content

Commit

Permalink
feat(components-native): AtlantisThemeContext for mobile (#2387)
Browse files Browse the repository at this point in the history
* Add AtlantisThemeContext for components-native

* Make Button component use theme tokens

* Add todos

* Handle changing theme globally

* Add mobile story for AtlantisThemeContext

* Add tests for mobile AtlantisThemeContextProvider

* Update meta.json

* Cleanup types

* Undo Button changes

* Update comment

* Generate mobile props for AtlantisThemeContext

* Show mobile props

* Theme the mobile preview iframe

* Add mobile example for AtlantisThemeContext

* Update mobile snippet to override the theme

* Fix lint

* Rename function

* Regen props files

* Revert "Regen props files"

This reverts commit 556789c.

* Note differences between web and mobile

* Tweak wording
  • Loading branch information
jdeichert authored Feb 24, 2025
1 parent 29a26cb commit 1c3085f
Show file tree
Hide file tree
Showing 14 changed files with 444 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,23 @@ tokens.

## Design & usage guidelines

### Using the AtlantisThemeContextProvider
Both the web and mobile components have the exact same API, except for one
minor difference in how you update the theme.

Each platform provides a `useAtlantisTheme` hook that you may use to access the
`theme` and `tokens` in your components.

On mobile, this hook also returns a `setTheme` function which you'll use to
update the theme for the nearest `AtlantisThemeContextProvider` ancestor.
Typically there will only be a single provider at the root, controlling the
theme for the entire app.

On web, you'll need to import the `updateTheme` function and call it with the
new theme. This is a separate function because it synchronizes the theme update
across all providers under various React trees. Synchronizing across providers
is necessary for cases where an island-based architecture is used.

### Usage for web

```tsx
import {
Expand Down Expand Up @@ -41,7 +57,7 @@ function ThemedComponent() {
}}
>
<Content>
<Text>The current theme is: {theme}. </Text>
<Text>The current theme is: {theme}.</Text>
<Text>
The javascript tokens can be accessed via the tokens object.
</Text>
Expand All @@ -61,6 +77,50 @@ function ThemedComponent() {
}
```

### Usage for mobile

```tsx
import { AtlantisThemeContextProvider, useAtlantisTheme } from "@jobber/components/AtlantisThemeContext";

function App() {
return (
<AtlantisThemeContextProvider>
<ThemedComponent />
</AtlantisThemeContextProvider>
);
}

function ThemedComponent() {
const { theme, tokens, setTheme } = useAtlantisTheme();
return (
<Content>
<View
style={{
background: tokens["surface-background"],
padding: tokens["space-base"],
}}
>
<Content>
<Text>The current theme is: {theme}.</Text>
<Text>
The javascript tokens can be accessed via the tokens object.
</Text>
<Text>The theme can be changed using `setTheme`</Text>
<Button
onPress={() => setTheme("light")}
label="The theme can be changed to light"
/>
<Button
onPress={() => setTheme("dark")}
label="The theme can be changed to dark"
/>
</Content>
</View>
</Content>
);
}
```

### Forcing a theme for an AtlantisThemeContextProvider

In some scenarios you may want to force a theme for specific components
Expand Down
94 changes: 94 additions & 0 deletions docs/components/AtlantisThemeContext/Mobile.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from "react";
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { View } from "react-native";
import {
AtlantisThemeContextProvider,
Button,
Content,
Text,
useAtlantisTheme,
} from "@jobber/components-native";

export default {
title: "Components/Themes/AtlantisThemeContext/Mobile",
component: AtlantisThemeContextProvider,
parameters: {
viewMode: "story",
previewTabs: {
code: {
hidden: false,
},
},
viewport: { defaultViewport: "mobile1" },
},
} as ComponentMeta<typeof AtlantisThemeContextProvider>;

function ChildrenComponent({
message = "It is possible to have multiple Atlantis Theme providers.",
}: {
readonly message?: string;
}) {
const { theme, tokens, setTheme } = useAtlantisTheme();

return (
<View
style={{
backgroundColor: tokens["color-surface"],
}}
>
<Content>
<Text>{message}</Text>
<Text>{`Current theme: ${theme}`}</Text>
<Text>Tokens can be accessed using tokens[token-name]</Text>
<Text>{`For example color-surface: ${tokens["color-surface"]}`}</Text>
<Button label="Set dark theme" onPress={() => setTheme("dark")} />
<Button label="Set light theme" onPress={() => setTheme("light")} />
</Content>
</View>
);
}

function ForcedDarkThemeComponent() {
const { tokens } = useAtlantisTheme();

return (
<View
style={{
backgroundColor: tokens["color-surface"],
}}
>
<Content>
<Text>This will always be a dark theme</Text>
</Content>
</View>
);
}

const BasicTemplate: ComponentStory<
typeof AtlantisThemeContextProvider
> = args => {
return (
<AtlantisThemeContextProvider {...args}>
<ChildrenComponent />
</AtlantisThemeContextProvider>
);
};

export const Basic = BasicTemplate.bind({});
BasicTemplate.args = {};

const ForceThemeTemplate: ComponentStory<
typeof AtlantisThemeContextProvider
> = args => {
return (
<AtlantisThemeContextProvider {...args}>
<ChildrenComponent message="It is possible to have a provider ignore Theme Changes" />
<AtlantisThemeContextProvider dangerouslyOverrideTheme="dark">
<ForcedDarkThemeComponent />
</AtlantisThemeContextProvider>
</AtlantisThemeContextProvider>
);
};

export const ForceThemeForProvider = ForceThemeTemplate.bind({});
ForceThemeForProvider.args = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from "react";
import { act, renderHook } from "@testing-library/react-native";
import { darkTokens, iosTokens } from "@jobber/design";
import merge from "lodash/merge";
import {
AtlantisThemeContextProvider,
useAtlantisTheme,
} from "./AtlantisThemeContext";
import { AtlantisThemeContextProviderProps, Theme } from "./types";

const expectedDarkTokens = merge({}, iosTokens, darkTokens);
const expectedLightTokens = iosTokens;

function Wrapper({
children,
dangerouslyOverrideTheme,
}: AtlantisThemeContextProviderProps) {
return (
<AtlantisThemeContextProvider
dangerouslyOverrideTheme={dangerouslyOverrideTheme}
>
{children}
</AtlantisThemeContextProvider>
);
}

function WrapperWithOverride({
children,
dangerouslyOverrideTheme,
}: AtlantisThemeContextProviderProps) {
return (
<Wrapper>
<AtlantisThemeContextProvider
dangerouslyOverrideTheme={dangerouslyOverrideTheme}
>
{children}
</AtlantisThemeContextProvider>
</Wrapper>
);
}

describe("ThemeContext", () => {
it("defaults to the light theme", () => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
<Wrapper {...props} />
),
});

expect(result.current.theme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});

it("updates the theme and tokens", () => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
<Wrapper {...props} />
),
});

act(() => result.current.setTheme("dark"));
expect(result.current.theme).toBe("dark");
expect(result.current.tokens).toEqual(expectedDarkTokens);

act(() => result.current.setTheme("light"));
expect(result.current.theme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});

describe("when theme is forced for provider", () => {
it("ignores updates to the global theme", () => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
<WrapperWithOverride {...props} dangerouslyOverrideTheme="light" />
),
});

// Update the global theme
act(() => result.current.setTheme("dark"));

// This hook shouldn't be affected by it because it's set to the light theme
expect(result.current.theme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});

it.each([
{ defaultTheme: "light", expectedTokens: expectedLightTokens },
{ defaultTheme: "dark", expectedTokens: expectedDarkTokens },
] as { defaultTheme: Theme; expectedTokens: typeof iosTokens }[])(
"provides the dangerouslyOverrideTheme $defaultTheme tokens",
({ defaultTheme, expectedTokens }) => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
<WrapperWithOverride
{...props}
dangerouslyOverrideTheme={defaultTheme}
/>
),
});

expect(result.current.theme).toBe(defaultTheme);
expect(result.current.tokens).toEqual(expectedTokens);
},
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { androidTokens, darkTokens, iosTokens } from "@jobber/design";
import React, { createContext, useContext, useState } from "react";
import merge from "lodash/merge";
import { Platform } from "react-native";
import {
AtlantisThemeContextProviderProps,
AtlantisThemeContextValue,
Theme,
} from "./types";

const lightTokens = Platform.select({
ios: () => iosTokens,
android: () => androidTokens,
default: () => androidTokens,
})();

const completeDarkTokens = merge({}, lightTokens, darkTokens);

const AtlantisThemeContext = createContext<AtlantisThemeContextValue>({
theme: "light",
tokens: lightTokens,
setTheme: () => {
console.error(
"useAtlantisTheme accessed outside of AtlantisThemeContextProvider",
);
},
});

export function AtlantisThemeContextProvider({
children,
dangerouslyOverrideTheme,
}: AtlantisThemeContextProviderProps) {
// TODO: check last saved theme from local/device storage
const initialTheme: Theme = "light";
const [globalTheme, setGlobalTheme] = useState<Theme>(initialTheme);

const currentTheme = dangerouslyOverrideTheme ?? globalTheme;
const currentTokens =
currentTheme === "dark" ? completeDarkTokens : lightTokens;

return (
<AtlantisThemeContext.Provider
value={{
theme: currentTheme,
tokens: currentTokens,
setTheme: setGlobalTheme,
}}
>
{children}
</AtlantisThemeContext.Provider>
);
}

export function useAtlantisTheme() {
return useContext(AtlantisThemeContext);
}
9 changes: 9 additions & 0 deletions packages/components-native/src/AtlantisThemeContext/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
AtlantisThemeContextProvider,
useAtlantisTheme,
} from "./AtlantisThemeContext";
export {
Theme,
AtlantisThemeContextProviderProps,
AtlantisThemeContextValue,
} from "./types";
33 changes: 33 additions & 0 deletions packages/components-native/src/AtlantisThemeContext/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { iosTokens } from "@jobber/design";

export interface AtlantisThemeContextValue {
/**
* The active theme.
*/
readonly theme: Theme;

/**
* The design tokens for the current theme.
*/
readonly tokens: typeof iosTokens;

/**
* Change the current theme globally.
*/
readonly setTheme: (theme: Theme) => void;
}

export interface AtlantisThemeContextProviderProps {
/**
* The children to render.
*/
readonly children: React.ReactNode;

/**
* 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.
* This is dangerous because the children in this provider will not be able to change the theme.
*/
readonly dangerouslyOverrideTheme?: Theme;
}

export type Theme = "light" | "dark";
1 change: 1 addition & 0 deletions packages/components-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./ActionItem";
export * from "./ActionLabel";
export * from "./ActivityIndicator";
export * from "./AtlantisContext";
export * from "./AtlantisThemeContext";
export * from "./AutoLink";
export * from "./Banner";
export * from "./BottomSheet";
Expand Down
Loading

0 comments on commit 1c3085f

Please sign in to comment.