diff --git a/examples/form/app/layout.jsx b/examples/form/app/layout.jsx index 5a32e7b9..a00d4a5e 100644 --- a/examples/form/app/layout.jsx +++ b/examples/form/app/layout.jsx @@ -2,11 +2,18 @@ import { PrimerRoot } from '@barso/ui'; import '@barso/ui/css'; import { DefaultLayout } from '../components/DefaultLayout.jsx'; +import { DefaultHead } from '../components/Head/Head.App'; import { NotificationsProvider } from '../components/Notifications'; +export const metadata = { + title: 'App Router · Default Title', + description: 'App Router · Default Description', +}; + export default function Layout({ children }) { return ( + {children} diff --git a/examples/form/components/Head/Head.App.jsx b/examples/form/components/Head/Head.App.jsx new file mode 100644 index 00000000..5dcd4ec3 --- /dev/null +++ b/examples/form/components/Head/Head.App.jsx @@ -0,0 +1,10 @@ +/* eslint-disable @next/next/no-head-element */ +import { DefaultTags } from './Tags'; + +export function DefaultHead() { + return ( + + + + ); +} diff --git a/examples/form/components/Head/Head.Pages.jsx b/examples/form/components/Head/Head.Pages.jsx new file mode 100644 index 00000000..f9ac0b30 --- /dev/null +++ b/examples/form/components/Head/Head.Pages.jsx @@ -0,0 +1,20 @@ +import NextHead from 'next/head'; + +import { DefaultTags } from './Tags'; + +export function DefaultHead() { + // next/head does not mount child components like a normal React tree. + // Using results in the SSR output being reused on the client, + // so hooks inside the component never run. + // Calling DefaultTags() forces evaluation instead of relying on component mounting. + return {DefaultTags()}; +} + +export function Head({ title, description }) { + return ( + + {title && {title}} + {description && } + + ); +} diff --git a/examples/form/components/Head/Tags.jsx b/examples/form/components/Head/Tags.jsx new file mode 100644 index 00000000..00426612 --- /dev/null +++ b/examples/form/components/Head/Tags.jsx @@ -0,0 +1,13 @@ +'use client'; +import { useMediaQuery } from '@barso/hooks'; + +export function DefaultTags() { + const isDark = useMediaQuery('(prefers-color-scheme: dark)'); + + return ( + <> + + {/* Add other SEO tags or links here as needed */} + + ); +} diff --git a/examples/form/pages/_app.js b/examples/form/pages/_app.js index 8064d86d..58aad699 100644 --- a/examples/form/pages/_app.js +++ b/examples/form/pages/_app.js @@ -1,11 +1,14 @@ import { AutoThemeProvider } from '@barso/ui'; import '@barso/ui/css'; +import { DefaultHead, Head } from '../components/Head/Head.Pages'; import { NotificationsProvider } from '../components/Notifications'; export default function MyApp({ Component, pageProps }) { return ( + + diff --git a/examples/form/pages/pages_router.jsx b/examples/form/pages/pages_router.jsx index 299dd22b..87dbde6f 100644 --- a/examples/form/pages/pages_router.jsx +++ b/examples/form/pages/pages_router.jsx @@ -1,10 +1,12 @@ import { Checkout } from '../components/Checkout.jsx'; import { DefaultLayout } from '../components/DefaultLayout.jsx'; +import { Head } from '../components/Head/Head.Pages.jsx'; import { checkoutFields, product, store } from '../form-config.js'; export default function Home() { return ( + ); diff --git a/examples/form/pages/registration.jsx b/examples/form/pages/registration.jsx index 27e950a2..573dbac8 100644 --- a/examples/form/pages/registration.jsx +++ b/examples/form/pages/registration.jsx @@ -1,10 +1,12 @@ import { DefaultLayout } from '../components/DefaultLayout.jsx'; +import { Head } from '../components/Head/Head.Pages.jsx'; import { Registration } from '../components/Registration.jsx'; import { registrationFields, store } from '../form-config.js'; export default function RegistrationPage() { return ( + ); diff --git a/examples/form/public/favicon-dark.png b/examples/form/public/favicon-dark.png new file mode 100644 index 00000000..58a1efde Binary files /dev/null and b/examples/form/public/favicon-dark.png differ diff --git a/examples/form/public/favicon-light.png b/examples/form/public/favicon-light.png new file mode 100644 index 00000000..f0501728 Binary files /dev/null and b/examples/form/public/favicon-light.png differ diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js index d281bdbf..eaf95150 100644 --- a/packages/hooks/src/index.js +++ b/packages/hooks/src/index.js @@ -1,3 +1,4 @@ export * from './useConfig/index.js'; +export * from './useMediaQuery/index.js'; export * from './useMergedState/index.js'; export * from './useTreeCollapse/index.js'; diff --git a/packages/hooks/src/useMediaQuery/index.js b/packages/hooks/src/useMediaQuery/index.js new file mode 100644 index 00000000..549eaca1 --- /dev/null +++ b/packages/hooks/src/useMediaQuery/index.js @@ -0,0 +1 @@ +export * from './useMediaQuery.js'; diff --git a/packages/hooks/src/useMediaQuery/useMediaQuery.js b/packages/hooks/src/useMediaQuery/useMediaQuery.js new file mode 100644 index 00000000..30c4b7a3 --- /dev/null +++ b/packages/hooks/src/useMediaQuery/useMediaQuery.js @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export function useMediaQuery(query) { + const [matches, setMatches] = useState(() => typeof window !== 'undefined' && !!window.matchMedia(query).matches); + + useEffect(() => { + const media = window.matchMedia(query); + const listener = () => setMatches(media.matches); + + listener(); + + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, [query]); + + return matches; +} diff --git a/packages/hooks/src/useMediaQuery/useMediaQuery.test.js b/packages/hooks/src/useMediaQuery/useMediaQuery.test.js new file mode 100644 index 00000000..58aa7c3a --- /dev/null +++ b/packages/hooks/src/useMediaQuery/useMediaQuery.test.js @@ -0,0 +1,60 @@ +import { act, renderHook } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; + +import { useMediaQuery } from './index.js'; + +describe('useMediaQuery', () => { + describe('renderHook', () => { + let matches = false; + const listeners = new Set(); + + beforeAll(() => { + vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ + get matches() { + return matches; + }, + media: query, + addEventListener: (event, cb) => listeners.add(cb), + removeEventListener: (event, cb) => listeners.delete(cb), + })); + }); + + beforeEach(() => { + matches = false; + listeners.clear(); + }); + + afterAll(() => { + window.matchMedia.mockRestore(); + }); + + it('should return true if the media query matches', () => { + matches = true; + const { result } = renderHook(() => useMediaQuery('(min-width: 600px)')); + expect(result.current).toBe(true); + }); + + it('should return false if the media query does not match', () => { + const { result } = renderHook(() => useMediaQuery('(min-width: 1200px)')); + expect(result.current).toBe(false); + }); + + it('should update when the media query changes', () => { + const { result } = renderHook(() => useMediaQuery('(min-width: 800px)')); + expect(result.current).toBe(false); + + act(() => { + matches = true; + listeners.forEach((cb) => cb()); + }); + expect(result.current).toBe(true); + }); + }); + + describe('SSR', () => { + it('should not throw during SSR and return false', () => { + const TestComponent = () => String(useMediaQuery('(min-width: 600px)')); + expect(renderToString()).toBe('false'); + }); + }); +});