Skip to content

Commit

Permalink
rework tabs query state to work better across both routers
Browse files Browse the repository at this point in the history
  • Loading branch information
charislam committed Apr 25, 2024
1 parent 239caec commit c5cd516
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 64 deletions.
18 changes: 18 additions & 0 deletions apps/docs/components/GuidesTableOfContents.tsx
Expand Up @@ -9,6 +9,7 @@ import useHash from '~/hooks/useHash'
import { useRerenderOnEvt } from '~/hooks/useManualRerender'
import { removeAnchor } from './CustomHTMLElements/CustomHTMLElements.utils'
import { Feedback } from './Feedback'
import { proxy, useSnapshot } from 'valtio'

const formatSlug = (slug: string) => {
// [Joshen] We will still provide support for headers declared like this:
Expand Down Expand Up @@ -37,6 +38,21 @@ const formatTOCHeader = (content: string) => {
return res.join('')
}

const tocRenderSwitch = proxy({
renderFlag: 0,
toggleRenderFlag: () => void (tocRenderSwitch.renderFlag = (tocRenderSwitch.renderFlag + 1) % 2),
})

const useSubscribeTocRerender = () => {
const { renderFlag } = useSnapshot(tocRenderSwitch)
return void renderFlag // Prevent it from being detected as unused code
}

const useTocRerenderTrigger = () => {
const { toggleRenderFlag } = useSnapshot(tocRenderSwitch)
return toggleRenderFlag
}

const GuidesTableOfContents = ({
className,
overrideToc,
Expand All @@ -46,6 +62,7 @@ const GuidesTableOfContents = ({
overrideToc?: Array<{ text: string; link: string; level: number }>
video?: string
}) => {
useSubscribeTocRerender()
const [tocList, setTocList] = useState([])
const pathname = usePathname()
const [hash] = useHash()
Expand Down Expand Up @@ -126,3 +143,4 @@ const GuidesTableOfContents = ({
}

export default GuidesTableOfContents
export { useTocRerenderTrigger }
21 changes: 21 additions & 0 deletions apps/docs/components/Tabs.tsx
@@ -0,0 +1,21 @@
'use client'

import { useCallback, type ComponentProps } from 'react'
import { Tabs as TabsPrimitive } from 'ui'
import { useTocRerenderTrigger } from '~/components/GuidesTableOfContents'

const TabPanel = TabsPrimitive.Panel
const Tabs = ({ onChange, ...props }: ComponentProps<typeof TabsPrimitive>) => {
const rerenderToc = useTocRerenderTrigger()
const onChangeInternal = useCallback(
(...args: Parameters<typeof onChange>) => {
rerenderToc()
onChange?.(...args)
},
[rerenderToc, onChange]
)

return <TabsPrimitive wrappable onChange={onChangeInternal} {...props} />
}

export { Tabs, TabPanel }
7 changes: 4 additions & 3 deletions apps/docs/components/index.tsx
Expand Up @@ -4,10 +4,11 @@

// Basic UI things
import Link from 'next/link'
import { Accordion, Admonition, Alert, Button, CodeBlock, markdownComponents, Tabs } from 'ui'
import { Accordion, Admonition, Alert, Button, CodeBlock, markdownComponents } from 'ui'
import { GlassPanel } from 'ui-patterns/GlassPanel'
import { IconPanel } from 'ui-patterns/IconPanel'
import { ThemeImage } from 'ui-patterns/ThemeImage'
import { TabPanel, Tabs } from '~/components/Tabs'

// Common components
import { CH } from '@code-hike/mdx/components'
Expand Down Expand Up @@ -152,8 +153,8 @@ const components = {
SocialProviderSettingsSupabase,
SocialProviderSetup,
StepHikeCompact,
TabPanel: (props: any) => <Tabs.Panel {...props}>{props.children}</Tabs.Panel>,
Tabs: (props: any) => <Tabs wrappable {...props} />,
TabPanel,
Tabs,
}

export default components
6 changes: 3 additions & 3 deletions apps/docs/pages/guides/database/database-linter.tsx
Expand Up @@ -8,10 +8,10 @@ import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'

import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' }
import { Tabs } from 'ui'

import components from '~/components'
import { Heading } from '~/components/CustomHTMLElements'
import { TabPanel, Tabs } from '~/components/Tabs'
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import Layout from '~/layouts/DefaultGuideLayout'
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
Expand Down Expand Up @@ -52,15 +52,15 @@ export default function ProjectLinterDocs({
<Heading tag="h2">Available lints</Heading>
<Tabs listClassNames="flex flex-wrap gap-2 [&>button]:!m-0" queryGroup="lint">
{lints.map((lint) => (
<Tabs.Panel
<TabPanel
key={lint.path}
id={lint.path}
label={capitalize(getBasename(lint.path).replace(/_/g, ' '))}
>
<section id={getBasename(lint.path)}>
<MDXRemote {...lint.content} components={components} />
</section>
</Tabs.Panel>
</TabPanel>
))}
</Tabs>
</Layout>
Expand Down
1 change: 1 addition & 0 deletions packages/common/hooks/index.ts
Expand Up @@ -4,5 +4,6 @@ export * from './useCopy'
export * from './useDebounce'
export * from './useDocsSearch'
export * from './useParams'
export * from './useSearchParamsShallow'
export * from './useTelemetryProps'
export * from './useThemeSandbox'
102 changes: 102 additions & 0 deletions packages/common/hooks/useSearchParamsShallow.ts
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useId, useMemo, useReducer, useRef } from 'react'

/**
* Stores state in search params while bypassing Next Router.
*
* The purpose of this is to use search params while maintaining SSG ability,
* because Next.js's `useSearchParams` forces at least the children to be
* client-side rendered on static routes.
*
* See https://nextjs.org/docs/app/api-reference/functions/use-search-params
*/
const useSearchParamsShallow = () => {
const EVENT_NAME = 'supabase.events.packages.common.useSearchParamsShallow'
const id = useId()
const timeoutHandle = useRef<ReturnType<typeof setTimeout>>()

const reducer = useCallback(
(_: URLSearchParams, action: { target: 'int' | 'ext'; newParams: URLSearchParams }) => {
clearTimeout(timeoutHandle.current)
if (action.target === 'ext') {
/**
* Doing this in the next tick makes sure that the originating
* component finishes rendering before it triggers updates to other
* components.
*/
timeoutHandle.current = setTimeout(() => {
document.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: { id } }))
})
}
return action.newParams
},
[id]
)

const [localParams, setLocalParams] = useReducer(reducer, undefined, () => new URLSearchParams())

useEffect(() => {
const handler = (event: CustomEvent<{ id: string }>) => {
if (event.detail.id !== id) {
const globalParams = new URLSearchParams(window.location.search)
setLocalParams({ target: 'int', newParams: globalParams })
}
}

document.addEventListener(EVENT_NAME, handler as EventListener)
return () => document.removeEventListener(EVENT_NAME, handler as EventListener)
}, [id])

useEffect(() => {
const globalParams = new URLSearchParams(window.location.search)
setLocalParams({ target: 'int', newParams: globalParams })
}, [])

const has = useCallback((key: string) => localParams.has(key), [localParams])

const get = useCallback((key: string) => localParams.get(key), [localParams])

const getAll = useCallback((key: string) => localParams.getAll(key), [localParams])

const set = useCallback((key: string, value: any) => {
if (typeof window === 'undefined') return

const url = new URL(window.location.href)
url.searchParams.set(key, value)
window.history.replaceState(null, '', url)
setLocalParams({ target: 'ext', newParams: url.searchParams })
}, [])

const append = useCallback((key: string, value: any) => {
if (typeof window === 'undefined') return

const url = new URL(window.location.href)
url.searchParams.append(key, value)
window.history.replaceState(null, '', url)
setLocalParams({ target: 'ext', newParams: url.searchParams })
}, [])

const _delete = useCallback((key: string) => {
if (typeof window === 'undefined') return

const url = new URL(window.location.href)
url.searchParams.delete(key)
window.history.replaceState(null, '', url)
setLocalParams({ target: 'ext', newParams: url.searchParams })
}, [])

const api = useMemo(
() => ({
has,
get,
getAll,
append,
set,
delete: _delete,
}),
[has, get, getAll, append, set, _delete]
)

return api
}

export { useSearchParamsShallow }
75 changes: 20 additions & 55 deletions packages/ui/src/components/Tabs/Tabs.tsx
@@ -1,15 +1,13 @@
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { useRouter } from 'next/router'
import { useSearchParamsShallow } from 'common'
import {
Children,
type KeyboardEvent,
type MouseEvent,
PropsWithChildren,
useEffect,
useState,
type KeyboardEvent,
type MouseEvent,
type PropsWithChildren,
} from 'react'

import { TAB_CHANGE_EVENT_NAME } from '../../lib/events'
import styleHandler from '../../lib/theme/styleHandler'
import { useTabGroup } from './TabsProvider'

Expand Down Expand Up @@ -58,9 +56,8 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
const children = Children.toArray(_children) as PanelPropsProps[]
const tabIds = children.map((tab) => tab.props.id)

const router = useRouter()
const queryTabs = queryGroup ? router.query[queryGroup] : undefined
const [queryTabRaw] = Array.isArray(queryTabs) ? queryTabs : [queryTabs]
const searchParams = useSearchParamsShallow()
const queryTabRaw = queryGroup && searchParams.get(queryGroup)
const queryTab = queryTabRaw && tabIds.includes(queryTabRaw) ? queryTabRaw : undefined

const [activeTab, setActiveTab] = useState(
Expand All @@ -70,61 +67,31 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
children?.[0]?.props?.id
)

useEffect(() => {
/**
* [Charis] The query param change is done by manual manipulation of window
* location and history, not by router.push (I think to avoid full-page
* rerenders). This doesn't reliably trigger rerender of all tabs on the
* page, possibly because it bypasses `useRouter`. The only way I could
* find of avoiding the full-page rerender but still reacting reliably to
* search param changes was to fire a CustomEvent.
*/

function handleChange(e: CustomEvent) {
if (
e.detail.queryGroup &&
e.detail.queryGroup === queryGroup &&
tabIds.includes(e.detail.id)
) {
setActiveTab(e.detail.id)
setGroupActiveId?.(e.detail.id)
}
}

window.addEventListener(TAB_CHANGE_EVENT_NAME, handleChange as EventListener)
return () => window.removeEventListener(TAB_CHANGE_EVENT_NAME, handleChange as EventListener)
}, [])
const { groupActiveId, setGroupActiveId } = useTabGroup(tabIds)

// If query param present for the query group, switch to that tab.
/**
* Can't shortcut the render here by taking this out of useEffect because
* `setActiveGroupId` comes from `TabProvider`
*/
useEffect(() => {
if (queryTab) {
setActiveTab(queryTab)
setGroupActiveId?.(queryTab)
}
}, [queryTab])

let __styles = styleHandler('tabs')

const { groupActiveId, setGroupActiveId } = useTabGroup(tabIds)

const active = activeId ?? groupActiveId ?? activeTab

function onTabClick(currentTarget: EventTarget, id: string) {
setActiveTab(id)
setGroupActiveId?.(id)
let __styles = styleHandler('tabs')

function onTabClick(id: string) {
if (queryGroup) {
const url = new URL(document.location.href)
if (!url.searchParams.getAll('queryGroups')?.includes(queryGroup))
url.searchParams.append('queryGroups', queryGroup)
url.searchParams.set(queryGroup, id)
window.history.replaceState(undefined, '', url)
if (!searchParams.getAll('queryGroups').includes(queryGroup)) {
searchParams.append('queryGroups', queryGroup)
}
searchParams.set(queryGroup, id)
}

currentTarget.dispatchEvent(
new CustomEvent(TAB_CHANGE_EVENT_NAME, { bubbles: true, detail: { queryGroup, id } })
)

onClick?.(id)
if (id !== active) {
onChange?.(id)
Expand Down Expand Up @@ -155,14 +122,12 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
return (
<TabsPrimitive.Trigger
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
if (e.keyCode === 13) {
if (e.key === 'Enter') {
e.preventDefault()
onTabClick(e.currentTarget, tab.props.id)
onTabClick(tab.props.id)
}
}}
onClick={(e: MouseEvent<HTMLButtonElement>) =>
onTabClick(e.currentTarget, tab.props.id)
}
onClick={(e: MouseEvent<HTMLButtonElement>) => onTabClick(tab.props.id)}
key={`${tab.props.id}-tab-button`}
value={tab.props.id}
className={triggerClasses.join(' ')}
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/components/Tabs/TabsProvider.tsx
@@ -1,8 +1,8 @@
import { xor } from 'lodash'
import {
Dispatch,
PropsWithChildren,
SetStateAction,
type Dispatch,
type PropsWithChildren,
type SetStateAction,
createContext,
useCallback,
useContext,
Expand Down

0 comments on commit c5cd516

Please sign in to comment.