mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
rework tabs query state to work better across both routers (#23253)
Refactor tabs so that: - Behavior add-ons, such as query params, are HOCs in ui-patterns, to keep the basic Tabs minimal - No dependency on next/router (this is to smooth out a separate App Router migration PR) - No dependency on useSearchParams either (this is to preserve static generation of as much HTML content as possible, for SEO purposes -- see comment about client-rendering bailout when using useSearchParams) - Syncing behavior better localized in a hook Minor enhancement to save tab preferences to local storage so they are remembered between sessions
This commit is contained in:
1 parent
b149fa975d
commit
2f4f0adbd1
17 files changed
+375
-320
No files matched your search
@@ -1,14 +1,14 @@
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn, TAB_CHANGE_EVENT_NAME } from 'ui'
|
||||
import { ExpandableVideo } from 'ui-patterns'
|
||||
|
||||
import { highlightSelectedTocItem } from '~/components/CustomHTMLElements/CustomHTMLElements.utils'
|
||||
import { cn } from 'ui'
|
||||
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import {
|
||||
highlightSelectedTocItem,
|
||||
removeAnchor,
|
||||
} from '~/components/CustomHTMLElements/CustomHTMLElements.utils'
|
||||
import { Feedback } from '~/components/Feedback'
|
||||
import useHash from '~/hooks/useHash'
|
||||
import { useRerenderOnEvt } from '~/hooks/useManualRerender'
|
||||
import { removeAnchor } from './CustomHTMLElements/CustomHTMLElements.utils'
|
||||
import { Feedback } from './Feedback'
|
||||
|
||||
const formatSlug = (slug: string) => {
|
||||
// [Joshen] We will still provide support for headers declared like this:
|
||||
@@ -37,6 +37,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,
|
||||
@@ -46,6 +61,7 @@ const GuidesTableOfContents = ({
|
||||
overrideToc?: Array<{ text: string; link: string; level: number }>
|
||||
video?: string
|
||||
}) => {
|
||||
useSubscribeTocRerender()
|
||||
const [tocList, setTocList] = useState([])
|
||||
const pathname = usePathname()
|
||||
const [hash] = useHash()
|
||||
@@ -77,7 +93,7 @@ const GuidesTableOfContents = ({
|
||||
return () => clearTimeout(timeoutHandle)
|
||||
/**
|
||||
* window.location.href needed to recalculate toc when page changes,
|
||||
* useRerenderOnEvt below will guarantee rerender on change
|
||||
* `useSubscribeTocRerender` above will trigger the rerender
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideToc, typeof window !== 'undefined' && window.location.href])
|
||||
@@ -89,12 +105,6 @@ const GuidesTableOfContents = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hash, JSON.stringify(displayedList)])
|
||||
|
||||
/**
|
||||
* Displayed headings may change if the tab changes, so the table of contents
|
||||
* needs to rerender.
|
||||
*/
|
||||
useRerenderOnEvt(TAB_CHANGE_EVENT_NAME)
|
||||
|
||||
if (!displayedList.length) return
|
||||
|
||||
const tocVideoPreview = `http://img.youtube.com/vi/${video}/0.jpg`
|
||||
@@ -126,3 +136,4 @@ const GuidesTableOfContents = ({
|
||||
}
|
||||
|
||||
export default GuidesTableOfContents
|
||||
export { useTocRerenderTrigger }
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, type ComponentProps } from 'react'
|
||||
import { Tabs as TabsPrimitive } from 'ui'
|
||||
import { withQueryParams } from 'ui-patterns/ComplexTabs'
|
||||
import { useTocRerenderTrigger } from '~/components/GuidesTableOfContents'
|
||||
|
||||
const TabsWithQueryParams = withQueryParams(TabsPrimitive)
|
||||
|
||||
const TabPanel = TabsPrimitive.Panel
|
||||
const Tabs = ({ onChange, ...props }: ComponentProps<typeof TabsWithQueryParams>) => {
|
||||
const rerenderToc = useTocRerenderTrigger()
|
||||
const onChangeInternal = useCallback(
|
||||
(...args: Parameters<typeof onChange>) => {
|
||||
rerenderToc()
|
||||
onChange?.(...args)
|
||||
},
|
||||
[rerenderToc, onChange]
|
||||
)
|
||||
|
||||
return <TabsWithQueryParams wrappable onChange={onChangeInternal} {...props} />
|
||||
}
|
||||
|
||||
export { Tabs, TabPanel }
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CodeBlock, Tabs } from 'ui'
|
||||
import { CodeBlock } from 'ui'
|
||||
import Param from '~/components/Params'
|
||||
import RefSubLayout from '~/layouts/ref/RefSubLayout'
|
||||
import { Tabs, TabPanel } from '~/components/Tabs'
|
||||
import { ReactMarkdown } from 'react-markdown/lib/react-markdown'
|
||||
|
||||
const ApiOperationSection = (props) => {
|
||||
@@ -96,7 +97,7 @@ const ApiOperationSection = (props) => {
|
||||
queryGroup="response-status"
|
||||
>
|
||||
{operation.responseList.map((response: any, i: number) => (
|
||||
<Tabs.Panel key={i} id={response.responseCode} label={response.responseCode}>
|
||||
<TabPanel key={i} id={response.responseCode} label={response.responseCode}>
|
||||
<ReactMarkdown className="text-foreground">{response.description}</ReactMarkdown>
|
||||
{response?.content && response?.content['application/json'] && (
|
||||
<div className="mt-8">
|
||||
@@ -105,7 +106,7 @@ const ApiOperationSection = (props) => {
|
||||
</CodeBlock>
|
||||
</div>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { CodeBlock, IconChevronRight, Tabs } from 'ui'
|
||||
import { CodeBlock, IconChevronRight } from 'ui'
|
||||
import spec from '~/spec/cli_v1_commands.yaml' assert { type: 'yml' }
|
||||
import Options from '~/components/Options'
|
||||
import Param from '~/components/Params'
|
||||
import { Tabs, TabPanel } from '~/components/Tabs'
|
||||
import RefSubLayout from '~/layouts/ref/RefSubLayout'
|
||||
import RefDetailCollapse from './RefDetailCollapse'
|
||||
|
||||
@@ -142,7 +143,7 @@ const CliCommandSection = (props) => {
|
||||
command.examples.map((example) => {
|
||||
const exampleId = `${command.id}-${example.id}`
|
||||
return (
|
||||
<Tabs.Panel
|
||||
<TabPanel
|
||||
id={exampleId}
|
||||
key={exampleId}
|
||||
label={example.name}
|
||||
@@ -181,12 +182,12 @@ const CliCommandSection = (props) => {
|
||||
</div>
|
||||
</RefDetailCollapse>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</TabPanel>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
// TODO: remove this block once all commands have examples
|
||||
<Tabs.Panel
|
||||
<TabPanel
|
||||
id={`${command.id}-basic-usage`}
|
||||
key={`${command.id}-basic-usage`}
|
||||
label="Basic usage"
|
||||
@@ -199,7 +200,7 @@ const CliCommandSection = (props) => {
|
||||
>
|
||||
{command.usage}
|
||||
</CodeBlock>
|
||||
</Tabs.Panel>
|
||||
</TabPanel>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Fragment } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { CodeBlock, IconDatabase, Tabs } from 'ui'
|
||||
|
||||
import { CodeBlock, IconDatabase } from 'ui'
|
||||
import components from '~/components'
|
||||
import Options from '~/components/Options'
|
||||
import Param from '~/components/Params'
|
||||
import { Tabs, TabPanel } from '~/components/Tabs'
|
||||
import RefDetailCollapse from '~/components/reference/RefDetailCollapse'
|
||||
import RefSubLayout from '~/layouts/ref/RefSubLayout'
|
||||
import { extractTsDocNode, generateParameters } from '~/lib/refGenerator/helpers'
|
||||
|
||||
import RefDetailCollapse from '~/components/reference/RefDetailCollapse'
|
||||
import { Fragment } from 'react'
|
||||
import { IRefFunctionSection } from './Reference.types'
|
||||
import components from '~/components'
|
||||
|
||||
const RefFunctionSection: React.FC<IRefFunctionSection> = (props) => {
|
||||
const item = props.spec.functions.find((x: any) => x.id === props.funcData.id)
|
||||
@@ -138,7 +136,7 @@ const RefFunctionSection: React.FC<IRefFunctionSection> = (props) => {
|
||||
const tables = staticExample?.data?.tables
|
||||
|
||||
return (
|
||||
<Tabs.Panel
|
||||
<TabPanel
|
||||
id={example.id}
|
||||
key={exampleIndex}
|
||||
label={example.name}
|
||||
@@ -219,7 +217,7 @@ const RefFunctionSection: React.FC<IRefFunctionSection> = (props) => {
|
||||
</div>
|
||||
</RefDetailCollapse>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</TabPanel>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useEffect, useReducer } from 'react'
|
||||
|
||||
/**
|
||||
* An escape hatch to manually trigger component rerenders in response to
|
||||
* external events, such as fired custom events.
|
||||
*/
|
||||
const useRerenderOnEvt = (event: string, listeningElem?: Document | Window | HTMLElement) => {
|
||||
const [, rerender] = useReducer((state) => !state, true)
|
||||
|
||||
useEffect(() => {
|
||||
const elem = listeningElem ?? window
|
||||
elem.addEventListener(event, rerender)
|
||||
return () => elem.removeEventListener(event, rerender)
|
||||
}, [event, listeningElem, rerender])
|
||||
}
|
||||
|
||||
export { useRerenderOnEvt }
|
||||
@@ -11,7 +11,7 @@ import { AuthProvider, ThemeProvider, useTelemetryProps, useThemeSandbox } from
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useEffect, useState, type PropsWithChildren } from 'react'
|
||||
import { PortalToast, PromoToast, TabsProvider } from 'ui'
|
||||
import { PortalToast, PromoToast } from 'ui'
|
||||
import { CommandMenuProvider } from 'ui-patterns/Cmdk'
|
||||
import { useConsent } from 'ui-patterns/ConsentToast'
|
||||
|
||||
@@ -225,15 +225,13 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
<SignOutHandler>
|
||||
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<CommandMenuProvider site="docs">
|
||||
<TabsProvider>
|
||||
<div className="h-screen flex flex-col">
|
||||
<SiteLayout>
|
||||
<PortalToast />
|
||||
<PromoToast />
|
||||
<Component {...pageProps} />
|
||||
</SiteLayout>
|
||||
</div>
|
||||
</TabsProvider>
|
||||
<div className="h-screen flex flex-col">
|
||||
<SiteLayout>
|
||||
<PortalToast />
|
||||
<PromoToast />
|
||||
<Component {...pageProps} />
|
||||
</SiteLayout>
|
||||
</div>
|
||||
</CommandMenuProvider>
|
||||
</ThemeProvider>
|
||||
</SignOutHandler>
|
||||
|
||||
@@ -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'
|
||||
@@ -52,7 +52,7 @@ export default function DatabaseAdvisorDocs({
|
||||
<Heading tag="h2">Available checks</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, ' '))}
|
||||
@@ -60,7 +60,7 @@ export default function DatabaseAdvisorDocs({
|
||||
<section id={getBasename(lint.path)}>
|
||||
<MDXRemote {...lint.content} components={components} />
|
||||
</section>
|
||||
</Tabs.Panel>
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Layout>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ModalProps } from '@ui/components/Modal/Modal'
|
||||
import { snakeCase } from 'lodash'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Button, CodeBlock, IconExternalLink, Modal, Tabs, TabsProvider } from 'ui'
|
||||
import { Button, CodeBlock, IconExternalLink, Modal, Tabs } from 'ui'
|
||||
|
||||
import TwoOptionToggle from 'components/ui/TwoOptionToggle'
|
||||
import { useSqlEditorStateSnapshot } from 'state/sql-editor'
|
||||
@@ -60,78 +60,66 @@ const DownloadSnippetModal = ({ id, ...props }: DownloadSnippetModalProps) => {
|
||||
header={<p>Download snippet as local migration file via the Supabase CLI.</p>}
|
||||
{...props}
|
||||
>
|
||||
<TabsProvider>
|
||||
<div className="flex flex-col items-start justify-between gap-4 py-5 relative">
|
||||
<Tabs type="underlined" listClassNames="pl-5">
|
||||
{SNIPPETS.map((snippet) => {
|
||||
return (
|
||||
<Tabs.Panel key={snippet.id} id={snippet.id} label={snippet.label} className="px-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h2 className="text-lg">{snippet.title}</h2>
|
||||
<Markdown
|
||||
className="text-sm text-scale-1000 [&>p>code]:!break-normal"
|
||||
content={snippet.description}
|
||||
/>
|
||||
</div>
|
||||
<TwoOptionToggle
|
||||
width={75}
|
||||
options={['CLI', 'NPM']}
|
||||
activeOption={selectedView}
|
||||
borderOverride="border-muted"
|
||||
onClickOption={() =>
|
||||
selectedView === 'CLI' ? setSelectedView('NPM') : setSelectedView('CLI')
|
||||
}
|
||||
<div className="flex flex-col items-start justify-between gap-4 py-5 relative">
|
||||
<Tabs type="underlined" listClassNames="pl-5">
|
||||
{SNIPPETS.map((snippet) => {
|
||||
return (
|
||||
<Tabs.Panel key={snippet.id} id={snippet.id} label={snippet.label} className="px-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h2 className="text-lg">{snippet.title}</h2>
|
||||
<Markdown
|
||||
className="text-sm text-scale-1000 [&>p>code]:!break-normal"
|
||||
content={snippet.description}
|
||||
/>
|
||||
</div>
|
||||
<pre>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
className="language-bash prose dark:prose-dark max-w-none"
|
||||
>
|
||||
{selectedView === 'CLI' ? snippet.cli : snippet.npm}
|
||||
</CodeBlock>
|
||||
</pre>
|
||||
</Tabs.Panel>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<p className="text-xs text-lighter mx-5">
|
||||
Run this command from your project directory
|
||||
</p>
|
||||
<div className="flex justify-between items-center gap-x-2 mx-5">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={<IconExternalLink size={14} strokeWidth={1.5} />}
|
||||
<TwoOptionToggle
|
||||
width={75}
|
||||
options={['CLI', 'NPM']}
|
||||
activeOption={selectedView}
|
||||
borderOverride="border-muted"
|
||||
onClickOption={() =>
|
||||
selectedView === 'CLI' ? setSelectedView('NPM') : setSelectedView('CLI')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<pre>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
className="language-bash prose dark:prose-dark max-w-none"
|
||||
>
|
||||
{selectedView === 'CLI' ? snippet.cli : snippet.npm}
|
||||
</CodeBlock>
|
||||
</pre>
|
||||
</Tabs.Panel>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<p className="text-xs text-lighter mx-5">Run this command from your project directory</p>
|
||||
<div className="flex justify-between items-center gap-x-2 mx-5">
|
||||
<Button asChild type="default" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/cli/local-development#database-migrations"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/cli/local-development#database-migrations"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
About migrations
|
||||
</Link>
|
||||
</Button>
|
||||
About migrations
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={<IconExternalLink size={14} strokeWidth={1.5} />}
|
||||
<Button asChild type="default" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/cli/local-development"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/cli/local-development"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
About CLI
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
About CLI
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsProvider>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,104 @@
|
||||
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]
|
||||
)
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutHandle.current), [])
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1 @@
|
||||
export * from './withQueryParams'
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useSearchParamsShallow } from 'common'
|
||||
import { xor } from 'lodash'
|
||||
import { Children, useEffect, useRef, type PropsWithChildren } from 'react'
|
||||
import { type Tabs, type TabsProps } from 'ui'
|
||||
|
||||
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'supabase.ui-patterns.ComplexTabs.withQueryParams.v0'
|
||||
|
||||
/**
|
||||
* Wraps the basic `Tabs` component from the `ui` library so it stores
|
||||
* selection state in query params.
|
||||
*/
|
||||
const withQueryParams =
|
||||
(Component: typeof Tabs) =>
|
||||
({
|
||||
children: childrenUnvalidated,
|
||||
queryGroup: queryGroupTemp,
|
||||
onClick,
|
||||
...props
|
||||
}: PropsWithChildren<TabsProps & { queryGroup?: string }>) => {
|
||||
const children = Children.toArray(childrenUnvalidated)
|
||||
const tabIdsTemp = children
|
||||
.map((child) => !!child && typeof child === 'object' && 'props' in child && child.props.id)
|
||||
.filter(isString)
|
||||
// Store in ref to avoid stale data in later timeout
|
||||
const tabIdsRef = useRef(tabIdsTemp)
|
||||
tabIdsRef.current = tabIdsTemp
|
||||
|
||||
// Store in ref to avoid stale data in later timeout
|
||||
const queryGroupRef = useRef(queryGroupTemp)
|
||||
queryGroupRef.current = queryGroupTemp
|
||||
|
||||
const searchParams = useSearchParamsShallow()
|
||||
const queryTabMaybe = queryGroupRef.current && searchParams.get(queryGroupRef.current)
|
||||
const queryTab =
|
||||
queryTabMaybe && tabIdsRef.current.includes(queryTabMaybe) ? queryTabMaybe : undefined
|
||||
|
||||
const checkedLocalStorage = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!checkedLocalStorage.current) {
|
||||
// Timeout to avoid something (I think the router) overwriting it
|
||||
setTimeout(() => {
|
||||
if (
|
||||
queryGroupRef.current &&
|
||||
!new URLSearchParams(window.location.search).has(queryGroupRef.current)
|
||||
) {
|
||||
try {
|
||||
const storedValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
|
||||
if (storedValues === null || typeof storedValues !== 'object') return
|
||||
|
||||
let storedValue: any = null
|
||||
let maxDiff = tabIdsRef.current.length
|
||||
Object.entries(storedValues).forEach(([key, value]) => {
|
||||
const arr = key.split(',')
|
||||
const diff = xor(arr, tabIdsRef.current)
|
||||
if (diff.length < maxDiff) {
|
||||
maxDiff = diff.length
|
||||
storedValue = value
|
||||
}
|
||||
})
|
||||
|
||||
if (storedValue && tabIdsRef.current.includes(storedValue)) {
|
||||
switchTab(storedValue)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, 300)
|
||||
|
||||
checkedLocalStorage.current = true
|
||||
}
|
||||
|
||||
if (queryGroupRef.current && queryTab) {
|
||||
let updatedValues: Record<string, unknown> = {}
|
||||
try {
|
||||
const oldValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
|
||||
if (oldValues && typeof oldValues === 'object') {
|
||||
updatedValues = oldValues
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
updatedValues[tabIdsRef.current.sort().join(',')] = queryTab
|
||||
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedValues))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [queryTab])
|
||||
|
||||
const switchTab = (id: string) => {
|
||||
if (queryGroupRef.current) {
|
||||
if (!searchParams.getAll('queryGroups').includes(queryGroupRef.current)) {
|
||||
searchParams.append('queryGroups', queryGroupRef.current)
|
||||
}
|
||||
searchParams.set(queryGroupRef.current, id)
|
||||
}
|
||||
}
|
||||
|
||||
const onTabClick = (id: string) => {
|
||||
switchTab(id)
|
||||
onClick?.(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Component {...props} activeId={queryTab} onClick={onTabClick}>
|
||||
{/* Tabs does its own validation */}
|
||||
{childrenUnvalidated}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
export { withQueryParams }
|
||||
@@ -1,24 +1,12 @@
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
Children,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
PropsWithChildren,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { TAB_CHANGE_EVENT_NAME } from '../../lib/events'
|
||||
import { Children, useMemo, useState, type KeyboardEvent, type PropsWithChildren } from 'react'
|
||||
import styleHandler from '../../lib/theme/styleHandler'
|
||||
import { useTabGroup } from './TabsProvider'
|
||||
|
||||
interface TabsProps {
|
||||
export interface TabsProps {
|
||||
type?: 'pills' | 'underlined' | 'cards' | 'rounded-pills'
|
||||
defaultActiveId?: string
|
||||
activeId?: string
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
|
||||
queryGroup?: string
|
||||
block?: boolean
|
||||
tabBarGutter?: number
|
||||
tabBarStyle?: React.CSSProperties
|
||||
@@ -41,7 +29,6 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
|
||||
activeId,
|
||||
type = 'pills',
|
||||
size = 'tiny',
|
||||
queryGroup,
|
||||
block,
|
||||
onChange,
|
||||
onClick,
|
||||
@@ -53,81 +40,26 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
|
||||
baseClassNames,
|
||||
children: _children,
|
||||
}) => {
|
||||
// toArray is used here to filter out invalid children
|
||||
// another method would be to use React.Children.map
|
||||
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 queryTab = queryTabRaw && tabIds.includes(queryTabRaw) ? queryTabRaw : undefined
|
||||
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
queryTab ??
|
||||
activeId ??
|
||||
defaultActiveId ??
|
||||
// if no defaultActiveId is set use the first panel
|
||||
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)
|
||||
}, [])
|
||||
|
||||
// If query param present for the query group, switch to that tab.
|
||||
useEffect(() => {
|
||||
if (queryTab) {
|
||||
setActiveTab(queryTab)
|
||||
setGroupActiveId?.(queryTab)
|
||||
}
|
||||
}, [queryTab])
|
||||
useMemo(() => {
|
||||
if (activeId && activeId !== activeTab) setActiveTab(activeId)
|
||||
}, [activeId])
|
||||
|
||||
let __styles = styleHandler('tabs')
|
||||
|
||||
const { groupActiveId, setGroupActiveId } = useTabGroup(tabIds)
|
||||
|
||||
const active = activeId ?? groupActiveId ?? activeTab
|
||||
|
||||
function onTabClick(currentTarget: EventTarget, id: string) {
|
||||
setActiveTab(id)
|
||||
setGroupActiveId?.(id)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
currentTarget.dispatchEvent(
|
||||
new CustomEvent(TAB_CHANGE_EVENT_NAME, { bubbles: true, detail: { queryGroup, id } })
|
||||
)
|
||||
|
||||
function onTabClick(id: string) {
|
||||
onClick?.(id)
|
||||
if (id !== active) {
|
||||
if (id !== activeTab) {
|
||||
onChange?.(id)
|
||||
setActiveTab(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,11 +69,11 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
|
||||
if (listClassNames) listClasses.push(listClassNames)
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Root value={active} className={[__styles.base, baseClassNames].join(' ')}>
|
||||
<TabsPrimitive.Root value={activeTab} className={[__styles.base, baseClassNames].join(' ')}>
|
||||
<TabsPrimitive.List className={listClasses.join(' ')}>
|
||||
{addOnBefore}
|
||||
{children.map((tab) => {
|
||||
const isActive = active === tab.props.id
|
||||
const isActive = activeTab === tab.props.id
|
||||
const triggerClasses = [__styles[type].base, __styles.size[size]]
|
||||
if (isActive) {
|
||||
triggerClasses.push(__styles[type].active)
|
||||
@@ -155,14 +87,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={() => onTabClick(tab.props.id)}
|
||||
key={`${tab.props.id}-tab-button`}
|
||||
value={tab.props.id}
|
||||
className={triggerClasses.join(' ')}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { xor } from 'lodash'
|
||||
import {
|
||||
Dispatch,
|
||||
PropsWithChildren,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
export interface TabGroup {
|
||||
tabIds: string[]
|
||||
activeId: string
|
||||
}
|
||||
|
||||
interface TabsContextValue {
|
||||
tabGroups: TabGroup[]
|
||||
setTabGroups: Dispatch<SetStateAction<TabGroup[]>>
|
||||
}
|
||||
|
||||
const TabsContext = createContext<TabsContextValue>({
|
||||
tabGroups: [],
|
||||
setTabGroups: () => {},
|
||||
})
|
||||
|
||||
/**
|
||||
* Tracks active Tab IDs across the site so that tabs
|
||||
* with the same ID stay in sync (eg. JS vs TS tabs).
|
||||
*/
|
||||
const TabsProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const [tabGroups, setTabGroups] = useState<TabGroup[]>([])
|
||||
|
||||
return <TabsContext.Provider value={{ tabGroups, setTabGroups }}>{children}</TabsContext.Provider>
|
||||
}
|
||||
|
||||
export interface UseTabGroupValue {
|
||||
/**
|
||||
* The active tab ID for the tab group.
|
||||
* This value is shared with all matching tab
|
||||
* groups within the context.
|
||||
*/
|
||||
groupActiveId?: string
|
||||
|
||||
/**
|
||||
* Set the active tab ID for the tab group.
|
||||
* Value will be shared with all matching tab
|
||||
* groups within the context.
|
||||
*/
|
||||
setGroupActiveId?(id: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to retrieve and set the active tab ID for
|
||||
* the current tab group.
|
||||
* The value will be shared with all matching tab
|
||||
* groups within the context.
|
||||
*
|
||||
* Silently fails if no `TabsProvider` is set.
|
||||
*/
|
||||
export const useTabGroup = (tabIds: string[]): UseTabGroupValue => {
|
||||
const tabsContext = useContext(TabsContext)
|
||||
|
||||
if (!tabsContext) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { tabGroups, setTabGroups } = tabsContext
|
||||
|
||||
const groupActiveId = useMemo(
|
||||
() => tabGroups.find((group) => xor(group.tabIds, tabIds).length === 0)?.activeId,
|
||||
[tabGroups]
|
||||
)
|
||||
|
||||
const setGroupActiveId = useCallback((id: string) => {
|
||||
setTabGroups((groups) => {
|
||||
// Clone the array
|
||||
const newGroups = groups.concat()
|
||||
|
||||
const existingGroupIndex = groups.findIndex((group) => xor(group.tabIds, tabIds).length === 0)
|
||||
|
||||
const tabGroup: TabGroup = {
|
||||
tabIds,
|
||||
activeId: id,
|
||||
}
|
||||
|
||||
// If this group already exists, replace it
|
||||
// Otherwise add to the end
|
||||
if (existingGroupIndex !== -1) {
|
||||
newGroups.splice(existingGroupIndex, 1, tabGroup)
|
||||
} else {
|
||||
newGroups.push(tabGroup)
|
||||
}
|
||||
|
||||
return newGroups
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { groupActiveId, setGroupActiveId }
|
||||
}
|
||||
|
||||
export default TabsProvider
|
||||
@@ -1,2 +1 @@
|
||||
export { default as Tabs } from './Tabs'
|
||||
export { default as TabsProvider } from './TabsProvider'
|
||||
export { default as Tabs, type TabsProps } from './Tabs'
|
||||
Reference in new issue
Block a user