Skip to content

[select][button] Keep the text of a disabled non-native button selectable - #5758

Open
vikuscz wants to merge 1 commit into
mui:masterfrom
vikuscz:fix/disabled-non-native-button-text-selection
Open

vikuscz wants to merge 1 commit into
mui:masterfrom
vikuscz:fix/disabled-non-native-button-text-selection

Conversation

@vikuscz

@vikuscz vikuscz commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #5736

A double click on the value of a disabled Select.Trigger selects nothing, while a selection dragged in from outside the trigger passes straight through and includes it, so the text is selectable in principle and only a press starting inside is refused. The same trigger without disabled selects the value, and so do Combobox and every other input, which leaves the value of a disabled field as the one value in the family that cannot be read out and copied.

Cause

useButton cancels the pointerdown default action for a disabled button:

onPointerDown(event: React.PointerEvent) {
  if (disabled) {
    event.preventDefault();
    return;
  }
  externalOnPointerDown?.(event);
},

Focus, starting a text selection and starting a drag are one default action, so canceling it to keep focus off the button also makes the text unselectable. The cancel is needed because the element stays focusable: useFocusableWhenDisabled gives a disabled non-native button tabIndex: -1, and Select.Trigger sets tabIndex: disabled ? -1 : 0 itself. -1 takes it out of the tab order but keeps it focusable by a click.

Measured with a library-free repro, identical in chromium, firefox and webkit:

element click focuses it word selectable by a double click
<button disabled> no no
a div with aria-disabled and no tabindex no yes
the same div with tabindex="-1" yes yes
the same div with tabindex="-1" and a canceled pointerdown no no

The two are therefore not separable on one element. While -1 is present, canceling the default action is the only way to keep focus off, and it always costs the selection.

Fix

Make a disabled non-native button unfocusable instead of focusable and canceled, which is what a disabled <button> is:

  • useFocusableWhenDisabled assigns no tabIndex at all when the button is disabled, non-native and not focusableWhenDisabled, instead of -1.
  • Select.Trigger likewise carries no tabIndex while disabled.
  • useButton no longer cancels pointerdown. The early return stays, so no external handler runs.

The tab order does not change: a <span> or <div> carrying no tabindex is not tabbable either. focusableWhenDisabled is untouched, and so is every composite item, whose tabIndex this hook never set.

Relationship to #5607

#5607 landed this behavior a day before this PR, and its changelog note says non-native disabled triggers "receive tabindex="-1"". Everything that PR set out to do is kept: disabled triggers leave the tab order, native ones carry disabled, non-native ones carry aria-disabled="true" and no disabled attribute. Only the mechanism changes, so that one line of the note becomes "carry no tabindex".

The three tests that asserted the attribute now assert its absence. Their names and their behavioral assertions are unchanged: aria-disabled is still set, Tab still does not focus the trigger, no handler still fires.

If you would rather keep tabindex="-1", the trade-off is the one in the table above: the value of a disabled field stays unselectable. I could not find a third option. No arrangement of handlers separates focus from text selection on the same element, and reverting focus from a focus handler would fire focus and blur and run Select.Trigger's own onFocus work.

Changelog note

A disabled non-native button no longer renders tabindex="-1"; it renders no tabindex at all. The tab order is unchanged and aria-disabled / data-disabled are unchanged, so a [tabindex="-1"] selector is the only thing that stops matching. In exchange, the text of a disabled trigger can be selected and copied.

Tests

  • useButton: a disabled non-native button carries no tabindex, is focused by neither Tab nor a click, and does not cancel pointerdown.
  • Select.Trigger: the value of a disabled non-native trigger can be selected by a real double click, driven over CDP so the selection is the browser's own default action rather than a synthetic event, and the trigger is still not focused by it. Blink only, like the other CDP tests in the repo.

All three fail on master and pass here. pnpm test:jsdom and pnpm test:chromium are green locally, with one exception I could not tie to this change: ScrollAreaViewport's data-scrolling test failed once in a loaded run and passed three times out of three on its own, and that file references neither useButton nor tabIndex nor disabled.

Beyond the suite, I patched the change into an installed @base-ui/react@1.8.0 and measured the component that the report came from, a Select and a MultiSelect wrapper rendering the trigger with nativeButton={false}, across the eleven state rows of their state matrices. One forced double click per row, reading window.getSelection() back:

state before after
enabled tabindex="0", selects unchanged
read-only tabindex="0", selects unchanged
disabled tabindex="-1", selects nothing no tabindex, selects

Nothing else in those matrices moved, in either component.

…able

A disabled `Select.Trigger` rendered with `nativeButton={false}` could not be
selected with the mouse, so its value could not be read out and copied, while a
read-only trigger, `Combobox` and every other input could.

`useButton` canceled the `pointerdown` default action for a disabled button to
keep focus off it. Focus, starting a text selection and starting a drag are one
default action, so the cancel always took the selection with it. It was needed
because the element stayed focusable: `useFocusableWhenDisabled` gave a disabled
non-native button `tabIndex: -1` and `Select.Trigger` set
`tabIndex: disabled ? -1 : 0` itself, and `-1` keeps an element focusable by a
click.

A disabled non-native button is now unfocusable rather than focusable and
canceled, which is what a disabled `<button>` is: no `tabIndex` is assigned while
disabled unless `focusableWhenDisabled` asks for it, and `pointerdown` is left
alone. The tab order is unchanged, because an element carrying no `tabindex` is
not tabbable either.
@pkg-pr-new

pkg-pr-new Bot commented Sep 18, 2026

Copy link
Copy Markdown

commit: 0846c6a

@code-infra-dashboard

code-infra-dashboard Bot commented Sep 18, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react ▼-35B(-0.01%) ▼-2B(0.00%)

Details of bundle changes

Performance

Total duration: 1,003.48 ms -96.80 ms(-8.8%) | Renders: 76 (+0) | Paint: 1,704.19 ms -132.99 ms(-7.2%)

Test Duration Renders
Select open (500 options) 46.50 ms ▼-22.74 ms(-32.8%) 14 (+0)
Combobox type — 500 items, all stay mounted (type "Row ") 26.51 ms ▼-8.71 ms(-24.7%) 11 (+0)

13 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Sep 18, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 0846c6a
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6aad2f0e188fc0000893c4f5
😎 Deploy Preview https://deploy-preview-5758--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@zannager zannager added component: select Changes related to the select component. component: button Changes related to the button component. labels Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: button Changes related to the button component. component: select Changes related to the select component.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[select] Disabled trigger's value can't be selected or copied with the mouse

2 participants