Files
supabase/apps/ui-library/components/side-navigation-item.tsx
Danny White e57aae3c83 feat(design-system): document disabled controls and add focusableWhenDisabled (#50068)
## What kind of change does this PR introduce?

Docs update, with supporting `ui` and Studio changes.

## What is the current behaviour?

Disabled buttons with tooltips use native `disabled`, which removes them
from the tab order. Keyboard users cannot focus the control or read the
tooltip explaining why an action is blocked.

The design system also lacked guidance on keeping disabled actions
discoverable and explaining why they are unavailable.

## What is the new behaviour?

- Adds a **Disabled controls** section to the accessibility docs, with
live examples for a focusable disabled button and visible page-level
context
- Adds `focusableWhenDisabled` to `Button`, keeping `disabled` as the
semantic state while using `aria-disabled`, retaining keyboard focus,
and guarding click handlers
- Updates Studio's `ButtonTooltip` to make disabled buttons with tooltip
text focusable automatically

Also includes earlier design-system fixes on this branch:

- Centralises `BASE_PATH` with a `/design-system` fallback so asset URLs
work without a local `.env` file
- Fixes sidebar hover and active tokens in design-system and ui-library,
aligned with Studio's `InnerSideMenuItem`

## To test

**Design system**

1. Open the [accessibility
preview](https://design-system-git-fix-design-system-docs-and-nav-fixes-supabase.vercel.app/design-system/docs/accessibility)
2. Scroll to **Disabled controls**
3. Tab to the **disabled-focusable** example. Confirm the button remains
focusable, looks disabled, and shows its tooltip on focus
4. Confirm the **disabled-unavailable-with-notice** example shows the
admonition and focusable disabled button pattern

**Studio (optional, requires a High Availability project)**

5. Go to Settings → General → **Pause project**. Tab to the button and
confirm it remains focusable, looks disabled, and shows the HA tooltip
on focus
6. Go to Database → Backups and find **Restore** on a scheduled backup
row. Confirm the same behaviour
2026-09-11 01:52:21 +00:00

119 lines
3.7 KiB
TypeScript

'use client'
import Link, { LinkProps } from 'next/link'
import { usePathname } from 'next/navigation'
import React from 'react'
import { Badge, cn } from 'ui'
import { useFramework } from '@/context/framework-context'
import { useMobileMenu } from '@/hooks/use-mobile-menu'
import { SidebarNavItem } from '@/types/nav'
// We extend:
// 1. LinkProps - for Next.js Link component props (prefetch, etc)
// 2. AnchorHTMLAttributes - for standard HTML anchor props (className, etc)
// We omit href from both since we compute it internally from item.href
interface NavigationItemProps
extends Omit<LinkProps, 'href'>, Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
item: SidebarNavItem
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void
}
const NavigationItem: React.FC<NavigationItemProps> = ({ item, onClick, ...props }) => {
const { setOpen } = useMobileMenu()
const { framework } = useFramework()
const pathname = usePathname()
const pathParts = pathname.split('/')
const slug = pathParts[pathParts.length - 1]
// Helper function to check if a framework is supported for a navigation item
const isFrameworkSupported = (item: SidebarNavItem, framework: string) => {
const supportedFrameworks = item.supportedFrameworks || []
const hasFrameworkRestrictions = supportedFrameworks.length > 0
// An item supports the current framework if either:
// 1. It has no framework restrictions (supports all frameworks)
// 2. The current framework is in its list of supported frameworks
return !hasFrameworkRestrictions || supportedFrameworks.includes(framework as any)
}
// Build URL with priority:
// 1. item.href if available (replacing any existing framework with current one)
// 2. Computed from current path considering framework support
let href = item.href
// Only modify URLs for items that explicitly support frameworks
if (item.supportedFrameworks) {
if (href && href.startsWith('/docs/')) {
const hrefParts = href.split('/')
if (hrefParts.length >= 3) {
if (framework && isFrameworkSupported(item, framework)) {
if (hrefParts.length >= 4) {
href = `/docs/${framework}/${hrefParts[3]}`
}
}
}
}
}
// Handle component items with slug but no href
else if (!href && slug) {
// Build the URL using the current framework if it's supported
if (framework && isFrameworkSupported(item, framework)) {
href = `/docs/${framework}/${slug}`
} else {
// Fall back to framework-agnostic URL when framework not supported
href = `/docs/${slug}`
}
}
// Determine if this link represents the current page
const isActive = pathname === href
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Close the mobile menu when navigating
setOpen(false)
// Call the onClick prop if it exists
if (onClick) {
onClick(e)
}
}
return (
<Link
href={href || '#'}
{...props}
onClick={handleClick}
className={cn(
'relative',
'flex',
'items-center justify-between',
'h-6',
'text-sm',
'px-6',
'transition-all',
isActive
? 'bg-selection text-foreground'
: 'text-foreground-light hover:bg-surface-200 hover:text-foreground',
props.className
)}
>
{/* Active indicator bar */}
<div
className={cn(
'transition',
'absolute left-0 w-1 h-full bg-foreground',
isActive ? 'opacity-100' : 'opacity-0'
)}
/>
{item.title}
{item.new && <Badge variant="success">New</Badge>}
</Link>
)
}
NavigationItem.displayName = 'NavigationItem'
export default NavigationItem