Files
supabase/apps/docs/features/ui/useTabsWithQueryParams.tsx
Gildas Garcia 4b7cb27ba9 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 -->
2026-07-04 02:44:29 +10:00

104 lines
3.2 KiB
TypeScript

'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])
}