Skip to content

Tabs: a disabled selected tab makes the whole tablist keyboard-unreachable #10633

Open

Description

@andrewpucci

Provide a general summary of the issue here

When the selected tab is also in disabledKeys (or disabled directly), the roving tabindex is left pointing at a tab that renders no tabIndex at all, and no other tab is reachable by keyboard.

🤔 Expected Behavior?

When the selected tab is disabled, the roving tabindex should fall back to an enabled tab, matching the existing arrow-key behavior that already skips disabled tabs.

😯 Current Behavior

useTabListState points focusedKey at selectedKey with no check against disabledKeys, and its reselect fallback only runs when the selection is empty or missing (#3162), never when it's merely disabled. useTab gives a disabled tab no tabIndex. Every enabled tab ends up at tabindex="-1", the disabled selected tab has none, and the tablist itself has none either. Tab/Shift+Tab skip the whole tablist and land on the tab panel (or the first focusable element inside it, if there is one); arrow keys can't help since they only move focus once it's already inside the list. I confirmed there is no keyboard route in at all: full Tab/Shift+Tab cycles plus all four arrows, Home, and End, from before the tabs, after the tabs, and from the panel, in both jsdom and headless Chrome. Only a mouse click on an enabled tab recovers.

Three ways I found into this state:

  1. defaultSelectedKey/selectedKey names a tab disabled from the start.
  2. A selection that starts enabled and later becomes disabled (controlled or uncontrolled).
  3. Every tab disabled at mount, then some re-enabled: selection sticks to the first (disabled) key.

Related: #10278 adds an opt-in allowFocusWhenDisabled per-item prop. If merged, setting it on the selected tab would work around this, but the default (non-opt-in) behavior above is unchanged.

💁 Possible Solution

I haven't tested a fix, just read the source, so take this as a guess: useTabListState sets focusedKey from selectedKey unconditionally. Falling back to the nearest enabled key when the selected one is disabled, mirroring the existing skip-disabled arrow-key behavior, looks like it would close all three routes above, but I haven't verified it has no side effects elsewhere.

🔦 Context

Found this while auditing keyboard operability of a Tabs component built directly on react-aria-components. The selection reaching a disabled state isn't misuse. It happens through ordinary controlled-prop updates, not just an invalid initial key (which I understand is the separate, already-discussed case in #9584). Right now the only mitigation available at the consuming layer is a dev-time warning, which is diagnostic, not a fix. If there's anything else I can share, let me know!

🖥️ Steps to Reproduce

<Tabs selectedKey="appearance" disabledKeys={["appearance"]}>
  <TabList aria-label="Settings">
    <Tab id="general">General</Tab>
    <Tab id="appearance">Appearance</Tab>
    <Tab id="notifications">Notifications</Tab>
  </TabList>
  <TabPanel id="general">General content</TabPanel>
  <TabPanel id="appearance">Appearance content</TabPanel>
  <TabPanel id="notifications">Notifications content</TabPanel>
</Tabs>

Tab into the page: focus skips the tablist entirely and lands on the panel. No arrow key, Tab, or Shift+Tab combination reaches any tab. Only clicking an enabled tab recovers.

Live reproduction: https://stackblitz.com/edit/dupebdjj?file=index.html

🌍 Your Environment

Version

react-aria-components 1.20.0 (react-aria 3.51.0, react-stately 3.49.0)

What browsers are you seeing the problem on?

Chrome

If other, please specify.

No response

What operating system are you using?

macOS

🦄 Other

🧢 Your Company/Team

No response

🕷 Tracking Issue

No response

Activity

  1. snowystinger commented on Sep 25, 2026

    @snowystinger
    Member

    I think we'd say that you shouldn't disable the selected key. You also know best which key should be selected if the case that the current one becomes disabled. We'd just be making a guess. Do you have a real use case where this happens? or is it just theoretical?

  2. andrewpucci commented on Sep 28, 2026

    @andrewpucci
    Author

    Yes, it's a real use case for us, though our app avoids it today with a guard of its own. Looking into it also showed me the fix I suggested in the issue was wrong. I think the better fix is for a selected tab to stay focusable when it's disabled. Here's a generic version of the use case, then the reasoning.

    Imagine a CI build page with Logs, Tests, and Artifacts tabs. Artifacts is disabled until the build finishes (disabled rather than hidden, so the tabs don't jump around while a build runs), and the selected tab lives in the URL so it can be shared. Two ordinary things leave the selected tab disabled:

    1. Someone pastes a link to a build's Artifacts tab in chat, and the build gets re-run before a teammate opens it.
    2. You're on the Artifacts tab and click Re-run in the page header. The build restarts, and the tab you're on becomes disabled.
    function BuildTabs({ build, tab, setTab }) {
      return (
        <Tabs selectedKey={tab} onSelectionChange={setTab}>
          <TabList aria-label="Build">
            <Tab id="logs">Logs</Tab>
            <Tab id="tests">Tests</Tab>
            <Tab id="artifacts" isDisabled={build.status !== 'finished'}>
              Artifacts
            </Tab>
          </TabList>
          {/* panels */}
        </Tabs>
      );
    }

    You're right that the app knows best which tab to select, and that's how we handle it: we fall back to Logs when the requested tab is disabled. My concern is that every feature has to remember that guard. If one forgets, nothing looks wrong with a mouse, but keyboard users can't reach any tab in the list.

    On the fix, I originally asked for the roving tabindex to fall back to an enabled tab. The APG Tabs pattern says focus entering the tab list goes to the active tab, so moving it to a different tab would break that.

    Focusability of disabled controls fits better. It lists tabs among the elements its examples keep focusable with aria-disabled="true", since screen reader users are far less likely to discover disabled elements they can't focus. Applied here, the selected tab keeps tabindex="0" while disabled, so focus lands on it and the arrow keys move on from there. Nothing about selection or focus has to be guessed.

    #10278's allowFocusWhenDisabled does this as an opt-in, but its tests don't cover a tab that's both selected and disabled, so I tried that on its branch (jsdom only, test below). With the opt-in, pressing Tab lands on the selected disabled tab and the arrow keys move on, in both activation modes, including when the tab becomes disabled after mount or while it has focus. Without it, the tab list is still skipped.

    Test run against #10278's branch
    import {pointerMap, render} from '@react-spectrum/test-utils-internal';
    import React from 'react';
    import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs';
    import userEvent from '@testing-library/user-event';
    
    let tree = ({tabsProps = {}, artifactsProps = {}} = {}) => (
      <>
        <button>before</button>
        <Tabs {...tabsProps}>
          <TabList aria-label="Build">
            <Tab id="logs">Logs</Tab>
            <Tab id="tests">Tests</Tab>
            <Tab id="artifacts" {...artifactsProps}>
              Artifacts
            </Tab>
          </TabList>
          <TabPanel id="logs">Logs panel</TabPanel>
          <TabPanel id="tests">Tests panel</TabPanel>
          <TabPanel id="artifacts">Artifacts panel</TabPanel>
        </Tabs>
      </>
    );
    
    describe('selected tab that is disabled', () => {
      let user;
      beforeAll(() => {
        user = userEvent.setup({delay: null, pointerMap});
      });
    
      let tabs = getAllByRole => {
        let [logs, tests, artifacts] = getAllByRole('tab');
        return {logs, tests, artifacts};
      };
    
      it('baseline: without the opt-in, Tab skips the whole tab list', async () => {
        let {getAllByRole, getByRole} = render(
          tree({tabsProps: {selectedKey: 'artifacts'}, artifactsProps: {isDisabled: true}})
        );
        let {logs, tests, artifacts} = tabs(getAllByRole);
        expect(artifacts).toHaveAttribute('aria-selected', 'true');
        expect(artifacts).toHaveAttribute('aria-disabled', 'true');
        expect(artifacts).not.toHaveAttribute('tabindex');
        expect(logs).toHaveAttribute('tabindex', '-1');
        expect(tests).toHaveAttribute('tabindex', '-1');
    
        await user.click(getByRole('button', {name: 'before'}));
        await user.tab();
        expect(getAllByRole('tab')).not.toContain(document.activeElement);
      });
    
      for (let [label, tabsExtra, artifactsProps] of [
        ['isDisabled + allowFocusWhenDisabled', {}, {isDisabled: true, allowFocusWhenDisabled: true}],
        [
          'disabledKeys + allowFocusWhenDisabled',
          {disabledKeys: ['artifacts']},
          {allowFocusWhenDisabled: true}
        ]
      ]) {
        for (let keyboardActivation of ['automatic', 'manual']) {
          it(`${label}, ${keyboardActivation}: focus enters on the selected disabled tab and arrows move on`, async () => {
            let onSelectionChange = jest.fn();
            let {getAllByRole, getByRole} = render(
              tree({
                tabsProps: {
                  selectedKey: 'artifacts',
                  keyboardActivation,
                  onSelectionChange,
                  ...tabsExtra
                },
                artifactsProps
              })
            );
            let {logs, tests, artifacts} = tabs(getAllByRole);
            expect(artifacts).toHaveAttribute('aria-selected', 'true');
            expect(artifacts).toHaveAttribute('aria-disabled', 'true');
            expect(artifacts).toHaveAttribute('tabindex', '0');
            expect(logs).toHaveAttribute('tabindex', '-1');
            expect(tests).toHaveAttribute('tabindex', '-1');
    
            await user.click(getByRole('button', {name: 'before'}));
            await user.tab();
            expect(document.activeElement).toBe(artifacts);
            expect(onSelectionChange).not.toHaveBeenCalled();
    
            await user.keyboard('{ArrowLeft}');
            expect(document.activeElement).toBe(tests);
            if (keyboardActivation === 'automatic') {
              expect(onSelectionChange).toHaveBeenLastCalledWith('tests');
            } else {
              expect(onSelectionChange).not.toHaveBeenCalled();
            }
    
            await user.keyboard('{Home}');
            expect(document.activeElement).toBe(logs);
          });
        }
      }
    
      for (let keyboardActivation of ['automatic', 'manual']) {
        let enabled = {selectedKey: 'artifacts', keyboardActivation};
        let optIn = {allowFocusWhenDisabled: true};
        let disabled = {isDisabled: true, allowFocusWhenDisabled: true};
    
        it(`${keyboardActivation}: a selection that becomes disabled after mount stays reachable with the opt-in`, async () => {
          let {getAllByRole, getByRole, rerender} = render(tree({tabsProps: enabled, artifactsProps: optIn}));
          rerender(tree({tabsProps: enabled, artifactsProps: disabled}));
          let {tests, artifacts} = tabs(getAllByRole);
          expect(artifacts).toHaveAttribute('aria-disabled', 'true');
          expect(artifacts).toHaveAttribute('tabindex', '0');
    
          await user.click(getByRole('button', {name: 'before'}));
          await user.tab();
          expect(document.activeElement).toBe(artifacts);
          await user.keyboard('{ArrowLeft}');
          expect(document.activeElement).toBe(tests);
        });
    
        it(`${keyboardActivation}: the focused selected tab becoming disabled keeps focus there with the opt-in`, async () => {
          let {getAllByRole, getByRole, rerender} = render(tree({tabsProps: enabled, artifactsProps: optIn}));
          await user.click(getByRole('button', {name: 'before'}));
          await user.tab();
          let {tests, artifacts} = tabs(getAllByRole);
          expect(document.activeElement).toBe(artifacts);
    
          rerender(tree({tabsProps: enabled, artifactsProps: disabled}));
          expect(artifacts).toHaveAttribute('aria-disabled', 'true');
          expect(document.activeElement).toBe(artifacts);
          expect(artifacts).toHaveAttribute('tabindex', '0');
          await user.keyboard('{ArrowLeft}');
          expect(document.activeElement).toBe(tests);
        });
      }
    });

    Would it make sense for a selected tab to stay focusable when it's disabled by default, even without #10278? That only changes a state where the tab list is unreachable today, so I don't think anyone is relying on it, and other disabled tabs would still be skipped as they are now.

  3. snowystinger commented on Sep 29, 2026

    @snowystinger
    Member

    If artifacts should be discoverable while waiting for the build to be finished, then it seems like it should not be disabled and should have a content area with a progress of some variety?

    The biggest problem with allowing it to be disabled but still focusable is that people usually style disabled things in a way that doesn't meet contrast (doesn't need to for WCAG compliance). This means the element is not discoverable to users with low sight but who aren't using a screen reader. To them it could just look like a focus ring on empty space.

    We've not discussed #10278 yet as a team, so I cannot say if we will be taking it on or what will happen to it yet.

  4. andrewpucci commented on Sep 29, 2026

    @andrewpucci
    Author

    Yes, I agree with that. And I think that's probably the right way to go in that situation.

    But the generic scenario I described above is not quite that scenario. It's not meant to be discoverable... the tab was valid and not disabled when the URL was copied and shared, but something in the state changed and when the URL is visited again, the tab is not meant to be discoverable. Now, it is more that it needs to be recoverable, it's not an ideal state.

  5. snowystinger commented on Sep 29, 2026

    @snowystinger
    Member

    Then this applies:

    I think we'd say that you shouldn't disable the selected key. You also know best which key should be selected if the case that the current one becomes disabled.

    When the app opens again from that URL you'd set the selectedKey to whatever it should be. You are controlling the selectedKey, we can't just set it to something else, you could elect to ignore that. If we forced it, then your state would be out of sync with reality.

    There are a few different issues in here, I've been focused on one. So just to quickly clarify.

    1. defaultSelectedKey/selectedKey names a tab disabled from the start.
    • I think this isn't a bug, set a different selected key
    1. A selection that starts enabled and later becomes disabled (controlled or uncontrolled).
    • uncontrolled seems like a bug
    1. Every tab disabled at mount, then some re-enabled: selection sticks to the first (disabled) key.
    • possibly a bug, I think that given you intend it to be enabled in the future, maybe it's best not to render it until you have one that's ready. otherwise a single tab that has information on what progress is being made
  6. andrewpucci commented on Oct 13, 2026

    @andrewpucci
    Author

    Thanks for breaking those out, that's a fair split. I agree on the controlled cases, and we already warn in dev on our side when selectedKey is in disabledKeys.

    For the uncontrolled cases, #10702 extends the existing fallback in useTabListState to cover case 2, and case 3 as well if you decide that one's a bug. I ran it against my repro cases in jsdom on React 18 and 19, and it holds up apart from one edge case I left a note about there.

    One thing I noticed: with that change, an uncontrolled defaultSelectedKey that names a disabled tab also falls back to the first enabled tab, so it touches case 1 as well.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions