Files
Alaister YoungandAlaister Young 55c297655e fix(studio): keep project sidebar navigation accessible (#51055)
Project sidebars now expose enabled navigation on fixed-width pages, and
the mobile project menu has an accessible dialog name. Fixed sidebar
sizing and editor resize controls remain intact.

**Changed:**

- Remove the disabled state from the shared sidebar panel while
retaining 256px fixed sizing, the disabled resize handle, and existing
editor resize limits.
- Add a screen-reader-only Project menu title inside the mobile project
sheet without changing other sheets or their titles.

**Added:**

- Tests using the actual resizable wrappers for enabled navigation and
handle semantics, and the actual mobile sheet for its accessible name
through menu navigation.

## To test

- Navigate through Database and Settings sidebar links with ordinary
clicks and Tab/Enter. Enabled links should have no disabled ancestor.
- Confirm fixed sidebars remain 256px wide, including an ordinary drag
of their disabled handle.
- In SQL Editor, verify keyboard resizing stays within 256–512px and
collapse/expand still works; restore the original width and visibility
without editing or executing anything.
- Open the mobile project menu. Confirm its dialog name is Project menu,
navigate through Tables, and close it without a missing-title warning.
Restore the viewport.

Validation: real regressions reproduce disabled navigation inheritance
and the unnamed mobile dialog before their fixes. All 26 focused
layout/menu/utility tests and source checks passed. Initial full browser
checks passed normal mouse/keyboard navigation, fixed-handle drag
resistance, editor resize limits, collapse/expand, and mobile
navigation, with viewport/state restored and no backend writes. Both
Next and TanStack production rebuilds passed. The focused mobile retest
passed the linked Project menu title through sections, closing,
navigation, and reopening with no new missing-title warnings, followed
by desktop sidebar and editor controls; viewport, width, and visibility
were restored. Unrelated development React mount/ref warnings remain
separately recorded; native Next local browser and catalog data were not
verified.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Project sidebar links remain available when sidebar resizing is turned
off; the resize handle stays unavailable in that setting.
* The mobile project menu retains the “Project menu” name as you
navigate between sections.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-10-07 18:45:58 +02:00

542 lines
19 KiB
TypeScript

import { LOCAL_STORAGE_KEYS, mergeRefs, useFlag, useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon } from 'lucide-react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import {
forwardRef,
Fragment,
isValidElement,
useEffect,
useLayoutEffect,
useRef,
type PropsWithChildren,
type ReactNode,
} from 'react'
import {
Alert,
AlertDescription,
AlertTitle,
cn,
LogoLoader,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useIsMobile,
usePanelRef,
} from 'ui'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { useMainScrollContainer, useSetMainScrollContainer } from '../MainScrollContainerContext'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import { ProductMenuBar } from '../Navigation/ProductMenuBar'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
import { ProjectPausedState } from './PausedState/ProjectPausedState'
import { PauseFailedState } from './PauseFailedState'
import { PausingState } from './PausingState'
import { ResizingState } from './ResizingState'
import RestartingState from './RestartingState'
import { RestoreFailedState } from './RestoreFailedState'
import { RestoringState } from './RestoringState'
import { UnhealthyState } from './UnhealthyState'
import { UpgradingState } from './UpgradingState'
import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { BannerExplorer } from '@/components/ui/BannerStack/Banners/BannerExplorer'
import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade'
import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { withAuth } from '@/hooks/misc/withAuth'
import { PROJECT_STATUS } from '@/lib/constants'
import { MANAGED_BY } from '@/lib/constants/infrastructure'
import { buildStudioPageTitle } from '@/lib/page-title'
import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
import { useAppStateSnapshot } from '@/state/app-state'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
// [Joshen] This is temporary while we unblock users from managing their project
// if their project is not responding well for any reason. Eventually needs a bit of an overhaul
const routesToIgnoreProjectDetailsRequest = [
'/project/[ref]/settings/infrastructure',
'/project/[ref]/settings/addons',
'/project/[ref]/settings/general',
'/project/[ref]/database/settings',
'/project/[ref]/storage/settings',
]
const routesToIgnoreDBConnection = [
'/project/[ref]/branches',
'/project/[ref]/database/backups',
'/project/[ref]/settings',
'/project/[ref]/functions',
'/project/[ref]/logs',
]
const routesToIgnorePostgrestConnection = [
'/project/[ref]/settings/general',
'/project/[ref]/settings/infrastructure',
'/project/[ref]/settings/addons',
'/project/[ref]/database/settings',
'/project/[ref]/reports',
]
const DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY =
LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('unknown', 'unknown')
function getProjectIntegrationBannerDismissKey({
projectRef,
integrationSource,
}: {
projectRef?: string
integrationSource?: string | null
}) {
if (!projectRef || !integrationSource) return DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY
return LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED(projectRef, integrationSource)
}
export interface ProjectLayoutProps {
isLoading?: boolean
isBlocking?: boolean
product?: string
productMenuBadge?: ReactNode
productMenuHeader?: ReactNode
productMenu?: ReactNode
browserTitle?: {
entity?: string
section?: string
override?: string
}
// Deprecated: use browserTitle.entity instead. Kept for backwards compatibility.
selectedTable?: string
resizableSidebar?: boolean
productMenuClassName?: string
}
export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutProps>>(
(
{
isLoading = false,
isBlocking = true,
product = '',
productMenuBadge,
productMenuHeader,
productMenu,
browserTitle,
children,
selectedTable,
resizableSidebar = false,
productMenuClassName,
},
ref
) => {
const router = useRouter()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: selectedProject } = useSelectedProjectQuery()
const { addBanner, dismissBanner } = useBannerStack()
const { data: resourceWarnings } = useResourceWarningsQuery({
slug: selectedOrganization?.slug,
})
const projectResourceWarnings = (Array.isArray(resourceWarnings) ? resourceWarnings : []).find(
(w) => w.project === selectedProject?.ref
)
const isComputeNearExhaustion =
!!projectResourceWarnings?.cpu_exhaustion ||
!!projectResourceWarnings?.memory_and_swap_exhaustion ||
!!projectResourceWarnings?.disk_space_exhaustion ||
!!projectResourceWarnings?.disk_io_exhaustion
const isNanoCompute = selectedProject?.infra_compute_size === 'nano'
const showUpgradeBanner = isNanoCompute && isComputeNearExhaustion
const isExplorerEnabled = useFlag('explorer')
const [isFreeMicroUpgradeBannerDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(selectedProject?.ref ?? ''),
false
)
const [isProjectIntegrationBannerDismissed, setIsProjectIntegrationBannerDismissed] =
useLocalStorageQuery(
getProjectIntegrationBannerDismissKey({
projectRef: selectedProject?.ref,
integrationSource: selectedProject?.integration_source,
}),
false
)
const [isExplorerBannerDismissed, , { isSuccess: isLocalStorageReady }] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.EXPLORER_BANNER_DISMISSED,
false
)
const { showSidebar } = useAppStateSnapshot()
const {
content: mobileSheetContent,
setContent: setMobileSheetContent,
registerOpenMenu,
} = useMobileSheet()
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
const currentSectionKey = getSectionKeyFromPathname(pathname)
const mainScrollContainer = useMainScrollContainer()
const setMainScrollContainer = useSetMainScrollContainer()
const combinedRef = mergeRefs(ref, setMainScrollContainer)
const { appTitle } = useCustomContent(['app:title'])
const brandTitle = appTitle || 'Supabase'
const isMobile = useIsMobile()
const editor = useEditorType()
const forceShowProductMenu = editor === undefined
const sideBarIsOpen = (forceShowProductMenu || showSidebar) && !isMobile
const panelRef = usePanelRef()
const projectName = selectedProject?.name
const organizationName = selectedOrganization?.name
const pageTitle =
browserTitle?.override ||
buildStudioPageTitle({
entity: browserTitle?.entity ?? selectedTable,
section: browserTitle?.section,
surface: product,
project: projectName,
org: organizationName,
brand: brandTitle,
}) ||
brandTitle
const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
const ignorePausedState =
router.pathname === '/project/[ref]' ||
router.pathname.includes('/project/[ref]/settings') ||
router.pathname.includes('/project/[ref]/functions') ||
router.pathname.includes('/project/[ref]/logs')
const showPausedState = isPaused && !ignorePausedState
const showStripeProjectBanner =
selectedProject?.integration_source === 'stripe_projects' &&
!isProjectIntegrationBannerDismissed
useEffect(() => {
if (!selectedProject?.ref) return
const isProjectHomepage = router.pathname === '/project/[ref]'
if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) {
addBanner({
id: BANNER_ID.FREE_MICRO_UPGRADE,
isDismissed: false,
content: <BannerFreeMicroUpgrade />,
priority: 2,
})
} else {
dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
}
}, [
router.pathname,
selectedProject?.ref,
showUpgradeBanner,
isFreeMicroUpgradeBannerDismissed,
addBanner,
dismissBanner,
])
useEffect(() => {
if (!isExplorerEnabled || !isLocalStorageReady || isExplorerBannerDismissed) return
addBanner({
id: 'explorer-banner',
priority: 2,
isDismissed: false,
content: <BannerExplorer />,
})
}, [addBanner, isExplorerEnabled, isExplorerBannerDismissed, isLocalStorageReady])
const mobileSheetContentRef = useRef(mobileSheetContent)
useLayoutEffect(() => {
mobileSheetContentRef.current = mobileSheetContent
}, [mobileSheetContent])
useLayoutEffect(() => {
const menu = (
<MobileMenuContent
currentProductMenu={productMenu ?? null}
currentProductMenuHeader={productMenuHeader}
currentProduct={product}
currentSectionKey={currentSectionKey}
onCloseSheet={() => setMobileSheetContent(null)}
/>
)
const unregister = registerOpenMenu(() => setMobileSheetContent(menu))
// Keep resource navigation in an already open sheet in sync with the layout.
const currentContent = mobileSheetContentRef.current
if (isValidElement(currentContent) && currentContent.type === MobileMenuContent) {
setMobileSheetContent(menu)
}
return unregister
}, [
registerOpenMenu,
productMenu,
productMenuHeader,
product,
currentSectionKey,
setMobileSheetContent,
])
useLayoutEffect(() => {
mainScrollContainer?.scrollTo({ top: 0, left: 0 })
}, [pathname, mainScrollContainer])
return (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content="Supabase Studio" />
</Head>
<div className="flex flex-row h-full w-full">
<ResizablePanelGroup orientation="horizontal">
{productMenu && sideBarIsOpen && (
<ResizablePanel
panelRef={panelRef}
minSize={256}
maxSize={resizableSidebar ? 512 : 256}
defaultSize={256}
id="panel-left"
>
<AnimatePresence initial={false}>
<motion.div
initial={{ width: 0, opacity: 0, height: '100%' }}
animate={{ width: 'auto', opacity: 1, height: '100%' }}
exit={{ width: 0, opacity: 0, height: '100%' }}
className="h-full"
transition={{ duration: 0.12 }}
>
<MenuBarWrapper
isLoading={isLoading}
isBlocking={isBlocking}
productMenu={productMenu}
>
<ProductMenuBar
title={product}
titleBadge={productMenuBadge}
header={productMenuHeader}
className={productMenuClassName}
>
{productMenu}
</ProductMenuBar>
</MenuBarWrapper>
</motion.div>
</AnimatePresence>
</ResizablePanel>
)}
{productMenu && sideBarIsOpen && (
<ResizableHandle
withHandle
disabled={resizableSidebar ? false : true}
className="hidden md:flex"
/>
)}
<ResizablePanel
className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
id="panel-project-content"
>
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
ref={combinedRef}
>
{showStripeProjectBanner && (
<Alert
variant="default"
className="flex items-center gap-4 border-t-0 border-x-0 rounded-none"
>
<PartnerIcon
organization={{ managed_by: MANAGED_BY.STRIPE_PROJECTS }}
showTooltip={false}
size="medium"
/>
<div className="flex-1">
<AlertTitle>This project is connected to Stripe</AlertTitle>
<AlertDescription>
Changes made here may affect your connected Stripe project.
</AlertDescription>
</div>
<ButtonTooltip
variant="text"
icon={<XIcon size={14} />}
className="h-7 w-7 p-0"
onClick={() => setIsProjectIntegrationBannerDismissed(true)}
aria-label="Dismiss project integration banner"
tooltip={{ content: { text: 'Dismiss' } }}
/>
</Alert>
)}
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center px-4">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<CreateBranchModal />
<ProjectAPIDocs />
</>
)
}
)
ProjectLayout.displayName = 'ProjectLayout'
export const ProjectLayoutWithAuth = withAuth(ProjectLayout)
interface MenuBarWrapperProps {
isLoading: boolean
isBlocking?: boolean
productMenu?: ReactNode
children: ReactNode
}
const MenuBarWrapper = ({
isLoading,
isBlocking = true,
productMenu,
children,
}: MenuBarWrapperProps) => {
const router = useRouter()
const { data: selectedProject } = useSelectedProjectQuery()
const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
if (!isBlocking) {
return children
}
const showMenuBar =
!requiresProjectDetails || (requiresProjectDetails && selectedProject !== undefined)
return !isLoading && productMenu && showMenuBar ? children : null
}
interface ContentWrapperProps {
children: ReactNode
}
/**
* Check project.status to show building state or error state
*
* [Joshen] As of 210422: Current testing connection by pinging postgres
* Ideally we'd have a more specific monitoring of the project such as during restarts
* But that will come later: https://supabase.slack.com/archives/C01D6TWFFFW/p1650427619665549
*
* Just note that this logic does not differentiate between a "restarting" state and
* a "something is wrong and can't connect to project" state.
*
* [TODO] Next iteration should scrape long polling and just listen to the project's status
*/
const ContentWrapper = ({ children }: ContentWrapperProps) => {
const router = useRouter()
const { ref } = useParams()
const state = useDatabaseSelectorStateSnapshot()
const { data: selectedProject } = useSelectedProjectQuery()
const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups')
const isHomePage = router.pathname === '/project/[ref]'
const requiresDbConnection = !routesToIgnoreDBConnection.some((x) => router.pathname.includes(x))
const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
const isRestarting = selectedProject?.status === PROJECT_STATUS.RESTARTING
const isResizing = selectedProject?.status === PROJECT_STATUS.RESIZING
const isProjectUpgrading = selectedProject?.status === PROJECT_STATUS.UPGRADING
const isProjectRestoring = selectedProject?.status === PROJECT_STATUS.RESTORING
const isProjectRestoreFailed = selectedProject?.status === PROJECT_STATUS.RESTORE_FAILED
const isProjectBuilding =
selectedProject?.status === PROJECT_STATUS.COMING_UP ||
selectedProject?.status === PROJECT_STATUS.UNKNOWN
const isProjectPausing = selectedProject?.status === PROJECT_STATUS.PAUSING
const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED
const isProjectUnhealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_UNHEALTHY
const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE'
const ignoreUnhealthyState =
isHomePage ||
router.pathname.includes('/project/[ref]/settings') ||
router.pathname.includes('/project/[ref]/logs')
const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage
// Don't show building state on the home page — it handles building state inline
const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage
useEffect(() => {
if (shouldRedirectToHomeForBuilding && ref) {
router.replace(`/project/${ref}`)
}
}, [shouldRedirectToHomeForBuilding, ref, router])
useEffect(() => {
if (ref) state.setSelectedDatabaseId(ref)
}, [ref])
if (isRestarting && !isBackupsPage) {
return <RestartingState />
}
if (isResizing && !isBackupsPage) {
return <ResizingState />
}
if (isProjectUpgrading && !isBackupsPage) {
return <UpgradingState />
}
if (isProjectPausing) {
return <PausingState project={selectedProject} />
}
if (isProjectPauseFailed) {
return <PauseFailedState />
}
if (isProjectUnhealthy && !ignoreUnhealthyState) {
return <UnhealthyState />
}
if (requiresPostgrestConnection && isProjectOffline) {
return <ConnectingState project={selectedProject} />
}
if (requiresDbConnection && isProjectRestoring) {
return <RestoringState />
}
if (requiresDbConnection && isProjectRestoreFailed) {
return <RestoreFailedState />
}
if (shouldRedirectToHomeForBuilding) {
return <LogoLoader />
}
if (shouldShowBuildingState) {
return <BuildingState />
}
return <Fragment key={selectedProject?.ref}>{children}</Fragment>
}