Files
supabase/apps/docs/features/ui/CodeBlock/CodeBlock.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

204 lines
6.1 KiB
TypeScript

import { Fragment, type PropsWithChildren } from 'react'
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'
import { createTwoslasher, type ExtraFiles, type NodeHover } from 'twoslash'
import { cn } from 'ui'
import { AnnotatedSpan, CodeBlockControls } from './CodeBlock.client'
import { getFontStyle } from './CodeBlock.utils'
import theme from './supabase-2.json' with { type: 'json' }
import denoTypes from './types/lib.deno.d.ts.include'
const extraFiles: ExtraFiles = { 'deno.d.ts': denoTypes }
const twoslasher = createTwoslasher({ extraFiles })
const TWOSLASHABLE_LANGS: ReadonlyArray<string> = ['js', 'ts', 'javascript', 'typescript']
const BUNDLED_LANGUAGES = Object.keys(bundledLanguages)
const highlighter = await createHighlighter({
themes: [theme],
langs: BUNDLED_LANGUAGES,
})
export async function CodeBlock({
className,
lang: langSetting,
lineNumbers = true,
contents,
children,
skipTypeGeneration,
}: PropsWithChildren<{
className?: string
lang?: string
lineNumbers?: boolean
contents?: string
skipTypeGeneration?: boolean
}>) {
let code = (contents || extractCode(children)).trim()
const lang = tryToBundledLanguage(langSetting || '') || extractLang(children)
let twoslashed = null as null | Map<number, Map<number, Array<NodeHover>>>
if (!skipTypeGeneration && lang && TWOSLASHABLE_LANGS.includes(lang)) {
try {
const { code: editedCode, nodes } = twoslasher(code)
const hoverNodes: Array<NodeHover> = nodes.filter((node) => node.type === 'hover')
twoslashed = annotationsByLine(hoverNodes)
code = editedCode
} catch (_err) {
// Silently ignore, if imports aren't defined type compilation fails
// Uncomment lines below to debug in dev
// console.log('\n==========CODE==========\n')
// console.log(code)
// console.error(_err.recommendation)
}
}
const { tokens } = highlighter.codeToTokens(code, {
lang: lang || undefined,
theme: 'Supabase Theme',
})
return (
<div
className={cn(
'shiki',
'group',
'relative',
'not-prose',
'w-full overflow-x-auto',
'border border-default rounded-lg',
'bg-200',
'text-sm',
className
)}
role="group"
aria-roledescription="code block"
>
<CodeBlockControls content={code.trim()} />
<pre>
<code className={lineNumbers ? 'grid grid-cols-[auto_1fr]' : ''}>
{lineNumbers ? (
<>
{tokens.map((line, idx) => (
<Fragment key={idx}>
<div
className={cn(
'select-none text-right text-muted bg-control px-2 min-h-5 leading-5',
idx === 0 && 'pt-6',
idx === tokens.length - 1 && 'pb-6'
)}
>
{idx + 1}
</div>
<div
className={cn(
'code-content min-h-5 leading-5 pl-6 pr-6',
idx === 0 && 'pt-6',
idx === tokens.length - 1 && 'pb-6'
)}
>
<CodeLine tokens={line} twoslash={twoslashed?.get(idx)} />
</div>
</Fragment>
))}
</>
) : (
<div className="code-content p-6">
{tokens.map((line, idx) => (
<CodeLine key={idx} tokens={line} twoslash={twoslashed?.get(idx)} />
))}
</div>
)}
</code>
</pre>
</div>
)
}
function CodeLine({
tokens: rawTokens,
twoslash,
}: {
tokens: Array<ThemedToken>
twoslash?: Map<number, Array<NodeHover>>
}) {
let offset = 0
const tokens = rawTokens.map((token) => {
const newToken = { ...token, offset }
offset += token.content.length
return newToken
})
return (
<span className="block min-h-5 leading-5">
{tokens.map((token) =>
twoslash?.has(token.offset) ? (
<AnnotatedSpan
key={token.offset}
token={token}
annotations={twoslash.get(token.offset)!}
/>
) : (
<span
key={token.offset}
style={{ color: token.color, ...getFontStyle(token.fontStyle || 0) }}
>
{token.content}
</span>
)
)}
</span>
)
}
function extractCode(children: React.ReactNode): string {
if (typeof children === 'string') return children
const child = Array.isArray(children) ? children[0] : children
if (!!child && typeof child === 'object' && 'props' in child) {
const props = child.props
if (!!props && typeof props === 'object' && 'children' in props) {
const code = props.children
if (typeof code === 'string') return code
}
}
return ''
}
function extractLang(children: React.ReactNode): BundledLanguage | null {
if (typeof children === 'string') return null
const child = Array.isArray(children) ? children[0] : children
if (!!child && typeof child === 'object' && 'props' in child) {
const props = child.props
if (!!props && typeof props === 'object' && 'className' in props) {
const className = props.className
if (typeof className === 'string') {
const lang = className.split(' ').find((className) => className.startsWith('language-'))
return lang ? tryToBundledLanguage(lang.replace('language-', '')) : null
}
}
}
return null
}
function annotationsByLine(nodes: Array<NodeHover>): Map<number, Map<number, Array<NodeHover>>> {
const result = new Map()
nodes.forEach((node) => {
const line = node.line
const char = node.character
if (!result.has(line)) {
result.set(line, new Map())
}
if (!result.get(line).has(char)) {
result.get(line).set(char, [])
}
result.get(line).get(char).push(node)
})
return result
}
function tryToBundledLanguage(lang: string): BundledLanguage | null {
if (BUNDLED_LANGUAGES.includes(lang)) {
return lang as BundledLanguage
}
return null
}