Skip to content

Commit c7bf4a2

Browse files
authored
allow tab navigation out of code blocks (#37)
1 parent 5d67989 commit c7bf4a2

1 file changed

Lines changed: 49 additions & 3 deletions

File tree

packages/theme/components/layout/code-block/ReactCodeBlock.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use client'
2-
import React, {PropsWithChildren, useCallback, useState} from 'react'
2+
import React, {type PropsWithChildren, useCallback, useState, useRef, useEffect} from 'react'
33
import clsx from 'clsx'
44
import {LiveProvider, LiveEditor, LiveError, LivePreview} from 'react-live'
55
import {useColorMode} from '../../context/color-modes/useColorMode'
@@ -18,12 +18,23 @@ type ReactCodeBlockProps = {
1818
jsxScope: Record<string, unknown>
1919
} & PropsWithChildren<HTMLElement>
2020

21+
const getFocusableElements = () => {
22+
const focusableElementsQuery = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
23+
24+
return Array.from(document.querySelectorAll<HTMLElement>(focusableElementsQuery)).filter(el => {
25+
const style = window.getComputedStyle(el)
26+
return style.display !== 'none' && style.visibility !== 'hidden' && !el.hasAttribute('disabled')
27+
})
28+
}
29+
2130
export function ReactCodeBlock(props: ReactCodeBlockProps) {
2231
const {colorMode, setColorMode} = useColorMode()
2332
const {basePath} = useConfig()
2433
const initialCode = getCodeFromChildren(props.children)
2534
const [code, setCode] = useState(initialCode)
2635
const shouldShowPreview = ['tsx', 'jsx'].includes(props['data-language'])
36+
const editorRef = useRef<HTMLDivElement>(null)
37+
const resetButtonRef = useRef<HTMLButtonElement>(null)
2738

2839
/**
2940
* Transforms code to prepend basePath to img src attributes
@@ -43,6 +54,41 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
4354

4455
const noInline = props['data-filename'] === 'noinline' || false
4556

57+
useEffect(() => {
58+
const editor = editorRef.current
59+
60+
if (!editor) return
61+
62+
const onKeyDown = (e: KeyboardEvent) => {
63+
if (e.key !== 'Tab') {
64+
return
65+
}
66+
67+
if (e.shiftKey) {
68+
e.preventDefault()
69+
// We know that the previous focusable element is always the reset button
70+
resetButtonRef.current?.focus()
71+
return
72+
}
73+
74+
const focusableElements = getFocusableElements()
75+
76+
const currentIndex = focusableElements.findIndex(el => el === resetButtonRef.current)
77+
78+
if (currentIndex !== -1) {
79+
e.preventDefault()
80+
const nextIndex = currentIndex + 1
81+
focusableElements[nextIndex]?.focus()
82+
}
83+
}
84+
85+
editor.addEventListener('keydown', onKeyDown)
86+
87+
return () => {
88+
editor.removeEventListener('keydown', onKeyDown)
89+
}
90+
}, [])
91+
4692
return (
4793
<>
4894
<LiveProvider transformCode={transformCodeWithBasePath} code={code} scope={props.jsxScope} noInline={noInline}>
@@ -76,11 +122,11 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
76122
<Button size="small" leadingVisual={CopyIcon} onClick={handleCopy}>
77123
Copy
78124
</Button>
79-
<Button size="small" leadingVisual={UndoIcon} onClick={handleReset}>
125+
<Button size="small" leadingVisual={UndoIcon} onClick={handleReset} ref={resetButtonRef}>
80126
Reset
81127
</Button>
82128
</div>
83-
<div className={styles.Editor}>
129+
<div className={styles.Editor} ref={editorRef}>
84130
<LiveEditor theme={colorMode === 'light' ? lightTheme : darkTheme} onChange={setCode} />
85131
</div>
86132
{shouldShowPreview && (

0 commit comments

Comments
 (0)