Skip to content

Commit 3130ccc

Browse files
committed
chore: move theme switch to full stack component
1 parent 2f8577e commit 3130ccc

File tree

2 files changed

+137
-129
lines changed

2 files changed

+137
-129
lines changed

app/root.tsx

+19-129
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import { useForm, getFormProps } from '@conform-to/react'
2-
import { parseWithZod } from '@conform-to/zod'
3-
import { invariantResponse } from '@epic-web/invariant'
41
import {
52
json,
63
type LoaderFunctionArgs,
7-
type ActionFunctionArgs,
84
type HeadersFunction,
95
type LinksFunction,
106
type MetaFunction,
@@ -17,16 +13,12 @@ import {
1713
Outlet,
1814
Scripts,
1915
ScrollRestoration,
20-
useFetcher,
21-
useFetchers,
2216
useLoaderData,
23-
// useMatches,
2417
useSubmit,
2518
} from '@remix-run/react'
2619
import { withSentry } from '@sentry/remix'
2720
import { useRef, useState } from 'react'
2821
import { HoneypotProvider } from 'remix-utils/honeypot/react'
29-
import { z } from 'zod'
3022
import { LogoIcon, MenuIcon, XIcon } from '#app/components/icons'
3123
import SiteFooter from '#app/components/site-footer.js'
3224
import {
@@ -35,32 +27,32 @@ import {
3527
SheetContent,
3628
Sheet,
3729
} from '#app/components/ui/sheet'
38-
import { GeneralErrorBoundary } from './components/error-boundary.tsx'
39-
import { EpicProgress } from './components/progress-bar.tsx'
40-
import { useToast } from './components/toaster.tsx'
41-
import { Button } from './components/ui/button.tsx'
30+
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
31+
import { EpicProgress } from '#app/components/progress-bar.tsx'
32+
import { useToast } from '#app/components/toaster.tsx'
33+
import { Button } from '#app/components/ui/button.tsx'
4234
import {
4335
DropdownMenu,
4436
DropdownMenuContent,
4537
DropdownMenuItem,
4638
DropdownMenuPortal,
4739
DropdownMenuTrigger,
48-
} from './components/ui/dropdown-menu.tsx'
49-
import { Icon, href as iconsHref } from './components/ui/icon.tsx'
50-
import { EpicToaster } from './components/ui/sonner.tsx'
40+
} from '#app/components/ui/dropdown-menu.tsx'
41+
import { Icon, href as iconsHref } from '#app/components/ui/icon.tsx'
42+
import { EpicToaster } from '#app/components/ui/sonner.tsx'
5143
import tailwindStyleSheetUrl from './styles/tailwind.css?url'
52-
import { getUserId, logout } from './utils/auth.server.ts'
53-
import { ClientHintCheck, getHints, useHints } from './utils/client-hints.tsx'
54-
import { prisma } from './utils/db.server.ts'
55-
import { getEnv } from './utils/env.server.ts'
56-
import { honeypot } from './utils/honeypot.server.ts'
57-
import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx'
58-
import { useNonce } from './utils/nonce-provider.ts'
59-
import { useRequestInfo } from './utils/request-info.ts'
60-
import { type Theme, setTheme, getTheme } from './utils/theme.server.ts'
61-
import { makeTimings, time } from './utils/timing.server.ts'
62-
import { getToast } from './utils/toast.server.ts'
63-
import { /* useOptionalUser, */ useUser } from './utils/user.ts'
44+
import { getUserId, logout } from '#app/utils/auth.server.ts'
45+
import { ClientHintCheck, getHints } from '#app/utils/client-hints.tsx'
46+
import { prisma } from '#app/utils/db.server.ts'
47+
import { getEnv } from '#app/utils/env.server.ts'
48+
import { honeypot } from '#app/utils/honeypot.server.ts'
49+
import { combineHeaders, getDomainUrl, getUserImgSrc } from '#app/utils/misc.tsx'
50+
import { useNonce } from '#app/utils/nonce-provider.ts'
51+
import { type Theme, getTheme } from '#app/utils/theme.server.ts'
52+
import { makeTimings, time } from '#app/utils/timing.server.ts'
53+
import { getToast } from '#app/utils/toast.server.ts'
54+
import { useUser } from '#app/utils/user.ts'
55+
import { ThemeSwitch, useTheme } from '#app/routes/resources+/theme'
6456

6557
export const links: LinksFunction = () => {
6658
return [
@@ -166,26 +158,6 @@ export const headers: HeadersFunction = ({ loaderHeaders }) => {
166158
return headers
167159
}
168160

169-
const ThemeFormSchema = z.object({
170-
theme: z.enum(['system', 'light', 'dark']),
171-
})
172-
173-
export async function action({ request }: ActionFunctionArgs) {
174-
const formData = await request.formData()
175-
const submission = parseWithZod(formData, {
176-
schema: ThemeFormSchema,
177-
})
178-
179-
invariantResponse(submission.status === 'success', 'Invalid theme received')
180-
181-
const { theme } = submission.value
182-
183-
const responseInit = {
184-
headers: { 'set-cookie': setTheme(theme) },
185-
}
186-
return json({ result: submission.reply() }, responseInit)
187-
}
188-
189161
function Document({
190162
children,
191163
nonce,
@@ -396,88 +368,6 @@ export function UserDropdown() {
396368
)
397369
}
398370

399-
/**
400-
* @returns the user's theme preference, or the client hint theme if the user
401-
* has not set a preference.
402-
*/
403-
export function useTheme() {
404-
const hints = useHints()
405-
const requestInfo = useRequestInfo()
406-
const optimisticMode = useOptimisticThemeMode()
407-
if (optimisticMode) {
408-
return optimisticMode === 'system' ? hints.theme : optimisticMode
409-
}
410-
return requestInfo.userPrefs.theme ?? hints.theme
411-
}
412-
413-
/**
414-
* If the user's changing their theme mode preference, this will return the
415-
* value it's being changed to.
416-
*/
417-
export function useOptimisticThemeMode() {
418-
const fetchers = useFetchers()
419-
const themeFetcher = fetchers.find(f => f.formAction === '/')
420-
421-
if (themeFetcher && themeFetcher.formData) {
422-
const submission = parseWithZod(themeFetcher.formData, {
423-
schema: ThemeFormSchema,
424-
})
425-
426-
if (submission.status === 'success') {
427-
return submission.value.theme
428-
}
429-
}
430-
}
431-
432-
export function ThemeSwitch({
433-
userPreference,
434-
}: {
435-
userPreference?: Theme | null
436-
}) {
437-
const fetcher = useFetcher<typeof action>()
438-
439-
const [form] = useForm({
440-
id: 'theme-switch',
441-
lastResult: fetcher.data?.result,
442-
})
443-
444-
const optimisticMode = useOptimisticThemeMode()
445-
const mode = optimisticMode ?? userPreference ?? 'system'
446-
const nextMode =
447-
mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system'
448-
const modeLabel = {
449-
light: (
450-
<Icon name="sun">
451-
<span className="sr-only">Light</span>
452-
</Icon>
453-
),
454-
dark: (
455-
<Icon name="moon">
456-
<span className="sr-only">Dark</span>
457-
</Icon>
458-
),
459-
system: (
460-
<Icon name="laptop">
461-
<span className="sr-only">System</span>
462-
</Icon>
463-
),
464-
}
465-
466-
return (
467-
<fetcher.Form method="POST" {...getFormProps(form)}>
468-
<input type="hidden" name="theme" value={nextMode} />
469-
<div className="flex gap-2">
470-
<button
471-
type="submit"
472-
className="flex h-8 w-8 cursor-pointer items-center justify-center"
473-
>
474-
{modeLabel[mode]}
475-
</button>
476-
</div>
477-
</fetcher.Form>
478-
)
479-
}
480-
481371
export function ErrorBoundary() {
482372
// the nonce doesn't rely on the loader so we can access that
483373
const nonce = useNonce()

app/routes/resources+/theme.tsx

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { useForm, getFormProps } from '@conform-to/react'
2+
import { parseWithZod } from '@conform-to/zod'
3+
import { invariantResponse } from '@epic-web/invariant'
4+
import {
5+
json,
6+
type ActionFunctionArgs,
7+
} from '@remix-run/node'
8+
import {
9+
useFetcher,
10+
useFetchers,
11+
} from '@remix-run/react'
12+
import { z } from 'zod'
13+
import { Icon } from '#app/components/ui/icon.tsx'
14+
import { useHints } from '#app/utils/client-hints.tsx'
15+
import { useRequestInfo } from '#app/utils/request-info.ts'
16+
import { type Theme, setTheme } from '#app/utils/theme.server.ts'
17+
18+
const ThemeFormSchema = z.object({
19+
theme: z.enum(['system', 'light', 'dark']),
20+
})
21+
22+
export async function action({ request }: ActionFunctionArgs) {
23+
const formData = await request.formData()
24+
const submission = parseWithZod(formData, {
25+
schema: ThemeFormSchema,
26+
})
27+
28+
invariantResponse(submission.status === 'success', 'Invalid theme received')
29+
30+
const { theme } = submission.value
31+
32+
const responseInit = {
33+
headers: { 'set-cookie': setTheme(theme) },
34+
}
35+
return json({ result: submission.reply() }, responseInit)
36+
}
37+
38+
export function ThemeSwitch({
39+
userPreference,
40+
}: {
41+
userPreference?: Theme | null
42+
}) {
43+
const fetcher = useFetcher<typeof action>()
44+
45+
const [form] = useForm({
46+
id: 'theme-switch',
47+
lastResult: fetcher.data?.result,
48+
})
49+
50+
const optimisticMode = useOptimisticThemeMode()
51+
const mode = optimisticMode ?? userPreference ?? 'system'
52+
const nextMode =
53+
mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system'
54+
const modeLabel = {
55+
light: (
56+
<Icon name="sun">
57+
<span className="sr-only">Light</span>
58+
</Icon>
59+
),
60+
dark: (
61+
<Icon name="moon">
62+
<span className="sr-only">Dark</span>
63+
</Icon>
64+
),
65+
system: (
66+
<Icon name="laptop">
67+
<span className="sr-only">System</span>
68+
</Icon>
69+
),
70+
}
71+
72+
return (
73+
<fetcher.Form method="POST" {...getFormProps(form)} action='/resources/theme'>
74+
<input type="hidden" name="theme" value={nextMode} />
75+
<div className="flex gap-2">
76+
<button
77+
type="submit"
78+
className="flex h-8 w-8 cursor-pointer items-center justify-center"
79+
>
80+
{modeLabel[mode]}
81+
</button>
82+
</div>
83+
</fetcher.Form>
84+
)
85+
}
86+
87+
/**
88+
* If the user's changing their theme mode preference, this will return the
89+
* value it's being changed to.
90+
*/
91+
export function useOptimisticThemeMode() {
92+
const fetchers = useFetchers()
93+
const themeFetcher = fetchers.find(f => f.formAction === '/')
94+
95+
if (themeFetcher && themeFetcher.formData) {
96+
const submission = parseWithZod(themeFetcher.formData, {
97+
schema: ThemeFormSchema,
98+
})
99+
100+
if (submission.status === 'success') {
101+
return submission.value.theme
102+
}
103+
}
104+
}
105+
106+
/**
107+
* @returns the user's theme preference, or the client hint theme if the user
108+
* has not set a preference.
109+
*/
110+
export function useTheme() {
111+
const hints = useHints()
112+
const requestInfo = useRequestInfo()
113+
const optimisticMode = useOptimisticThemeMode()
114+
if (optimisticMode) {
115+
return optimisticMode === 'system' ? hints.theme : optimisticMode
116+
}
117+
return requestInfo.userPrefs.theme ?? hints.theme
118+
}

0 commit comments

Comments
 (0)