diff --git a/apps/docs/components/GuidesTableOfContents.tsx b/apps/docs/components/GuidesTableOfContents.tsx index 5edef88d6b5..5b42a189ed1 100644 --- a/apps/docs/components/GuidesTableOfContents.tsx +++ b/apps/docs/components/GuidesTableOfContents.tsx @@ -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 } diff --git a/apps/docs/components/Tabs.tsx b/apps/docs/components/Tabs.tsx new file mode 100644 index 00000000000..3663fbde4e7 --- /dev/null +++ b/apps/docs/components/Tabs.tsx @@ -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) => { + const rerenderToc = useTocRerenderTrigger() + const onChangeInternal = useCallback( + (...args: Parameters) => { + rerenderToc() + onChange?.(...args) + }, + [rerenderToc, onChange] + ) + + return +} + +export { Tabs, TabPanel } diff --git a/apps/docs/components/index.tsx b/apps/docs/components/index.tsx index 107b4f8bd3a..0feee8d8531 100644 --- a/apps/docs/components/index.tsx +++ b/apps/docs/components/index.tsx @@ -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) => {props.children}, - Tabs: (props: any) => , + TabPanel, + Tabs, } export default components diff --git a/apps/docs/components/reference/ApiOperationSection.tsx b/apps/docs/components/reference/ApiOperationSection.tsx index 897101f010c..5ef1d400b9b 100644 --- a/apps/docs/components/reference/ApiOperationSection.tsx +++ b/apps/docs/components/reference/ApiOperationSection.tsx @@ -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) => ( - + {response.description} {response?.content && response?.content['application/json'] && (
@@ -105,7 +106,7 @@ const ApiOperationSection = (props) => {
)} -
+ ))}
diff --git a/apps/docs/components/reference/CLICommandSection.tsx b/apps/docs/components/reference/CLICommandSection.tsx index 001bd1eff54..6997aeaca7c 100644 --- a/apps/docs/components/reference/CLICommandSection.tsx +++ b/apps/docs/components/reference/CLICommandSection.tsx @@ -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 ( - { )} - + ) }) ) : ( // TODO: remove this block once all commands have examples - { > {command.usage} - + )} diff --git a/apps/docs/components/reference/RefFunctionSection.tsx b/apps/docs/components/reference/RefFunctionSection.tsx index 0a4a7695771..eae8489a713 100644 --- a/apps/docs/components/reference/RefFunctionSection.tsx +++ b/apps/docs/components/reference/RefFunctionSection.tsx @@ -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 = (props) => { const item = props.spec.functions.find((x: any) => x.id === props.funcData.id) @@ -138,7 +136,7 @@ const RefFunctionSection: React.FC = (props) => { const tables = staticExample?.data?.tables return ( - = (props) => { )} - + ) })} diff --git a/apps/docs/hooks/useManualRerender.ts b/apps/docs/hooks/useManualRerender.ts deleted file mode 100644 index 4fde23deb2d..00000000000 --- a/apps/docs/hooks/useManualRerender.ts +++ /dev/null @@ -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 } diff --git a/apps/docs/pages/_app.tsx b/apps/docs/pages/_app.tsx index 27caa65f6e1..0c0667aae00 100644 --- a/apps/docs/pages/_app.tsx +++ b/apps/docs/pages/_app.tsx @@ -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) { - -
- - - - - -
-
+
+ + + + + +
diff --git a/apps/docs/pages/guides/database/database-advisors.tsx b/apps/docs/pages/guides/database/database-advisors.tsx index 1c55e424923..70d678eacb1 100644 --- a/apps/docs/pages/guides/database/database-advisors.tsx +++ b/apps/docs/pages/guides/database/database-advisors.tsx @@ -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({ Available checks {lints.map((lint) => ( - - + ))} diff --git a/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx b/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx index db3651b251e..4c987537d10 100644 --- a/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx @@ -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={

Download snippet as local migration file via the Supabase CLI.

} {...props} > - -
- - {SNIPPETS.map((snippet) => { - return ( - -
-
-

{snippet.title}

- -
- - selectedView === 'CLI' ? setSelectedView('NPM') : setSelectedView('CLI') - } +
+ + {SNIPPETS.map((snippet) => { + return ( + +
+
+

{snippet.title}

+
-
-                    
-                      {selectedView === 'CLI' ? snippet.cli : snippet.npm}
-                    
-                  
- - ) - })} - -
-

- Run this command from your project directory -

-
-
+
+                  
+                    {selectedView === 'CLI' ? snippet.cli : snippet.npm}
+                  
+                
+ + ) + })} + +
+

Run this command from your project directory

+
+ + About migrations + + - -
+ About CLI + +
- +
) } diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts index c1b77bca5ab..8d3a720025b 100644 --- a/packages/common/hooks/index.ts +++ b/packages/common/hooks/index.ts @@ -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' diff --git a/packages/common/hooks/useSearchParamsShallow.ts b/packages/common/hooks/useSearchParamsShallow.ts new file mode 100644 index 00000000000..a5245404144 --- /dev/null +++ b/packages/common/hooks/useSearchParamsShallow.ts @@ -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>() + + 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 } diff --git a/packages/ui-patterns/ComplexTabs/index.tsx b/packages/ui-patterns/ComplexTabs/index.tsx new file mode 100644 index 00000000000..f6ad08c0b4d --- /dev/null +++ b/packages/ui-patterns/ComplexTabs/index.tsx @@ -0,0 +1 @@ +export * from './withQueryParams' diff --git a/packages/ui-patterns/ComplexTabs/withQueryParams.tsx b/packages/ui-patterns/ComplexTabs/withQueryParams.tsx new file mode 100644 index 00000000000..1ea5ab9e2c6 --- /dev/null +++ b/packages/ui-patterns/ComplexTabs/withQueryParams.tsx @@ -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) => { + 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 = {} + 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 ( + + {/* Tabs does its own validation */} + {childrenUnvalidated} + + ) + } + +export { withQueryParams } diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index 1087e37c47f..adf935eb932 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -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> & TabsSubComponents = ({ activeId, type = 'pills', size = 'tiny', - queryGroup, block, onChange, onClick, @@ -53,81 +40,26 @@ const Tabs: React.FC> & 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> & TabsSubComponents = ({ if (listClassNames) listClasses.push(listClassNames) return ( - + {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> & TabsSubComponents = ({ return ( ) => { - if (e.keyCode === 13) { + if (e.key === 'Enter') { e.preventDefault() - onTabClick(e.currentTarget, tab.props.id) + onTabClick(tab.props.id) } }} - onClick={(e: MouseEvent) => - onTabClick(e.currentTarget, tab.props.id) - } + onClick={() => onTabClick(tab.props.id)} key={`${tab.props.id}-tab-button`} value={tab.props.id} className={triggerClasses.join(' ')} diff --git a/packages/ui/src/components/Tabs/TabsProvider.tsx b/packages/ui/src/components/Tabs/TabsProvider.tsx deleted file mode 100644 index feb6a74941b..00000000000 --- a/packages/ui/src/components/Tabs/TabsProvider.tsx +++ /dev/null @@ -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> -} - -const TabsContext = createContext({ - 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([]) - - return {children} -} - -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 diff --git a/packages/ui/src/components/Tabs/index.tsx b/packages/ui/src/components/Tabs/index.tsx index 14400819be6..dc2c0ed0446 100644 --- a/packages/ui/src/components/Tabs/index.tsx +++ b/packages/ui/src/components/Tabs/index.tsx @@ -1,2 +1 @@ -export { default as Tabs } from './Tabs' -export { default as TabsProvider } from './TabsProvider' +export { default as Tabs, type TabsProps } from './Tabs'