Skip to content

Playground icon-only buttons have no accessible names, and editor tabs cannot be closed from the keyboard #1763

Open

Description

@joshunrau

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

  1. Open the playground with VoiceOver or NVDA running and Tab through the header and the editor toolbar.
  2. 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

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Playgroundapps/playground (in-browser instrument editor)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e).

    Gaps in the suggested fix

    • CloneButton.tsx:78 and ShareButton.tsx:101 write their tooltip t() inline. Hoist each into one const shared by aria-label and Tooltip.Content. In CloneButton the same string is also the dialog title (:46).
    • The ActionsDropdown trigger (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" }).
    • EditorTab needs a wrapper around the select button and the new close button.
      • Move the border, background and last:border-r classes to the wrapper. :last-child is evaluated among the toolbar row's children (Editor.tsx:62-86), and the select button would never be last inside its wrapper.
      • With one onClick per button, xRef and the event.target as HTMLElement cast go away.
      • Keep fix: resolve 26 low-difficulty issues, one commit each #1825's aria-current on the select button.
      • Do not switch to 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.
    • Closing the focused tab unmounts its close button, and focus drops to <body>. Move focus to the next tab, else the previous one, else View Files.

    Tests

    • Unit: land after build: upgrade dependencies held back by breaking changes #1660, which adds happy-dom and @testing-library/react to the playground with a per-file // @vitest-environment happy-dom docblock (Editor/__tests__/MonacoEditor.test.tsx). On main the playground depends on neither. If this has to go first, renderToStaticMarkup (as build: upgrade dependencies held back by breaking changes #1660's Resizable/__tests__/ResizableHandle.test.tsx does) can pin the aria-labels and the close button's markup, but not the click.
      • Header/ActionButton/__tests__/ActionButton.test.tsx and Editor/__tests__/EditorButton.test.tsx: the button resolves by role with the tooltip text as its name.
      • Editor/__tests__/EditorTab.test.tsx: clicking Close a.ts removes it from openFilenames, and clicking the other button selects it. Drive the real useAppStore with idb-keyval and @/instruments mocked, as fix: resolve 26 low-difficulty issues, one commit each #1825's slice tests do.
    • E2E: fix: resolve 26 low-difficulty issues, one commit each #1825's new specs find these controls by icon class: button:has(svg.lucide-save), button:has(svg.lucide-columns-3), button:has(svg.lucide-ellipsis-vertical) and tab('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 as PlaygroundPage members.
      • Keep fix: resolve 26 low-difficulty issues, one commit each #1825's button[aria-current] filter for the tab itself. getByRole('button', { name: 'a.ts' }) also matches Close a.ts, because Playwright matches name as a substring unless exact: true. Even with exact: true it matches the sidebar's a.ts file button, whose name Playwright takes from its embedded <input> value.
      • Keyboard case: focus the a.ts tab, press Tab, assert Close a.ts is focused, press Enter. Then assert the tab is gone, and b.ts keeps aria-current="true" and has focus.

    Same area, not covered

    • Share popover: the link <Input readOnly id="link"> has no label (ShareButton.tsx:95). The Preview Mode help text is a hidden span that only group-hover/help:block reveals (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's CopyButton, which Icon-only buttons on the instrument toolbar, results summary and navigation have no accessible name #1701 covers.
    • File sidebar: EditorFileButton.tsx:53-74 and EditorAddFileButton.tsx:20-39 nest 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

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Playgroundapps/playground (in-browser instrument editor)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions