Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components-native): AtlantisThemeContext for mobile #2387

Merged
merged 23 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ tokens.

## Design & usage guidelines

### Using the AtlantisThemeContextProvider
### Usage for web

```tsx
import {
Expand Down Expand Up @@ -41,7 +41,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 +61,50 @@ function ThemedComponent() {
}
```

### Usage for mobile
Copy link
Contributor

@Aiden-Brine Aiden-Brine Feb 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usage sections give enough information to figure out how to use both web and mobile but how about a small section to highlight the differences so that if someone who is familiar with one is looking at the other they can quickly see the differences in usages?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, will add this today!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some docs above!


```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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some basic stories. They won't look correct in dark mode until we update the entire library to use buildThemedStyles.

Screen.Recording.2025-02-20.at.1.46.26.PM.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note, after I recorded this video I reverted the Button style changes. @Aiden-Brine's PR will handle that!

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a TODO for the future when we actually do dark mode in JM

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);
}
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";
34 changes: 34 additions & 0 deletions packages/components-native/src/AtlantisThemeContext/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { iosTokens } from "@jobber/design";
import { PropsWithChildren } from "react";

export interface AtlantisThemeContextValue {
/**
* The theme of the application.
*/
readonly theme: Theme;

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

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

export interface AtlantisThemeContextProviderProps extends PropsWithChildren {
/**
* 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;
Copy link
Contributor

@Aiden-Brine Aiden-Brine Feb 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably too late for this component because I know we use this language on the web theme provider but ideally should this be "unsafe" instead of "dangerous" to be more consistent with our current naming or is there a difference between an unsafe action and a dangerous action? This definitely isn't for this PR but I wanted to raise it to chat about

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the code I think this is the only place we use "dangerous"

Copy link
Contributor Author

@jdeichert jdeichert Feb 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good point. This predates our decisions around using unsafe terminology. I believe the original intent for using dangerously is that it's similar to other react props, like dangerouslySetInnerHTML.

I'll create a ticket to rename these props in atlantis and consumers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the ticket, it's in our backlog 👍

}

export type Theme = "light" | "dark";
Loading
Loading