Repository navigation
Description
The playground's icon-only buttons have no accessible names, and editor tabs cannot be closed from the keyboard. These buttons render nothing but a lucide icon: Save, Create New Instrument, Share, Download Archive, Upload Archive, Refresh and the actions menu in the header, and View Files, Add File and Upload Files in the editor toolbar. lucide marks its icons aria-hidden="true". The visible label lives only in a Radix tooltip, which sets aria-describedby on the trigger while the tooltip is open; that is a description, not a name. Screen readers announce each of them as "button". Each editor tab is a single <button>, and its close "X" is a plain <div> detected by click target. The X cannot be focused and has no label, and Enter or Space on the tab only selects it, so a keyboard user has no way to close a tab. (The theme and language toggles in the same header are libui components; #1722 covers those.)
Where
apps/playground/src/components/Header/ActionButton/ActionButton.tsx:15-24 (the same pattern is in Editor/EditorButton.tsx:13-21, CloneButton.tsx:40-42, ShareButton.tsx:49-51 and ActionsDropdown.tsx:31-33):
<Tooltip.Trigger
className="h-9 w-9"
disabled={disabled}
size="icon"
type="button"
variant="outline"
onClick={onClick}
>
{icon}
</Tooltip.Trigger>apps/playground/src/components/Editor/EditorTab.tsx:25-37:
onClick={(event) => {
const isClose = xRef.current?.contains(event.target as HTMLElement);
if (isClose) {
closeFile(filename);
} else {
selectFile(filename);
}
}}
>
<span className="leading-non truncate">{filename}</span>
<div className="rounded-md p-0.5 hover:bg-slate-200 dark:hover:bg-slate-600" ref={xRef}>
<XIcon style={{ height: '14px', width: '14px' }} />
</div>Reproduce
- Open the playground with VoiceOver or NVDA running and Tab through the header and the editor toolbar.
- Tab to an editor tab and try to close it with the keyboard.
Actual: each icon button is announced as "button" with no name, and no key closes the tab.
Expected: each button is announced by its tooltip text ("Save", "Share", …), and each tab has a focusable close button announced as "Close index.ts".
Tests
E2E in testing/src/specs/playground.spec.ts: assert that page.getByRole('button', { name: 'Save' }) and the other header and toolbar buttons resolve, and that focusing a tab's close button and pressing Enter removes the tab. These role-based locators also give testing/src/pages/playground/index.page.ts a stable way to reach the header actions. There is no pure logic to unit-test.
Suggested fix
Pass the translated tooltip text as aria-label from ActionButton and EditorButton, and add the same to the Create New Instrument and Share triggers and the actions-menu trigger. In EditorTab, render the close control as a sibling <button type="button"> with a translated aria-label (for example Close {filename}), instead of a <div> nested in the tab button.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).Gaps in the suggested fix
CloneButton.tsx:78andShareButton.tsx:101write their tooltipt()inline. Hoist each into one const shared byaria-labelandTooltip.Content. InCloneButtonthe same string is also the dialog title (:46).ActionsDropdowntrigger (ActionsDropdown.tsx:31-33) has no tooltip, so there is no text to reuse. It needs a new string, e.g.t({ en: 'More actions', fr: "Plus d'actions" }).EditorTabneeds a wrapper around the select button and the new close button.last:border-rclasses to the wrapper.:last-childis evaluated among the toolbar row's children (Editor.tsx:62-86), and the select button would never be last inside its wrapper.onClickper button,xRefand theevent.target as HTMLElementcast go away.aria-currenton the select button.role="tablist"/role="tab". A tablist owns only tabs and a tab's children are presentational, so the close button would have no valid place.<body>. Move focus to the next tab, else the previous one, else View Files.Tests
happy-domand@testing-library/reactto the playground with a per-file// @vitest-environment happy-domdocblock (Editor/__tests__/MonacoEditor.test.tsx). Onmainthe playground depends on neither. If this has to go first,renderToStaticMarkup(as build: upgrade dependencies held back by breaking changes #1660'sResizable/__tests__/ResizableHandle.test.tsxdoes) can pin thearia-labels and the close button's markup, but not the click.Header/ActionButton/__tests__/ActionButton.test.tsxandEditor/__tests__/EditorButton.test.tsx: the button resolves by role with the tooltip text as its name.Editor/__tests__/EditorTab.test.tsx: clickingClose a.tsremoves it fromopenFilenames, and clicking the other button selects it. Drive the realuseAppStorewithidb-keyvaland@/instrumentsmocked, as fix: resolve 26 low-difficulty issues, one commit each #1825's slice tests do.button:has(svg.lucide-save),button:has(svg.lucide-columns-3),button:has(svg.lucide-ellipsis-vertical)andtab('a.ts').locator('svg.lucide-x'). The last one stops matching once the X leaves the tab button. Land after fix: resolve 26 low-difficulty issues, one commit each #1825 and replace all four with role locators asPlaygroundPagemembers.button[aria-current]filter for the tab itself.getByRole('button', { name: 'a.ts' })also matchesClose a.ts, because Playwright matchesnameas a substring unlessexact: true. Even withexact: trueit matches the sidebar'sa.tsfile button, whose name Playwright takes from its embedded<input>value.a.tstab, press Tab, assertClose a.tsis focused, press Enter. Then assert the tab is gone, andb.tskeepsaria-current="true"and has focus.Same area, not covered
<Input readOnly id="link">has no label (ShareButton.tsx:95). The Preview Mode help text is ahiddenspan that onlygroup-hover/help:blockreveals (ShareButton.tsx:69-77), so keyboard and screen-reader users never get it. Both are small enough to fold in here. The copy button is react-core'sCopyButton, which Icon-only buttons on the instrument toolbar, results summary and navigation have no accessible name #1701 covers.EditorFileButton.tsx:53-74andEditorAddFileButton.tsx:20-39nest an<input>inside a<button>, which HTML does not allow. Delete and Rename are reachable only through the context menu (EditorFileButton.tsx:76-93). This needs its own issue.Related
ShareButton.tsx(:17-19) and proposes a happy-domShareButton/__tests__/ShareButton.test.tsx. Whichever lands second adds its case to that file.react/jsx-no-literalsblock to the playground, but that block setsignoreProps: true(eslint.config.js:237), so it will not catch a hard-codedaria-label.testing/src/pages/playground/index.page.tsandtesting/src/specs/playground.spec.ts.Triage note written with Claude Code. Code references point at
main@d86740e.