Files
supabase/apps/studio/components/layouts/DefaultLayout.tsx
Danny White 37dded67d1 feat: unify SkipToContent across studio, docs, www, and design-system (#48314)
## What kind of change does this PR introduce?

Feature / a11y polish

## What is the current behavior?

Studio and Docs each had their own skip-to-content link (different
styling and behaviour). www and design-system had none.

## What is the new behavior?

Shared `SkipToContent` in `ui-patterns`, adopted by Studio, Docs, www,
and design-system. Documented as a fragment with a short note under
Accessibility → Jumping ahead.

Tab once to reveal the button (top-left), Enter to jump to a
content-only `<main>`.

| After |
| --- |
| <img width="836" height="324" alt="CleanShot 2026-07-24 at 14 08
47@2x"
src="https://github.com/user-attachments/assets/6df29452-e53a-4eca-8f64-946f2b9f605d"
/> |

## To test

Shared steps for every app: enable Tab key navigation if needed, load
the preview, press **Tab** once — skip button should slide in top-left.
Press **Enter** — focus jumps to main content (no blue ring on
`<main>`). Press **Tab** again — first interactive control in the page
body, not the sidebar/nav. Hover the skip button — solid fill, clear
hover state, no chrome showing through.

- **Studio** —
[preview](https://studio-staging-git-dnywh-featskip-to-content-supabase.vercel.app)
→ sign in → any project page
- **Docs** —
[preview](https://docs-git-dnywh-featskip-to-content-supabase.vercel.app)
→ any docs page with sidebar
- **www** —
[preview](https://zone-www-dot-com-git-dnywh-featskip-to-content-supabase.vercel.app)
→ homepage or any marketing page with the default nav
- **Design system** —
[preview](https://design-system-git-dnywh-featskip-to-content-supabase.vercel.app)
→ any docs page (confirm Tab from content does **not** walk the
sidebar), plus [Skip to Content
fragment](https://design-system-git-dnywh-featskip-to-content-supabase.vercel.app/docs/fragments/skip-to-content)

## Additional context

Follow-up to #47694 / #48303 (Studio) and #47515 (Docs).

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

- **New Features**
- Added a reusable “Skip to content” accessibility link across key
layouts and pages.
- Updated main landmarks to support keyboard focus and skip-link
navigation (`id="main"`).
- **Accessibility**
- Skip links now follow consistent landmark-target conventions and
remain hidden until focused.
- Improved documentation for skip links/jump shortcuts in persistent
chrome layouts.
- **Documentation**
- Added a dedicated Skip to Content fragment, navigation entry, and
expanded accessibility guidance.
  - Updated button description wording in component docs.
- **Tests**
  - Added component tests for SkipToContent.
- **Chores**
  - Exposed SkipToContent via additional public package entry points.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 06:21:50 +10:00

150 lines
5.9 KiB
TypeScript

import { useBreakpoint, useParams } from 'common'
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect, useState } from 'react'
import { ResizablePanel, ResizablePanelGroup, SidebarProvider, usePanelRef } from 'ui'
import { SkipToContent } from 'ui-patterns/SkipToContent'
import { BannerStack } from '../ui/BannerStack/BannerStack'
import { LayoutHeader } from './Navigation/LayoutHeader/LayoutHeader'
import MobileNavigationBar from './Navigation/NavigationBar/MobileNavigationBar'
import { MobileSheetProvider } from './Navigation/NavigationBar/MobileSheetContext'
import { StudioMobileSheetNav } from './Navigation/NavigationBar/StudioMobileSheetNav'
import { LayoutSidebar } from './ProjectLayout/LayoutSidebar'
import {
LayoutSidebarProvider,
SIDEBAR_KEYS,
} from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ProjectContextProvider } from './ProjectLayout/ProjectContext'
import { AppBannerWrapper } from '@/components/interfaces/App/AppBannerWrapper'
import { Sidebar } from '@/components/interfaces/Sidebar'
import { useSyncScopedIntrospection } from '@/data/scoped-introspection'
import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganization'
import { useCheckLatestDeploy } from '@/hooks/use-check-latest-deploy'
import { IS_PLATFORM } from '@/lib/constants'
import { useAppStateSnapshot } from '@/state/app-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
export interface DefaultLayoutProps {
headerTitle?: string
hideMobileMenu?: boolean
}
/**
* Base layout for all project pages in the dashboard, rendered as the first child on all page files within a project.
*
* A second layout as the child to this is required, and the layout depends on which section of the dashboard the page is on. (e.g Auth - AuthLayout)
*
* The base layout handles rendering the following UI components:
* - App banner (e.g for notices or incidents)
* - Mobile navigation bar
* - First level side navigation bar (e.g For navigating to Table Editor, SQL Editor, Database page, etc)
*/
export const DefaultLayout = ({
children,
headerTitle,
hideMobileMenu,
}: PropsWithChildren<DefaultLayoutProps>) => {
useSyncScopedIntrospection()
useCheckLatestDeploy()
const { ref } = useParams()
const router = useRouter()
const panelRef = usePanelRef()
const isMobile = useBreakpoint('md')
const appSnap = useAppStateSnapshot()
const { isMaximised, activeSidebar } = useSidebarManagerSnapshot()
const { lastVisitedOrganization } = useLastVisitedOrganization()
const [isMounted, setIsMounted] = useState(false)
const backToDashboardURL = router.pathname.startsWith('/account')
? appSnap.lastRouteBeforeVisitingAccountPage.length > 0
? appSnap.lastRouteBeforeVisitingAccountPage
: IS_PLATFORM && !!lastVisitedOrganization
? `/org/${lastVisitedOrganization}`
: IS_PLATFORM
? '/organizations'
: '/project/default'
: undefined
const contentMinSizePercentage = 50
const contentMaxSizePercentage = 70
useEffect(() => {
setIsMounted(true)
}, [])
useEffect(() => {
if (!isMounted || !panelRef.current || !activeSidebar || isMobile) return
if (isMaximised) {
panelRef.current.collapse()
} else {
panelRef.current.resize(`${contentMaxSizePercentage}%`)
}
}, [isMounted, isMaximised, panelRef, activeSidebar, isMobile])
// This is required to prevent layout shift when rendering resizable panels (they initially render at 50%, then shift
// to whatever is specified).
if (!isMounted) {
return null
}
return (
<SidebarProvider defaultOpen={false}>
<LayoutSidebarProvider>
<ProjectContextProvider projectRef={ref}>
<MobileSheetProvider>
<div className="flex flex-col h-screen w-screen">
<SkipToContent href="#main" />
{/* Top Banner */}
<AppBannerWrapper />
<div className="shrink-0">
{isMobile && (
<MobileNavigationBar
hideMobileMenu={hideMobileMenu}
backToDashboardURL={backToDashboardURL}
/>
)}
<LayoutHeader headerTitle={headerTitle} backToDashboardURL={backToDashboardURL} />
</div>
{/* Main Content Area */}
<div className="flex flex-1 w-full overflow-y-hidden">
{/* Sidebar - Only show for project pages, not account pages */}
{!router.pathname.startsWith('/account') && <Sidebar />}
{/* Main Content with Layout Sidebar */}
<ResizablePanelGroup
orientation="horizontal"
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
autoSaveId="default-layout-content"
>
<ResizablePanel
id="panel-content"
className="w-full"
panelRef={panelRef}
collapsible={activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT}
minSize={`${contentMinSizePercentage}`}
maxSize={`${contentMaxSizePercentage}`}
defaultSize={`${contentMaxSizePercentage}`}
>
<main id="main" tabIndex={-1} className="h-full overflow-y-auto outline-hidden">
{children}
</main>
</ResizablePanel>
<LayoutSidebar
minSize={`${100 - contentMaxSizePercentage}`}
maxSize="100"
defaultSize={`${100 - contentMaxSizePercentage}`}
/>
</ResizablePanelGroup>
</div>
</div>
<BannerStack />
<StudioMobileSheetNav />
</MobileSheetProvider>
</ProjectContextProvider>
</LayoutSidebarProvider>
</SidebarProvider>
)
}