Disabled Pajamas button links: omit href, remove dead attributes, and settle the activation guard

Context

!251416 (merged) fixed the tab-order defect on the Pajamas::ButtonComponent link branch: an <a> rendered with disabled or loading now gets tabindex="-1", so it can't be tabbed to anymore.

That lines up with how GlButton handles the same case on the Vue side, but not with GlLink, which is what actually blocks activation on a disabled GlButton link by setting aria-disabled and calling stopEvent on click. The server-rendered <a> branch has no equivalent guard.

This issue tracks three pieces of follow-up work that merge request left out to keep its fix to one line: omitting href instead of relying on disabled, removing dead attributes that don't apply to the tag they render on, and deciding whether this component needs its own activation guard for server-rendered markup.

Omit href instead of disabled

The more direct fix for a disabled link is to not render it as a link at all. An <a> with no href isn't a hyperlink: it drops out of screen reader link lists, isn't focusable, and can't be activated by any input method, with no JS guard required.

That's the direction Lauren Barker raised in review on the merge request above. Adopting it changes the shape of the link branch's disabled/loading handling in base_attributes, from adding attributes to conditionally omitting href, which is a bigger change than the one-line tabindex fix and belongs on its own.

Remove dead attributes on both render branches

Native disabled does nothing on <a>, but it stays in base_attributes today because that method is shared across all three ways this component renders: button_to, a plain <button>, and the link. disabled is load-bearing on the first two, so it can't just be dropped from the shared method.

The same mismatch shows up on the <span> this component renders when label: true is set (app/components/pajamas/button_component.html.haml:2 picks span over button, and line 22 spreads the same base_attributes into it). Rendering Pajamas::ButtonComponent.new(label: true, disabled: true) produces:

<span disabled="disabled" aria-disabled="true" type="button" class="gl-button btn disabled btn-label btn-md btn-default">

Both disabled and type are invalid on <span>. Neither does anything there.

A real cleanup means auditing base_attributes by render target rather than emitting the full attribute set unconditionally and counting on each tag to swallow the mismatch. That's two branches (<a> and <span>) and at least two attributes (disabled and type), not a one-line change.

The activation-guard question

GlLink is what makes a disabled GlButton link inert on the Vue side: it sets aria-disabled, and its click handler calls stopEvent when disabled. The server-rendered <a> branch has nothing equivalent.

GitLab's global click guard in app/assets/javascripts/main.js:173-175 only catches clicks on .btn elements that already exist in the DOM when that file runs, because it binds directly rather than through delegation. Anything injected afterward, such as content fetched and inserted client-side, never gets bound.

If the omit-href direction above lands, this stops mattering for links specifically, since a link with no href can't be activated regardless. The broader question, whether a server-rendered opted-in control needs its own per-element guard, is already open on #612142. This issue should track the link-specific instance of it and stay coordinated with that issue rather than duplicate it.

References