Skip to content

Commit

Permalink
feat(system-theme): added support for system themes
Browse files Browse the repository at this point in the history
`useTheme` hook provides a `metadata` object with `definedBy` property
which indicates the source of the theme. This should be enough to build
a light/dark/system dropdown. To set the system theme pass a `null`
value to `setTheme`.
  • Loading branch information
Alexandru Bereghici committed Jul 4, 2024
1 parent f40a6b4 commit 731f9a9
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 53 deletions.
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

- ✅ Perfect dark mode in a few lines of code
- ✅ System setting with prefers-color-scheme
- ✅ Automatically updates the theme when the user changes the system setting
- ✅ No flash on load
- ✅ Disable flashing when changing themes
- ✅ Class or data attribute selector
- ✅ Sync theme across tabs and windows

Check out the
Expand Down Expand Up @@ -107,8 +108,11 @@ function App() {

#### Add the action route

Create a file in `/routes/action/set-theme.ts` or `/routes/action.set-theme.ts` when using [Route File Naming v2](https://remix.run/docs/en/1.19.3/file-conventions/route-files-v2#route-file-naming-v2) with the content below. Ensure
that you pass the filename to the `ThemeProvider` component.
Create a file in `/routes/action/set-theme.ts` or `/routes/action.set-theme.ts`
when using
[Route File Naming v2](https://remix.run/docs/en/1.19.3/file-conventions/route-files-v2#route-file-naming-v2)
with the content below. Ensure that you pass the filename to the `ThemeProvider`
component.

> Note: You can name the action route whatever you want. Just make sure you pass
> the correct action name to the `ThemeProvider` component. Make sure to use
Expand Down Expand Up @@ -141,6 +145,12 @@ Let's dig into the details.
useTheme takes no parameters but returns:

- `theme`: Active theme name
- `setTheme`: Function to set the theme. If the theme is set to `null`, the
system theme will be used and `definedBy` property in the `metadata` object
will be set to `SYSTEM`.
- `metadata`: An object which contains the following properties:
- `definedBy`: The theme source. It can be `USER` or `SYSTEM`. Useful to
detect if the theme was set by the user or by the system.

### createThemeSessionResolver

Expand Down
13 changes: 7 additions & 6 deletions e2e-tests/themes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ test('toggling the theme', async ({page}) => {
await page.goto('/')

const html = () => page.locator('html')
const toggler = () => page.getByRole('button', {name: 'Toggle theme'})

const themeAttribute = 'data-theme'
const initialTheme = await html().getAttribute(themeAttribute)
const oppositeTheme = initialTheme === 'light' ? 'dark' : 'light'

await toggler().click()
await page.locator('select').selectOption({
value: oppositeTheme,
})

expect(html()).toHaveAttribute(themeAttribute, oppositeTheme)

await page.reload()

await expect(toggler()).toBeVisible()
await expect(page.locator('select')).toBeVisible()

await expect(html()).toHaveAttribute(themeAttribute, oppositeTheme)
})
Expand All @@ -28,14 +30,13 @@ test('sync between tabs when theme change', async ({context}) => {
await pageTwo.goto('/')

const html = (page: Page) => page.locator('html')
const toggler = (page: Page) =>
page.getByRole('button', {name: 'Toggle theme'})

const themeAttribute = 'data-theme'
const initialTheme = await html(pageOne).getAttribute(themeAttribute)
const oppositeTheme = initialTheme === 'light' ? 'dark' : 'light'

await toggler(pageOne).click()
await pageOne.locator('select').selectOption({value: oppositeTheme})

await expect(html(pageOne)).toHaveAttribute(themeAttribute, oppositeTheme)
await expect(html(pageTwo)).toHaveAttribute(themeAttribute, oppositeTheme)
})
38 changes: 29 additions & 9 deletions packages/remix-themes-app/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
import {Link} from '@remix-run/react'
import {useEffect} from 'react'
import {Theme, useTheme} from 'remix-themes'

export default function Index() {
const [, setTheme] = useTheme()
const [theme, setTheme, {definedBy}] = useTheme()

useEffect(() => {
console.log({theme, definedBy})
}, [definedBy, theme])

return (
<div>
<h1>Welcome to Remix</h1>
<button
type="button"
onClick={() =>
setTheme(prev => (prev === Theme.DARK ? Theme.LIGHT : Theme.DARK))
}
>
Toggle theme
</button>
<div style={{margin: '1rem 0'}}>
<label style={{display: 'flex', gap: '8px'}}>
Theme
<select
name="theme"
value={definedBy === 'SYSTEM' ? '' : theme ?? ''}
onChange={e => {
const nextTheme = e.target.value

if (nextTheme === '') {
setTheme(null)
} else {
setTheme(nextTheme as Theme)
}
}}
>
<option value="">System</option>
<option value={Theme.LIGHT}>Light</option>
<option value={Theme.DARK}>Dark</option>
</select>
</label>
</div>
<Link to="/about">About</Link>
</div>
)
Expand Down
10 changes: 10 additions & 0 deletions packages/remix-themes-app/app/styles/index.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
.dark,
[data-theme='dark'] {
color-scheme: dark;
}

.light,
[data-theme='light'] {
color-scheme: light;
}

[data-theme='dark'] {
background-color: #000;
color: white;
Expand Down
5 changes: 2 additions & 3 deletions packages/remix-themes/src/create-theme-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,12 @@ describe('create-theme action', () => {
})
})

it("doesn't accept empty themes", async () => {
it('accepts empty themes', async () => {
let theme = ''
let request = createThemedRequest(theme)
let response = await action({request, params: {}, context: {}})
await expect(response.json()).resolves.toEqual({
success: false,
message: `empty theme provided`,
success: true,
})
})
})
13 changes: 8 additions & 5 deletions packages/remix-themes/src/create-theme-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ export const createThemeAction = (
const session = await themeSessionResolver(request)
const {theme} = await request.json()

if (!isTheme(theme)) {
let message = theme
? `theme value of ${theme} is not a valid theme.`
: `empty theme provided`
if (!theme) {
return json(
{success: true},
{headers: {'Set-Cookie': await session.destroy()}},
)
}

if (!isTheme(theme)) {
return json({
success: false,
message,
message: `theme value of ${theme} is not a valid theme.`,
})
}

Expand Down
4 changes: 3 additions & 1 deletion packages/remix-themes/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export {createThemeSessionResolver} from './theme-server'
export {createThemeSessionResolver, ThemeSessionResolver} from './theme-server'
export {
ThemeProvider,
useTheme,
Expand All @@ -8,3 +8,5 @@ export {
PreventFlashOnWrongTheme,
} from './theme-provider'
export {createThemeAction} from './create-theme-action'

export type {ThemeMetadata} from './theme-provider'
49 changes: 48 additions & 1 deletion packages/remix-themes/src/theme-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('theme-provider', () => {
})

expect(result.current[0]).toBe(Theme.DARK)
expect(result.current[2].definedBy).toBe('USER')
})

it('changes the theme', async () => {
Expand All @@ -56,6 +57,7 @@ describe('theme-provider', () => {
),
})
expect(result.current[0]).toBe(Theme.DARK)
expect(result.current[2].definedBy).toBe('USER')

act(() => {
result.current[1](Theme.LIGHT)
Expand All @@ -71,6 +73,7 @@ describe('theme-provider', () => {

expect(global.fetch).toHaveBeenLastCalledWith(...request)
expect(result.current[0]).toBe(Theme.LIGHT)
expect(result.current[2].definedBy).toBe('USER')
})

it('uses the current system theme if no specified theme was provided', async () => {
Expand All @@ -89,9 +92,10 @@ describe('theme-provider', () => {
})

expect(result.current[0]).toBe(preferredTheme)
expect(result.current[2].definedBy).toBe('SYSTEM')
})

it('updates automatically when the system theme changes', async () => {
it('updates automatically when the system theme changes', async () => {
const prefersLightMQ = '(prefers-color-scheme: light)'

const {result} = renderHook(() => useTheme(), {
Expand All @@ -111,6 +115,49 @@ describe('theme-provider', () => {

await waitFor(() => {
expect(result.current[0]).toBe(Theme.DARK)
expect(result.current[2].definedBy).toBe('SYSTEM')
})

act(() => {
mediaQuery?.dispatchEvent(
new MediaQueryListEvent('change', {
media: prefersLightMQ,
matches: true,
}),
)
})

await waitFor(() => {
expect(result.current[0]).toBe(Theme.LIGHT)
expect(result.current[2].definedBy).toBe('SYSTEM')
})
})

it('ignores the system theme if the user already chose one', async () => {
const prefersLightMQ = '(prefers-color-scheme: light)'

const {result} = renderHook(() => useTheme(), {
wrapper: (props: ThemeProviderProps) => (
<ThemeProvider
{...props}
themeAction={themeAction}
specifiedTheme={Theme.LIGHT}
/>
),
})

act(() => {
mediaQuery?.dispatchEvent(
new MediaQueryListEvent('change', {
media: prefersLightMQ,
matches: false,
}),
)
})

await waitFor(() => {
expect(result.current[0]).toBe(Theme.LIGHT)
expect(result.current[2].definedBy).toBe('USER')
})

act(() => {
Expand Down
Loading

0 comments on commit 731f9a9

Please sign in to comment.