Skip to content

Commit 98af978

Browse files
fix: accessibility issue in tabs (#1184)
* fix: accessibility issue in tabs * refactor: use css to give outline to the tab when button is focused over js * chore: remove console.log * chore: remove unwanted color from FileTab * add sandpack id * tweak focus * feat: add unique id to each tab and tab panel for aria-* attributes * chore: revert back code editor stories changes * chore: remove focus-within added in codeEditor/styles * refactor: make activeFileUniqueId as optional paramater in FileTabs --------- Co-authored-by: Danilo Woznica <[email protected]>
1 parent 7420b2e commit 98af978

File tree

7 files changed

+159
-29
lines changed

7 files changed

+159
-29
lines changed

sandpack-react/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
"lint": "eslint '**/*.ts?(x)' --fix",
4040
"build": "rollup -c",
4141
"build:publish": "yarn build",
42-
"start": "tsc -p tsconfig.esm.json --watch",
4342
"dev": "storybook dev -p 6006 --quiet",
4443
"typecheck": "tsc",
4544
"format": "prettier --write '**/*.{ts,tsx,js,jsx}'",

sandpack-react/src/Playground.stories.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,23 @@ export const Basic: React.FC = () => {
1111
return (
1212
<div style={{ height: "400vh" }}>
1313
<Sandpack
14-
customSetup={{
15-
dependencies: {
16-
"react-content-loader": "latest",
17-
"radix-ui": "latest",
18-
"styled-components": "latest",
19-
"react-dom": "latest",
20-
react: "latest",
21-
"react-table": "latest",
22-
},
23-
}}
2414
options={{
25-
bundlerURL: "https://ymxnqs-3000.csb.app",
15+
showTabs: true,
16+
closableTabs: true,
2617
}}
18+
// customSetup={{
19+
// dependencies: {
20+
// "react-content-loader": "latest",
21+
// "radix-ui": "latest",
22+
// "styled-components": "latest",
23+
// "react-dom": "latest",
24+
// react: "latest",
25+
// "react-table": "latest",
26+
// },
27+
// }}
28+
// options={{
29+
// bundlerURL: "https://ymxnqs-3000.csb.app",
30+
// }}
2731
template="react"
2832
/>
2933
</div>

sandpack-react/src/components/CodeEditor/index.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useActiveCode } from "../../hooks/useActiveCode";
66
import { useSandpack } from "../../hooks/useSandpack";
77
import type { CustomLanguage, SandpackInitMode } from "../../types";
88
import { useClassNames } from "../../utils/classNames";
9+
import { useSandpackId } from "../../utils/useAsyncSandpackId";
910
import { FileTabs } from "../FileTabs";
1011
import { RunButton } from "../common/RunButton";
1112
import { SandpackStack } from "../common/Stack";
@@ -94,11 +95,23 @@ export const SandpackCodeEditor = forwardRef<CodeMirrorRef, CodeEditorProps>(
9495
updateCode(newCode, shouldUpdatePreview);
9596
};
9697

98+
const activeFileUniqueId = useSandpackId();
99+
97100
return (
98101
<SandpackStack className={classNames("editor", [className])} {...props}>
99-
{shouldShowTabs && <FileTabs closableTabs={closableTabs} />}
102+
{shouldShowTabs && (
103+
<FileTabs
104+
activeFileUniqueId={activeFileUniqueId}
105+
closableTabs={closableTabs}
106+
/>
107+
)}
100108

101-
<div className={classNames("code-editor", [editorClassName])}>
109+
<div
110+
aria-labelledby={`${activeFile}-${activeFileUniqueId}-tab`}
111+
className={classNames("code-editor", [editorClassName])}
112+
id={`${activeFile}-${activeFileUniqueId}-tab-panel`}
113+
role="tabpanel"
114+
>
102115
<CodeMirror
103116
key={activeFile}
104117
ref={ref}

sandpack-react/src/components/CodeEditor/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ export const getEditorTheme = (): Extension =>
3737
outline: "none",
3838
},
3939

40-
".cm-activeLine": {
40+
"& .cm-activeLine": {
41+
backgroundColor: "transparent",
42+
},
43+
44+
"&.cm-editor.cm-focused .cm-activeLine": {
4145
backgroundColor: `var(--${THEME_PREFIX}-colors-surface3)`,
4246
borderRadius: `var(--${THEME_PREFIX}-border-radius)`,
4347
},

sandpack-react/src/components/CodeViewer/index.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { CustomLanguage, SandpackInitMode } from "../..";
44
import { useActiveCode } from "../../hooks/useActiveCode";
55
import { useSandpack } from "../../hooks/useSandpack";
66
import { useClassNames } from "../../utils/classNames";
7+
import { useSandpackId } from "../../utils/useAsyncSandpackId";
78
import { CodeEditor } from "../CodeEditor";
89
import type { CodeEditorRef } from "../CodeEditor";
910
import type { Decorators } from "../CodeEditor/CodeMirror";
@@ -59,11 +60,20 @@ export const SandpackCodeViewer = React.forwardRef<
5960

6061
const shouldShowTabs = showTabs ?? sandpack.visibleFiles.length > 1;
6162

63+
const activeFileUniqueId = useSandpackId();
64+
6265
return (
6366
<SandpackStack className={classNames("editor-viewer")} {...props}>
64-
{shouldShowTabs ? <FileTabs /> : null}
67+
{shouldShowTabs ? (
68+
<FileTabs activeFileUniqueId={activeFileUniqueId} />
69+
) : null}
6570

66-
<div className={classNames("code-editor", [editorClassName])}>
71+
<div
72+
aria-labelledby={`${sandpack.activeFile}-${activeFileUniqueId}-tab`}
73+
className={classNames("code-editor", [editorClassName])}
74+
id={`${sandpack.activeFile}-${activeFileUniqueId}-tab-panel`}
75+
role="tabpanel"
76+
>
6777
<CodeEditor
6878
ref={ref}
6979
additionalLanguages={additionalLanguages}

sandpack-react/src/components/FileTabs/index.tsx

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,28 @@ const tabsScrollableClassName = css({
2525
marginBottom: "-1px",
2626
});
2727

28+
const tabContainer = css({
29+
display: "flex",
30+
alignItems: "center",
31+
outline: "none",
32+
position: "relative",
33+
paddingRight: "20px",
34+
margin: "1px 0",
35+
36+
"&:has(button:focus)": {
37+
outline: "$colors$accent auto 1px",
38+
},
39+
});
40+
2841
const closeButtonClassName = css({
2942
padding: "0 $space$1 0 $space$1",
3043
borderRadius: "$border$radius",
3144
marginLeft: "$space$1",
3245
width: "$space$5",
3346
visibility: "hidden",
47+
cursor: "pointer",
48+
position: "absolute",
49+
right: "0px",
3450

3551
svg: {
3652
width: "$space$3",
@@ -46,15 +62,22 @@ export const tabButton = css({
4662
height: "$layout$headerHeight",
4763
whiteSpace: "nowrap",
4864

49-
"&:focus": { outline: "none" },
50-
[`&:hover > .${closeButtonClassName}`]: { visibility: "unset" },
65+
"&:focus": {
66+
outline: "none",
67+
},
68+
[`&:hover ~ .${closeButtonClassName}`]: { visibility: "visible" },
5169
});
5270

5371
export interface FileTabsProps {
5472
/**
5573
* This adds a close button next to each file with a unique trigger to close it.
5674
*/
5775
closableTabs?: boolean;
76+
/**
77+
* unique id appended with active files. This is
78+
* used in aria-controls value along the combination of activeFile
79+
*/
80+
activeFileUniqueId?: string;
5881
}
5982

6083
/**
@@ -70,6 +93,7 @@ export const FileTabs = ({
7093
const classNames = useClassNames();
7194

7295
const { activeFile, visibleFiles, setActiveFile } = sandpack;
96+
const [hoveredIndex, setIsHoveredIndex] = React.useState<null | number>(null);
7397

7498
const handleCloseFile = (ev: React.MouseEvent<HTMLDivElement>): void => {
7599
ev.stopPropagation();
@@ -111,6 +135,56 @@ export const FileTabs = ({
111135
}
112136
};
113137

138+
const onKeyDown = ({
139+
e,
140+
index,
141+
}: {
142+
e: React.KeyboardEvent<HTMLElement>;
143+
index: number;
144+
}) => {
145+
const target = e.currentTarget as HTMLElement;
146+
147+
switch (e.key) {
148+
case "ArrowLeft":
149+
{
150+
const leftSibling = target.previousElementSibling as HTMLElement;
151+
152+
if (leftSibling) {
153+
leftSibling.querySelector("button")?.focus();
154+
setActiveFile(visibleFiles[index - 1]);
155+
}
156+
}
157+
break;
158+
case "ArrowRight":
159+
{
160+
const rightSibling = target.nextElementSibling as HTMLElement;
161+
162+
if (rightSibling) {
163+
rightSibling.querySelector("button")?.focus();
164+
setActiveFile(visibleFiles[index + 1]);
165+
}
166+
}
167+
break;
168+
case "Home": {
169+
const parent = target.parentElement as HTMLElement;
170+
171+
const firstChild = parent.firstElementChild as HTMLElement;
172+
firstChild.querySelector("button")?.focus();
173+
setActiveFile(visibleFiles[0]);
174+
break;
175+
}
176+
case "End": {
177+
const parent = target.parentElement as HTMLElement;
178+
const lastChild = parent.lastElementChild as HTMLElement;
179+
lastChild.querySelector("button")?.focus();
180+
setActiveFile(visibleFiles[-1]);
181+
break;
182+
}
183+
default:
184+
break;
185+
}
186+
};
187+
114188
return (
115189
<div
116190
className={classNames("tabs", [tabsClassName, className])}
@@ -124,27 +198,44 @@ export const FileTabs = ({
124198
])}
125199
role="tablist"
126200
>
127-
{visibleFiles.map((filePath) => (
128-
<button
129-
key={filePath}
201+
{visibleFiles.map((filePath, index) => (
202+
<div
203+
aria-controls={`${filePath}-${props.activeFileUniqueId}-tab-panel`}
130204
aria-selected={filePath === activeFile}
131-
className={classNames("tab-button", [buttonClassName, tabButton])}
132-
data-active={filePath === activeFile}
133-
onClick={(): void => setActiveFile(filePath)}
205+
className={classNames("tab-container", [tabContainer])}
206+
onKeyDown={(e) => onKeyDown({ e, index })}
207+
onMouseEnter={() => setIsHoveredIndex(index)}
208+
onMouseLeave={() => setIsHoveredIndex(null)}
134209
role="tab"
135-
title={filePath}
136-
type="button"
137210
>
138-
{getTriggerText(filePath)}
211+
<button
212+
key={filePath}
213+
className={classNames("tab-button", [buttonClassName, tabButton])}
214+
data-active={filePath === activeFile}
215+
id={`${filePath}-${props.activeFileUniqueId}-tab`}
216+
onClick={(): void => setActiveFile(filePath)}
217+
tabIndex={filePath === activeFile ? 0 : -1}
218+
title={filePath}
219+
type="button"
220+
>
221+
{getTriggerText(filePath)}
222+
</button>
139223
{closableTabs && visibleFiles.length > 1 && (
140224
<span
141225
className={classNames("close-button", [closeButtonClassName])}
142226
onClick={handleCloseFile}
227+
style={{
228+
visibility:
229+
filePath === activeFile || hoveredIndex === index
230+
? "visible"
231+
: "hidden",
232+
}}
233+
tabIndex={filePath === activeFile ? 0 : -1}
143234
>
144235
<CloseIcon />
145236
</span>
146237
)}
147-
</button>
238+
</div>
148239
))}
149240
</div>
150241
</div>

sandpack-react/src/utils/useAsyncSandpackId.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { useId as useReactId } from "react";
33

44
import { generateRandomId } from "./stringUtils";
55

6+
export const useSandpackId = () => {
7+
if (typeof useReactId === "function") {
8+
/* eslint-disable-next-line */
9+
return useReactId();
10+
} else {
11+
return generateRandomId();
12+
}
13+
};
14+
615
/**
716
* This is a hard constraint to make URLs shorter.
817
* For example, this id will be used to mount SW in the iframe

0 commit comments

Comments
 (0)