Remove disabled Pajamas button links from the tab order

What breaks

Passing href together with disabled or loading renders:

<a disabled="disabled" aria-disabled="true" class="gl-button btn disabled btn-md btn-default" href="/foo">

disabled isn't valid on <a>, so browsers ignore it. The link stays focusable and in the tab order.

Why this isn't caught everywhere

app/assets/javascripts/main.js:173-175 binds a click guard directly to .btn elements, checking hasClass('disabled') and calling preventDefault() and stopImmediatePropagation(). It doesn't cover markup injected after it runs. Where it's attached, the only defect is tab order. Where it isn't, the link can also be activated.

Traced case

The milestone labels tab renders two disabled link buttons (app/views/shared/milestones/_labels_tab.html.haml:20 and :23) via app/assets/javascripts/milestones/milestone.js:33, after main.js has already bound its guard. DOMPurify's config (app/assets/javascripts/lib/dompurify.js:14-31) keeps the tabindex attribute intact.

The fix

# `disabled` is not valid on `<a>`, so browsers ignore it and the link stays
# focusable. Take it out of the tab order the way GlButton does.
attributes['tabindex'] = '-1' if link? && !form? && (@disabled || @loading)

base_attributes is spread after @button_options in the template, so a caller-supplied tabindex is overridden. The !form? guard leaves the button_to path alone, since that renders a real <button> where native disabled already works. GlButton's tabindex() returns -1 for disabled links when not in accessible-disabled mode, and GlLink's click guard is what actually blocks activation. This fix only handles tab order, so it isn't full parity with that Vue behavior. It also fires on loading, which GlButton's tabindex() doesn't, since a loading link is as unusable as a disabled one.

Affected call sites

Ten call sites pass both disabled and href, across five files:

  • app/views/kaminari/gitlab/_keyset_paginator.html.haml (4)
  • app/views/shared/milestones/_labels_tab.html.haml (2)
  • app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml (2)
  • ee/app/views/admin/application_settings/_semantic_search_settings.html.haml (1)
  • app/views/admin/users/_head.html.haml (1)

Specs

spec/components/pajamas/button_component_spec.rb: 91 examples, 0 failures. Rubocop clean.

  • #612142 — accessible-disabled mechanism for this component
  • !250688 (merged) — in-flight MR adding an opt-in accessible_disabled option; if it merges first, the condition here becomes link? && !form? && native_disabled?
  • #621995 — follow-up: omit href when disabled, plus the dead-attribute cleanup
Edited by Adam Ferch

Merge request reports

Loading
Loading