Files
supabase/apps/docs/features/ui/CodeBlock/CodeBlock.tsx
Miranda Limonczenko f09d35cfd5 fix(docs): make code blocks reachable and readable by keyboard and screen reader (#49562)
Closes DOCS-1283



https://github.com/user-attachments/assets/6e55a27f-6f73-453b-b98f-e91d3c14a9e4



## Problem

Three defects in the docs code block:

- The scroll container has no `tabindex`. On `/guides/database/tables`,
18 blocks, none focusable, 2 overflowing at 1280px. Tab skips the scroll
region, so a keyboard-only user cannot scroll code that runs off the
edge.
- The container has `role="group"` with no accessible name, so it
announces as bare "group".
- The line-number gutter has no `aria-hidden`, so digits are read inline
with the code. A block linearizes as `1import { createClient } from
'@supabase/supabase-js'23const supabase = ...`, with lines 2 and 3
collapsing into "23".

Four more surfaced while testing the fix:

- The wrap and copy buttons were absolutely positioned inside the
element that scrolls, so `right-2` measured against the scrollable
content box. Scrolling dragged them out of the corner into the middle of
the code. This one predates the PR.
- The buttons preceded the code in the DOM, so a screen reader read two
actions before naming what they act on.
- `focus-within` only fired for the buttons, so focusing the block left
the controls invisible.
- Both buttons set an `aria-label` identical to their tooltip text, and
Radix points `aria-describedby` at the tooltip on focus, producing "Copy
code, button, Copy code".

## Solution

Keyboard:

- Split the scroll region out of the positioning container, so the
controls stay pinned.
- Give the scroll region a `tabIndex` and a focus ring.
- Reveal the controls on `group-focus-within`.

Screen reader:

- Name the region `<language>, <n> lines`. Code content stays readable;
the summary goes in the name so the group can be skipped or stepped
into.
- Map fence aliases to spoken names, so `ts` announces as TypeScript.
Only the ambiguous ones; `bash`, `python`, `kotlin`, `dart`, `swift`
already read fine.
- `aria-hidden` the gutter. The numbers are already `select-none`, and
copy takes its content from the source string rather than the DOM, so
copy behavior is unchanged.
- Order the controls after the code.
- Announce the word wrap toggle through a live region, matching the copy
button.
- Opt both buttons out of Radix's generated description.

Also moved the `data-wrapped` side effect out of the `setIsWrapped`
updater, since React calls updaters twice under StrictMode.

## Manual testing

1. Open `/docs/guides/database/tables`.
2. Run `document.querySelectorAll('.code-scroll[tabindex="0"]').length`
in the console. Expect `18`.
3. Run `[...document.querySelectorAll('.code-scroll')].map(b =>
b.getAttribute('aria-label'))`. Expect entries like `SQL, 11 lines` and
`bash, 2 lines`, plus one bare `2 lines` for the fence with no language.
4. Tab to a code block. Expect a visible focus ring, and the wrap and
copy buttons to appear.
5. Press ArrowRight on the block under "Basic data loading", which
overflows. Expect it to scroll, and the buttons to stay in the top-right
corner.
6. Press Enter on the wrap button. Expect the code to wrap and a screen
reader to announce "Word wrap enabled".
7. With VoiceOver on, focus a code block. Expect "SQL, 11 lines, code
block", then the code read without line numbers interleaved. Focus each
button and expect its name once, not twice.


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

## Summary by CodeRabbit

- **Accessibility**
- Improved code block labels for screen readers, including programming
language and line count.
  - Added announcements when word wrap is enabled or disabled.
  - Enhanced keyboard focus behavior for code block controls.

- **Usability**
  - Kept code block controls visible while scrolling through code.
  - Improved wrapped-code overflow handling.
- Removed redundant tooltip descriptions for copy and word-wrap
controls.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-26 10:43:40 -07:00

214 lines
6.5 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 { getCodeBlockLabel, 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,
hideControls = false,
}: PropsWithChildren<{
className?: string
lang?: string
lineNumbers?: boolean
contents?: string
skipTypeGeneration?: boolean
hideControls?: 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) {
// Type compilation fails when imports aren't defined
}
}
const { tokens } = highlighter.codeToTokens(code, {
lang: lang || undefined,
theme: 'Supabase Theme',
})
return (
<div
className={cn(
'shiki',
'group',
'relative',
'not-prose',
'w-full',
'border border-default rounded-lg',
'bg-200',
'text-sm',
className
)}
>
<div
className={cn(
'code-scroll',
'w-full overflow-x-auto rounded-lg',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring'
)}
role="group"
aria-roledescription="code block"
aria-label={getCodeBlockLabel(lang, tokens.length)}
tabIndex={0}
>
<pre>
<code className={lineNumbers ? 'grid grid-cols-[auto_1fr]' : ''}>
{lineNumbers ? (
<>
{tokens.map((line, idx) => (
<Fragment key={idx}>
<div
aria-hidden="true"
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>
{/* After the code so the block is named before its controls, and outside the scroller so they stay pinned */}
{!hideControls && <CodeBlockControls content={code.trim()} />}
</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
}