Files
supabase/apps/docs/features/ui/CodeBlock/CodeBlock.client.tsx
Gildas Garcia 26b1fd9aab fix: keyboard navigation between docs tabs (#47778)
## Problem

Keyboard navigation on docs tabs is confusing:

- tab panels can be focused but without any indication that they are
- code example buttons can be focused with keyboard but stay invisible
- copy code button has no label and do not notify screen reader users
about its status

## Solution

- Make tab panels non focusable
- Ensure buttons are visible when focused
- Add a label to the copy code buttons
- Add a live region for the copy code status

## How to test

1. Go to
https://docs-git-gildasgarcia-docs-1156-cannot-keyboard-140a35-supabase.vercel.app/docs/guides/local-development/cli/getting-started
2. Go to a tab list with _Tab_ key
3. Verify you can choose the tab value with Arrow keys and select it
with space
4. Tab again and verify you now have focused the code example first
button and it is visible
5. Tab again and the copy code button should be focused and visible

If you enable Voice over, clicking the copy code button should announce
that the code has been copied

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved code block copy feedback so the “copied” state resets more
reliably after interaction.
* **Accessibility Improvements**
* Code block controls now appear on keyboard focus (not just hover) and
include an assistive live announcement when copying succeeds.
  * Enhanced code block semantics with clearer ARIA labeling.
* Prevented tab panels from being reachable through normal tab
navigation to reduce unintended focus stops.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-17 11:40:43 +02:00

179 lines
4.9 KiB
TypeScript

'use client'
import { ArrowRightFromLine, Check, Copy, WrapText } from 'lucide-react'
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'
import { type ThemedToken } from 'shiki'
import { type NodeHover } from 'twoslash'
import { cn, copyToClipboard, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { getFontStyle } from './CodeBlock.utils'
export function AnnotatedSpan({
token,
annotations,
}: {
token: ThemedToken
annotations: Array<NodeHover>
}) {
const [open, setOpen] = useState(false)
const [isTouchDevice, setIsTouchDevice] = useState(false)
useEffect(() => {
const touchDevice = !window.matchMedia('(pointer: fine)').matches
setIsTouchDevice(touchDevice)
}, [])
const handleClick = useCallback(
(evt: MouseEvent) => {
if (isTouchDevice) {
evt.preventDefault()
evt.stopPropagation()
setOpen((open) => !open)
}
},
[isTouchDevice]
)
const onOpenChange = useCallback(
(open: boolean) => {
if (!isTouchDevice || !open) {
setOpen(open)
}
},
[isTouchDevice]
)
return (
<Tooltip open={open} onOpenChange={onOpenChange}>
<TooltipTrigger asChild onClick={handleClick}>
<button
style={token.htmlStyle}
className={cn(
isTouchDevice &&
'underline underline-offset-4 decoration-dashed decoration-[rgba(from_currentColor_r_g_b/0.5)]'
)}
>
{token.content}
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[min(80vw,400px)] p-0 divide-y">
{annotations.map((annotation, idx) => (
<Annotation key={idx} annotation={annotation} />
))}
</TooltipContent>
</Tooltip>
)
}
function Annotation({ annotation }: { annotation: NodeHover }) {
const { text, docs, tags } = annotation
return (
<div className="flex flex-col gap-2">
<code className={cn('block bg-200 p-2', (docs || tags) && 'border-b border-default')}>
{text}
</code>
{docs && <p className={cn('p-2', tags && 'border-b border-default')}>{docs}</p>}
{tags && (
<div className="p-2 flex flex-col">
{tags.map((tag, idx) => {
return (
<span key={idx}>
<code>@{tag[0]}</code> {tag[1]}
</span>
)
})}
</div>
)}
</div>
)
}
export function CodeCopyButton({ className, content }: { className?: string; content: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
copyToClipboard(content, () => {
setCopied(true)
})
}
const resetStatus = () => {
setCopied(false)
}
return (
<>
<span className="sr-only" aria-live="polite">
{copied ? 'Code copied' : ''}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopy}
onBlur={resetStatus}
className={cn(
'border rounded-md p-1',
copied && 'bg-selection',
'hover:bg-selection transition',
className
)}
aria-label="Copy code"
>
{copied ? (
<Check size={14} className="text-lighter" />
) : (
<Copy size={14} className="text-lighter" />
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
</>
)
}
export function CodeBlockControls({ content }: { content: string }) {
const [isWrapped, setIsWrapped] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const toggleWrap = useCallback(() => {
setIsWrapped((prev) => {
const newValue = !prev
// Find the parent code block and toggle the wrap data attribute
const codeBlock = wrapperRef.current?.closest('.shiki')
if (codeBlock) {
if (newValue) {
codeBlock.setAttribute('data-wrapped', 'true')
} else {
codeBlock.removeAttribute('data-wrapped')
}
}
return newValue
})
}, [])
return (
<div
ref={wrapperRef}
className="opacity-0 flex group-hover:opacity-100 focus-within:opacity-100 absolute top-2 right-2 gap-1"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleWrap}
className={cn('border rounded-md p-1', 'hover:bg-selection transition')}
aria-label={isWrapped ? 'Disable word wrap' : 'Enable word wrap'}
>
{isWrapped ? (
<ArrowRightFromLine size={14} className="text-lighter" />
) : (
<WrapText size={14} className="text-lighter" />
)}
</button>
</TooltipTrigger>
<TooltipContent>{isWrapped ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
</Tooltip>
<CodeCopyButton content={content} />
</div>
)
}