-
Notifications
You must be signed in to change notification settings - Fork 30
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
Changes from 7 commits
575b7d4
9d948fb
37e774f
8cd5920
4d27b02
a5c4faa
cad4736
afed2e0
edfd378
c491555
0a9f647
bbdcddf
09b639a
9fe8b36
3c05faf
a7d4432
acefddf
5348136
556789c
674ae69
036921d
a98187c
7e177f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Screen.Recording.2025-02-20.at.1.46.26.PM.movThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"; |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I'll create a ticket to rename these props in atlantis and consumers. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add some docs above!