mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: refactor docs tabs (#47557)
## Problem Now that `docs` is the only place where we use the deprecated `ui/Tabs`, we can move this component and the related HOC from `ui-patterns` in `docs` ## Solution - Move `ui/Tabs`, `ui-patterns/ComplexTabs/withQueryParams` and `ui-patterns/ComplexTabs/withSticky` to `docs` - Refactor `ui-patterns/ComplexTabs/withQueryParams` and `ui-patterns/ComplexTabs/withSticky` HOCs as hooks to make them easier to understand - Refactor `Tabs` accordingly No visual nor functional changes. ## How to test On https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/auth/passwords (Tabs are driven by URL and the flow tabs should have sticky headers even though there's a CSS bug already reported) - check that by default, the first tab in each tabs is active - change the tabs in different groups and validate it works - refresh the page and check that previously selected tabs are active (URL based selection) - In a new tab, visit https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/auth/passwords again and check that previously selected tabs are active (LocalStorage based selection) Do the same on https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/database/database-advisors (This one is driven by URL but does not have sticky tab headers) Do the same on https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/deployment/terraform/reference (this one is not driven by URL nor has sticky tab headers) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Docs tabs now persist and restore the active tab via URL query parameters. * Added optional “sticky” tab behavior that keeps the active panel in view. * Enhanced keyboard interaction for selecting tabs. * **Bug Fixes** * Improved active-tab initialization and synchronization when the URL query changes. * **Chores** * Refreshed the tabs UI implementation and styling to improve consistency and remove deprecated tab exports from shared UI packages. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
3ac8cf52e3
commit
4b7cb27ba9
14 files changed
+410
-482
No files matched your search
@@ -28,8 +28,6 @@ The instructions in this section assume that email confirmations are enabled.
|
||||
stickyTabList={{
|
||||
style: {
|
||||
top: 'var(--header-height)',
|
||||
backgroundColor:
|
||||
'oklch(from var(--background-default) l c h / var(--tw-bg-opacity))',
|
||||
maskImage: 'none',
|
||||
borderBottom: '1px solid oklch(from var(--border-default) l c h / 1)',
|
||||
}
|
||||
|
||||
+258
-28
@@ -1,43 +1,273 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, type ComponentPropsWithoutRef, type PropsWithChildren } from 'react'
|
||||
import { Tabs as TabsPrimitive, type TabsProps } from 'ui'
|
||||
import { withQueryParams, withSticky, type QueryParamsProps } from 'ui-patterns/ComplexTabs'
|
||||
import { useTocRerenderTrigger } from '~/features/docs/GuidesMdx.state'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui'
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const TabsWithStickyAndQueryParams = withSticky<PropsWithChildren<TabsProps & QueryParamsProps>>(
|
||||
withQueryParams(TabsPrimitive)
|
||||
import { useTocRerenderTrigger } from '../docs/GuidesMdx.state'
|
||||
import { useStickyTabs, UseStickyTabsOptions } from './useStickyTabs'
|
||||
import { useTabsWithQueryParams, UseTabsWithQueryParamsOptions } from './useTabsWithQueryParams'
|
||||
|
||||
export interface TabsProps {
|
||||
children: ReactNode
|
||||
type?: 'pills' | 'underlined' | 'cards' | 'rounded-pills'
|
||||
defaultActiveId?: string
|
||||
activeId?: string
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
|
||||
block?: boolean
|
||||
onChange?: (id: string) => void
|
||||
onClick?: any
|
||||
scrollable?: boolean
|
||||
wrappable?: boolean
|
||||
addOnBefore?: React.ReactNode
|
||||
addOnAfter?: React.ReactNode
|
||||
listClassNames?: string
|
||||
baseClassNames?: string
|
||||
}
|
||||
|
||||
export const tabsListVariants = cva(cn('flex'), {
|
||||
variants: {
|
||||
type: {
|
||||
pills: 'space-x-1',
|
||||
underlined: 'items-center border-b border-secondary',
|
||||
cards: '',
|
||||
'rounded-pills': 'flex-wrap gap-2',
|
||||
},
|
||||
scrollable: {
|
||||
true: 'overflow-auto whitespace-nowrap no-scrollbar mask-fadeout-right',
|
||||
},
|
||||
wrappable: {
|
||||
true: 'flex-wrap',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const tabsTriggerListVariants = cva(
|
||||
cn(
|
||||
'relative cursor-pointer flex items-center space-x-2 text-center transition focus:outline-hidden focus-visible:ring-3 focus-visible:ring-foreground-muted focus-visible:border-foreground-muted'
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
type: {
|
||||
pills: 'shadow-xs rounded-sm border',
|
||||
underlined: 'text-foreground-lighter',
|
||||
cards: '',
|
||||
'rounded-pills': 'shadow-xs rounded-full',
|
||||
},
|
||||
size: {
|
||||
tiny: 'text-xs px-2.5 py-1',
|
||||
small: 'text-base md:text-sm leading-4 px-3 py-2',
|
||||
medium: 'text-base md:text-sm px-4 py-2',
|
||||
large: 'text-base px-4 py-2',
|
||||
xlarge: 'text-base px-6 py-3',
|
||||
},
|
||||
block: {
|
||||
true: 'w-full flex items-center justify-center',
|
||||
},
|
||||
isActive: {
|
||||
false: 'hover:text-foreground',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
type: 'pills',
|
||||
isActive: true,
|
||||
className: 'bg-selection text-foreground border-stronger',
|
||||
},
|
||||
{
|
||||
type: 'pills',
|
||||
isActive: false,
|
||||
className:
|
||||
'bg-background border-strong hover:border-foreground-muted text-foreground-muted',
|
||||
},
|
||||
{
|
||||
type: 'underlined',
|
||||
isActive: true,
|
||||
className: '!text-foreground border-b-2 border-foreground',
|
||||
},
|
||||
{
|
||||
type: 'rounded-pills',
|
||||
isActive: true,
|
||||
className: 'bg-foreground text-background border-foreground',
|
||||
},
|
||||
{
|
||||
type: 'rounded-pills',
|
||||
isActive: false,
|
||||
className:
|
||||
'bg-surface-200 hover:bg-surface-300 hover:border-foreground-lighter text-foreground-lighter',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
const TabPanel = TabsPrimitive.Panel
|
||||
const Tabs = ({
|
||||
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
|
||||
|
||||
export const Tabs = ({
|
||||
defaultActiveId,
|
||||
activeId,
|
||||
type = 'pills',
|
||||
size = 'tiny',
|
||||
block,
|
||||
onChange,
|
||||
onClick,
|
||||
scrollable,
|
||||
wrappable,
|
||||
addOnBefore,
|
||||
addOnAfter,
|
||||
listClassNames,
|
||||
baseClassNames,
|
||||
children,
|
||||
queryGroup,
|
||||
stickyTabList,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof TabsWithStickyAndQueryParams>) => {
|
||||
}: TabsProps &
|
||||
Pick<UseTabsWithQueryParamsOptions, 'queryGroup'> & {
|
||||
stickyTabList?: UseStickyTabsOptions
|
||||
}) => {
|
||||
const childrenArr: ReactElement<TabPanelProps>[] = []
|
||||
const tabIds: string[] = []
|
||||
Children.forEach(children, (child) => {
|
||||
if (isValidElement<TabPanelProps>(child) && isString(child.props.id)) {
|
||||
childrenArr.push(child)
|
||||
tabIds.push(child.props.id)
|
||||
}
|
||||
})
|
||||
const { queryTab, onTabSelected: onTabSelectedForQuery } = useTabsWithQueryParams({
|
||||
tabIds,
|
||||
queryGroup,
|
||||
})
|
||||
|
||||
const sanitizedStickyTabList = useMemo(
|
||||
() =>
|
||||
stickyTabList != undefined
|
||||
? {
|
||||
...stickyTabList,
|
||||
// Magic number is the height of tab list + paragraph margin, worth getting
|
||||
// rid of this?
|
||||
scrollMarginTop:
|
||||
stickyTabList.scrollMarginTop || 'calc(var(--header-height) + 43px + 20px)',
|
||||
}
|
||||
: undefined,
|
||||
[stickyTabList]
|
||||
)
|
||||
const {
|
||||
observedRef,
|
||||
stickyRef,
|
||||
onTabSelected: onTabSelectedForSticky,
|
||||
} = useStickyTabs(sanitizedStickyTabList)
|
||||
const rerenderToc = useTocRerenderTrigger()
|
||||
const onChangeInternal = useCallback(
|
||||
(...args: Parameters<typeof onChange>) => {
|
||||
rerenderToc()
|
||||
onChange?.(...args)
|
||||
},
|
||||
[rerenderToc, onChange]
|
||||
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
queryTab ??
|
||||
activeId ??
|
||||
defaultActiveId ??
|
||||
// if no defaultActiveId is set use the first panel
|
||||
childrenArr?.[0]?.props?.id
|
||||
)
|
||||
|
||||
if (stickyTabList && !stickyTabList.scrollMarginTop) {
|
||||
// Magic number is the height of tab list + paragraph margin, worth getting
|
||||
// rid of this?
|
||||
stickyTabList.scrollMarginTop = 'calc(var(--header-height) + 43px + 20px)'
|
||||
}
|
||||
useEffect(() => {
|
||||
// If we have a queryTab, Tabs is controller by URL params
|
||||
if (queryTab && queryTab !== activeTab) {
|
||||
setActiveTab(queryTab)
|
||||
return
|
||||
}
|
||||
if (activeId && activeId !== activeTab) {
|
||||
setActiveTab(activeId)
|
||||
}
|
||||
}, [activeId, activeTab, queryTab])
|
||||
|
||||
const onTabClick = useCallback(
|
||||
(id: string) => {
|
||||
if (id !== activeTab) {
|
||||
setActiveTab(id)
|
||||
onTabSelectedForSticky()
|
||||
onTabSelectedForQuery(id)
|
||||
rerenderToc()
|
||||
onChange?.(id)
|
||||
onClick?.(id)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeTab,
|
||||
onTabSelectedForSticky,
|
||||
onTabSelectedForQuery,
|
||||
rerenderToc,
|
||||
onChange,
|
||||
onClick,
|
||||
setActiveTab,
|
||||
]
|
||||
)
|
||||
return (
|
||||
<TabsWithStickyAndQueryParams
|
||||
wrappable
|
||||
onChange={onChangeInternal}
|
||||
stickyTabList={stickyTabList}
|
||||
{...props}
|
||||
/>
|
||||
<TabsPrimitive.Root
|
||||
value={activeTab}
|
||||
className={cn('w-full justify-between space-y-4', baseClassNames)}
|
||||
ref={observedRef}
|
||||
>
|
||||
<TabsPrimitive.List
|
||||
className={tabsListVariants({
|
||||
type,
|
||||
scrollable,
|
||||
wrappable,
|
||||
className: cn({ 'bg-background': stickyTabList != null }, listClassNames),
|
||||
})}
|
||||
ref={stickyRef}
|
||||
>
|
||||
{addOnBefore}
|
||||
{childrenArr.map((tab) => {
|
||||
const isActive = activeTab === tab.props.id
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onTabClick(tab.props.id)
|
||||
}
|
||||
}}
|
||||
onClick={() => onTabClick(tab.props.id)}
|
||||
key={`${tab.props.id}-tab-button`}
|
||||
value={tab.props.id}
|
||||
className={tabsTriggerListVariants({ type, isActive, size, block })}
|
||||
>
|
||||
{tab.props.icon}
|
||||
<span>{tab.props.label}</span>
|
||||
{tab.props.iconRight}
|
||||
</TabsPrimitive.Trigger>
|
||||
)
|
||||
})}
|
||||
{addOnAfter}
|
||||
</TabsPrimitive.List>
|
||||
{childrenArr}
|
||||
</TabsPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { TabPanel, Tabs }
|
||||
interface TabPanelProps {
|
||||
children: ReactNode
|
||||
id: string
|
||||
label?: string
|
||||
icon?: React.ReactNode
|
||||
iconRight?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const TabPanel = ({ children, id, className }: TabPanelProps) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
value={id}
|
||||
className={cn('focus:outline-hidden transition-height', className)}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Content>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, type FC } from 'react'
|
||||
|
||||
import { useSticky } from './useStickyTabs.utils'
|
||||
|
||||
export type UseStickyTabsOptions = { scrollMarginTop?: string; style?: CSSStyleDeclaration }
|
||||
|
||||
export const useStickyTabs = (options?: UseStickyTabsOptions) => {
|
||||
const enabled = !!options
|
||||
const { scrollMarginTop, style } = options || {}
|
||||
const { inView, observedRef, stickyRef } = useSticky<HTMLDivElement>({
|
||||
enabled,
|
||||
style,
|
||||
})
|
||||
|
||||
const onTabSelected = useCallback(() => {
|
||||
if (enabled && inView && stickyRef.current) {
|
||||
let elem = stickyRef.current as Element | null
|
||||
while (elem && !elem.matches('[role="tabpanel"][data-state="active"]')) {
|
||||
elem = elem.nextElementSibling
|
||||
}
|
||||
if (!elem) return
|
||||
|
||||
const top = elem.getBoundingClientRect().top
|
||||
;(elem as HTMLElement).style.scrollMarginTop = scrollMarginTop || '0px'
|
||||
if (top < 0) {
|
||||
elem.scrollIntoView({
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
||||
? 'smooth'
|
||||
: 'instant',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [enabled, inView, scrollMarginTop, stickyRef])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
observedRef,
|
||||
stickyRef,
|
||||
onTabSelected,
|
||||
}),
|
||||
[observedRef, stickyRef, onTabSelected]
|
||||
)
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParamsShallow } from 'common'
|
||||
import { xor } from 'lodash-es'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'supabase.ui-patterns.ComplexTabs.withQueryParams.v0'
|
||||
|
||||
export interface UseTabsWithQueryParamsOptions {
|
||||
tabIds: string[]
|
||||
queryGroup?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the basic `Tabs` component from the `ui` library so it stores
|
||||
* selection state in query params.
|
||||
*/
|
||||
export const useTabsWithQueryParams = ({ tabIds, queryGroup }: UseTabsWithQueryParamsOptions) => {
|
||||
// Store in ref to avoid stale data in later timeout
|
||||
const tabIdsRef = useRef(tabIds)
|
||||
tabIdsRef.current = tabIds
|
||||
|
||||
// Store in ref to avoid stale data in later timeout
|
||||
const queryGroupRef = useRef(queryGroup)
|
||||
queryGroupRef.current = queryGroup
|
||||
|
||||
const searchParams = useSearchParamsShallow()
|
||||
const queryTabMaybe = queryGroupRef.current && searchParams.get(queryGroupRef.current)
|
||||
const queryTab =
|
||||
queryTabMaybe && tabIdsRef.current.includes(queryTabMaybe) ? queryTabMaybe : undefined
|
||||
|
||||
const onTabSelected = useCallback(
|
||||
(id: string) => {
|
||||
if (queryGroupRef.current) {
|
||||
if (!searchParams.getAll('queryGroups').includes(queryGroupRef.current)) {
|
||||
searchParams.append('queryGroups', queryGroupRef.current)
|
||||
}
|
||||
searchParams.set(queryGroupRef.current, id)
|
||||
}
|
||||
},
|
||||
[searchParams]
|
||||
)
|
||||
|
||||
const checkedLocalStorage = useRef(false)
|
||||
useEffect(() => {
|
||||
let timeout: number | undefined
|
||||
if (!checkedLocalStorage.current) {
|
||||
// Timeout to avoid something (I think the router) overwriting it
|
||||
timeout = window.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)) {
|
||||
onTabSelected(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, onTabSelected])
|
||||
|
||||
return useMemo(() => ({ queryTab, onTabSelected }), [queryTab, onTabSelected])
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
"acorn-typescript": "^1.4.13",
|
||||
"ai-commands": "workspace:*",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"common": "workspace:*",
|
||||
"common-tags": "^1.8.2",
|
||||
"config": "workspace:*",
|
||||
|
||||
@@ -250,22 +250,6 @@
|
||||
"import": "./src/CommandMenu/prepackaged/shared/constants.ts",
|
||||
"types": "./src/CommandMenu/prepackaged/shared/constants.ts"
|
||||
},
|
||||
"./ComplexTabs": {
|
||||
"import": "./src/ComplexTabs/index.tsx",
|
||||
"types": "./src/ComplexTabs/index.tsx"
|
||||
},
|
||||
"./ComplexTabs/withQueryParams": {
|
||||
"import": "./src/ComplexTabs/withQueryParams.tsx",
|
||||
"types": "./src/ComplexTabs/withQueryParams.tsx"
|
||||
},
|
||||
"./ComplexTabs/withSticky": {
|
||||
"import": "./src/ComplexTabs/withSticky.tsx",
|
||||
"types": "./src/ComplexTabs/withSticky.tsx"
|
||||
},
|
||||
"./ComplexTabs/withSticky.utils": {
|
||||
"import": "./src/ComplexTabs/withSticky.utils.ts",
|
||||
"types": "./src/ComplexTabs/withSticky.utils.ts"
|
||||
},
|
||||
"./ComputeBadge": {
|
||||
"import": "./src/ComputeBadge/index.tsx",
|
||||
"types": "./src/ComputeBadge/index.tsx"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './withQueryParams'
|
||||
export * from './withSticky'
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParamsShallow } from 'common'
|
||||
import { xor } from 'lodash'
|
||||
import { Children, isValidElement, useEffect, useRef, type FC, type PropsWithChildren } from 'react'
|
||||
import { type TabsProps } from 'ui'
|
||||
|
||||
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'supabase.ui-patterns.ComplexTabs.withQueryParams.v0'
|
||||
|
||||
interface QueryParamsProps {
|
||||
queryGroup?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the basic `Tabs` component from the `ui` library so it stores
|
||||
* selection state in query params.
|
||||
*/
|
||||
const withQueryParams =
|
||||
<Props extends PropsWithChildren<TabsProps>>(
|
||||
Component: FC<Omit<Props, 'children' | 'queryGroup' | 'onClick'>>
|
||||
) =>
|
||||
({
|
||||
children: childrenUnvalidated,
|
||||
queryGroup: queryGroupTemp,
|
||||
onClick,
|
||||
...props
|
||||
}: Props & QueryParamsProps) => {
|
||||
// Avoid Children.toArray — it clones elements (accessing element.ref) which
|
||||
// triggers a React 19 warning. Children.forEach iterates without cloning.
|
||||
const tabIdsTemp: string[] = []
|
||||
Children.forEach(childrenUnvalidated, (child) => {
|
||||
if (isValidElement(child) && isString((child.props as any).id)) {
|
||||
tabIdsTemp.push((child.props as any).id)
|
||||
}
|
||||
})
|
||||
// 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 }
|
||||
export type { QueryParamsProps }
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PropsWithChildren, useCallback, type FC } from 'react'
|
||||
import { type TabsProps } from 'ui/src/components/Tabs'
|
||||
|
||||
import { useSticky } from './withSticky.utils'
|
||||
|
||||
interface StickyProps {
|
||||
stickyTabList?: {
|
||||
scrollMarginTop?: string
|
||||
style?: CSSStyleDeclaration
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around Tabs to make the tab list sticky while the tab content is in
|
||||
* view.
|
||||
*/
|
||||
const withSticky =
|
||||
<Props extends PropsWithChildren<TabsProps>>(
|
||||
Component: FC<Omit<Props, 'stickyTabList' | 'onClick'>>
|
||||
) =>
|
||||
({ stickyTabList, onClick, ...props }: Props & StickyProps) => {
|
||||
const { inView, observedRef, stickyRef } = useSticky<HTMLDivElement>({
|
||||
enabled: !!stickyTabList,
|
||||
style: stickyTabList?.style,
|
||||
})
|
||||
|
||||
const onClickInternal = useCallback(
|
||||
(id: string) => {
|
||||
if (stickyTabList && inView && stickyRef.current) {
|
||||
let elem = stickyRef.current as Element | null
|
||||
while (elem && !elem.matches('[role="tabpanel"][data-state="active"]')) {
|
||||
elem = elem.nextElementSibling
|
||||
}
|
||||
if (!elem) return
|
||||
|
||||
const top = elem.getBoundingClientRect().top
|
||||
;(elem as HTMLElement).style.scrollMarginTop = stickyTabList?.scrollMarginTop || '0px'
|
||||
if (top < 0) {
|
||||
elem.scrollIntoView({
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
||||
? 'smooth'
|
||||
: 'instant',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onClick?.(id)
|
||||
},
|
||||
[!!stickyTabList, inView, onClick]
|
||||
)
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
onClick={onClickInternal}
|
||||
refs={{ base: observedRef, list: stickyRef }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { withSticky }
|
||||
@@ -3,10 +3,6 @@
|
||||
export * from './src/components/Button'
|
||||
export * from './src/components/Icon/IconBackground'
|
||||
|
||||
// DISPLAYS
|
||||
|
||||
export * from './src/components/Tabs'
|
||||
|
||||
// NAV
|
||||
|
||||
export * from './src/components/Menu'
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui'
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useMemo,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PropsWithChildren,
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export interface TabsProps {
|
||||
type?: 'pills' | 'underlined' | 'cards' | 'rounded-pills'
|
||||
defaultActiveId?: string
|
||||
activeId?: string
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
|
||||
block?: boolean
|
||||
tabBarGutter?: number
|
||||
tabBarStyle?: React.CSSProperties
|
||||
onChange?: any
|
||||
onClick?: any
|
||||
scrollable?: boolean
|
||||
wrappable?: boolean
|
||||
addOnBefore?: React.ReactNode
|
||||
addOnAfter?: React.ReactNode
|
||||
listClassNames?: string
|
||||
baseClassNames?: string
|
||||
refs?: {
|
||||
base: RefObject<HTMLDivElement> | ((elem: HTMLDivElement | null) => void)
|
||||
list: RefObject<HTMLDivElement> | ((elem: HTMLDivElement | null) => void)
|
||||
}
|
||||
}
|
||||
|
||||
interface TabsSubComponents {
|
||||
Panel: React.FC<PropsWithChildren<PanelProps>>
|
||||
}
|
||||
|
||||
export const tabsListVariants = cva(cn('flex'), {
|
||||
variants: {
|
||||
type: {
|
||||
pills: 'space-x-1',
|
||||
underlined: 'items-center border-b border-secondary',
|
||||
cards: '',
|
||||
'rounded-pills': 'flex-wrap gap-2',
|
||||
},
|
||||
scrollable: {
|
||||
true: 'overflow-auto whitespace-nowrap no-scrollbar mask-fadeout-right',
|
||||
},
|
||||
wrappable: {
|
||||
true: 'flex-wrap',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const tabsTriggerListVariants = cva(
|
||||
cn(
|
||||
'relative cursor-pointer flex items-center space-x-2 text-center transition focus:outline-hidden focus-visible:ring-3 focus-visible:ring-foreground-muted focus-visible:border-foreground-muted'
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
type: {
|
||||
pills: 'shadow-xs rounded-sm border',
|
||||
underlined: 'text-foreground-lighter',
|
||||
cards: '',
|
||||
'rounded-pills': 'shadow-xs rounded-full',
|
||||
},
|
||||
size: {
|
||||
tiny: 'text-xs px-2.5 py-1',
|
||||
small: 'text-base md:text-sm leading-4 px-3 py-2',
|
||||
medium: 'text-base md:text-sm px-4 py-2',
|
||||
large: 'text-base px-4 py-2',
|
||||
xlarge: 'text-base px-6 py-3',
|
||||
},
|
||||
block: {
|
||||
true: 'w-full flex items-center justify-center',
|
||||
},
|
||||
isActive: {
|
||||
false: 'hover:text-foreground',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
type: 'pills',
|
||||
isActive: true,
|
||||
className: 'bg-selection text-foreground border-stronger',
|
||||
},
|
||||
{
|
||||
type: 'pills',
|
||||
isActive: false,
|
||||
className:
|
||||
'bg-background border-strong hover:border-foreground-muted text-foreground-muted',
|
||||
},
|
||||
{
|
||||
type: 'underlined',
|
||||
isActive: true,
|
||||
className: '!text-foreground border-b-2 border-foreground',
|
||||
},
|
||||
{
|
||||
type: 'rounded-pills',
|
||||
isActive: true,
|
||||
className: 'bg-foreground text-background border-foreground',
|
||||
},
|
||||
{
|
||||
type: 'rounded-pills',
|
||||
isActive: false,
|
||||
className:
|
||||
'bg-surface-200 hover:bg-surface-300 hover:border-foreground-lighter text-foreground-lighter',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* @deprecated Use `import { Tabs_shadcn_ } from "ui"` instead
|
||||
*/
|
||||
const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
|
||||
defaultActiveId,
|
||||
activeId,
|
||||
type = 'pills',
|
||||
size = 'tiny',
|
||||
block,
|
||||
onChange,
|
||||
onClick,
|
||||
scrollable,
|
||||
wrappable,
|
||||
addOnBefore,
|
||||
addOnAfter,
|
||||
listClassNames,
|
||||
baseClassNames,
|
||||
refs,
|
||||
children: _children,
|
||||
}) => {
|
||||
// Children.toArray clones elements (to assign keys) and accesses element.ref
|
||||
// internally, which triggers a React 19 warning. Children.forEach iterates
|
||||
// without cloning, so it never touches .ref
|
||||
// todo: remove this when we upgrade the radix packages to support react 19
|
||||
const childrenArr: PanelPropsProps[] = []
|
||||
Children.forEach(_children, (child) => {
|
||||
if (isValidElement(child)) childrenArr.push(child as unknown as PanelPropsProps)
|
||||
})
|
||||
const children = childrenArr
|
||||
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
activeId ??
|
||||
defaultActiveId ??
|
||||
// if no defaultActiveId is set use the first panel
|
||||
children?.[0]?.props?.id
|
||||
)
|
||||
|
||||
useMemo(() => {
|
||||
if (activeId && activeId !== activeTab) setActiveTab(activeId)
|
||||
}, [activeId])
|
||||
|
||||
function onTabClick(id: string) {
|
||||
onClick?.(id)
|
||||
if (id !== activeTab) {
|
||||
onChange?.(id)
|
||||
setActiveTab(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
value={activeTab}
|
||||
className={cn('w-full justify-between space-y-4', baseClassNames)}
|
||||
ref={refs?.base}
|
||||
>
|
||||
<TabsPrimitive.List
|
||||
className={tabsListVariants({ type, scrollable, wrappable, className: listClassNames })}
|
||||
ref={refs?.list}
|
||||
>
|
||||
{addOnBefore}
|
||||
{children.map((tab) => {
|
||||
const isActive = activeTab === tab.props.id
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onTabClick(tab.props.id)
|
||||
}
|
||||
}}
|
||||
onClick={() => onTabClick(tab.props.id)}
|
||||
key={`${tab.props.id}-tab-button`}
|
||||
value={tab.props.id}
|
||||
className={tabsTriggerListVariants({ type, isActive, size, block })}
|
||||
>
|
||||
{tab.props.icon}
|
||||
<span>{tab.props.label}</span>
|
||||
{tab.props.iconRight}
|
||||
</TabsPrimitive.Trigger>
|
||||
)
|
||||
})}
|
||||
{addOnAfter}
|
||||
</TabsPrimitive.List>
|
||||
{children as any}
|
||||
</TabsPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// bit of a hack because we map over the JSX in the parent component
|
||||
interface PanelPropsProps {
|
||||
props: PanelProps
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
id: string
|
||||
label?: string
|
||||
icon?: React.ReactNode
|
||||
iconRight?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use ./TabsContent_Shadcn_ instead
|
||||
*/
|
||||
export const Panel: React.FC<PropsWithChildren<PanelProps>> = ({ children, id, className }) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
value={id}
|
||||
className={cn('focus:outline-hidden transition-height', className)}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
Tabs.Panel = Panel
|
||||
export default Tabs
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Tabs, type TabsProps } from './Tabs'
|
||||
Generated
+3
@@ -384,6 +384,9 @@ importers:
|
||||
beautiful-mermaid:
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
common:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/common
|
||||
|
||||
Reference in new issue
Block a user