shuding / nextra

Simple, powerful and flexible site generation framework with everything you love from Next.js.
https://nextra.site
MIT License
11.95k stars 1.29k forks source link

Make blog dark mode context more accessible to user | make dark mode sync to custom components in mdx #630

Closed evanetizen closed 2 years ago

evanetizen commented 2 years ago

I'm using Geist UI library to build custom components to import into my Nextra blog. In the config, I have enabled the dark mode button.

However, it's very difficult to access and sync my component to the current theme of the nextra website because the theme context appears to be inaccessible. I looked into the nextra-theme-blog index.js to try to reverse engineer it, and still could not find a good way to sync my custom component the nextra's theme.

Things I have tried:

  1. D3Portillo's local storage event listeners and tips. Very useful. This was the closest I got, linking up a ton of listeners to the toggle button with document.querySelector('[aria-label="Toggle Dark Mode"]') and doing my best to sync everything to the click event. However, this solution wasn't perfect as I could get the custom component to change colors, but not the background. Something bugged.

  2. Installing and implementing another layer of next-themes locally (did not appear to work, maybe because the next-themes built into nextra overrides it). Could not access theme from useThemes, always showed up as undefined.

  3. Using useState/useEffect on _app.js to implement my own theme state. Does not work because nothing seems to re-render when pressing the dark mode toggle button.

 const [theme, setTheme] = useState('light')
 useEffect(() => {
    // let darkActive = !!document.querySelector('.dark')
    // let localStorageTheme = localStorage.getItem('theme')
    // if (!darkActive || localStorageTheme == 'light') {
    //   setTheme('light')
    // }
    // if (darkActive || localStorageTheme == 'dark') {
    //   setTheme('dark')
    // }
    // console.log('theme', theme)
    // console.log('darkactive? ', darkActive)
    // console.log('local storage', localStorageTheme)
  }, [theme])

return(     
 <GeistProvider themeType={theme}>
      <Component {...pageProps} />
</GeistProvider>
)

Current Behavior: When I refresh the page, I am able to sync the component to the current theme in local storage. So every time I refresh, everything works. But when I press the toggle button, everything on the page changes except my custom component. image image

(the links change color because of nextra's css styling)

I looked at the structure in React devtools to try to find the appropriate context to consume, but to no avail.

My question is, I'm using a UI library that allows me to very easily shift from light mode to dark mode with the change of one variable (themeType). How can I sync this to the native nextra toggle button?

evanetizen commented 2 years ago

Super hackey, temporary solution: listen to the class change on the html. Not too sure of the extents of the bugs, but the code below checks if there's already a theme in the local storage and sets the initial value. Then it sets up a mutation observer to check for the .dark class on the html element and sets the theme using useState. The theme is a dependency in the useEffect hook, so the app rerenders every time the theme changes. Then set your UI library theme to the theme variable.

This solution seems to have a continuous rerendering issue, so make sure you put a cleanup function in useEffect. Without it, your app continuously gets slower the more times you press the toggle button.

This solution appears to be the best of both worlds, able to change the background and other nextra things, while also syncing up your custom React elements to your own theme useState. Again, not a perfect solution, but seems to sync things well enough.

import { useEffect } from 'react'
import { useState } from 'react'
export default function Nextra({ Component, pageProps }) {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    if (localStorage.getItem('theme')) {
      setTheme(localStorage.getItem('theme'))
    }

    const html = document.querySelector('html')
    const options = {
      attributes: true
    }

    function callback(mutationsList, observer) {
      mutationsList.forEach((mutation) => {
        if (
          mutation.type == 'attributes' &&
          mutation.attributeName == 'class'
        ) {
          console.log('change')
          setTheme(theme == 'light' ? 'dark' : 'light')
        }
      })
    }
    const observer = new MutationObserver(callback)
    observer.observe(html, options)

    return () => {
      observer.disconnect()
    }

  }, [theme])

  return (
    <>
      <Head>
        <link
          rel="alternate"
          type="application/rss+xml"
          title="RSS"
          href="/feed.xml"
        />
        <link
          rel="preload"
          href="/fonts/Inter-roman.latin.var.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
      </Head>
      <GeistProvider themeType={theme}>
        <Component {...pageProps} />
      </GeistProvider>
    </>
  )
}
shuding commented 2 years ago

I think Nextra themes should expose a API to give the current context to components:

import { useNextra } from 'nextra-theme-blog'

function MyButton() {
  const { theme, setTheme, /* maybe other things? */ } = useNextra()
}
dimaMachina commented 2 years ago

@shuding yes, so user will no longer need to install next-themes if he wants to use theme/setTheme

Also useNextra can return config data https://github.com/shuding/nextra/blob/96ed5c2056da5651651bdc93e2848fafd7203ff4/packages/nextra-theme-docs/src/config.ts#L11

promer94 commented 2 years ago

currently we have a around https://github.com/shuding/nextra/discussions/490 here

we could just re-export next-theme's useTheme hooks. Putting everything in a single context does not feel right.