Skip to content

feat(button-group): add roving focus and move the group ARIA to the host - #2385

Merged
rkaraivanov merged 4 commits into
masterfrom
rkaraivanov/button-group-aria-improvements
Sep 17, 2026
Merged

rkaraivanov merged 4 commits into
masterfrom
rkaraivanov/button-group-aria-improvements

Conversation

@rkaraivanov

@rkaraivanov rkaraivanov commented Sep 17, 2026

Copy link
Copy Markdown
Member

Description

The single selection modes expose radio semantics but had no keyboard handling: every toggle button was its own tab stop and the arrow keys did nothing, which the radiogroup role does not allow. The group now runs a roving tab index through addRovingFocusController, with the selected button - or the first enabled one - as the single tab stop, and arrow navigation that carries the selection along, skips disabled buttons and wraps. The alignment picks the axis and the horizontal one follows the writing direction. The multiple selection mode keeps a tab stop per button.

The group resolves the tab stop and publishes it on ButtonGroupContext; each button applies it to its own host, because its shadow root delegates focus and an inner tabindex="-1" would leave the host in the tab order. syncSelection becomes syncState, covering both selected and disabled.

The role and the disabled state move from an element of the shadow root onto the host through addInternalsController, matching the other container components, so that an author's aria-label names the group.

Also collapses the two selection event emitters into one writer, resolves the tab stop once per interaction instead of once per emitted event, and drops a dead selected-items reset and a slot guard the slot controller already provides.

Type of Change

  • New feature (non-breaking change that adds functionality)

Checklist

  • My code follows the project's coding standards
  • I have tested my changes locally

The single selection modes expose radio semantics but had no keyboard
handling: every toggle button was its own tab stop and the arrow keys did
nothing, which the radiogroup role does not allow. The group now runs a
roving tab index through addRovingFocusController, with the selected button
- or the first enabled one - as the single tab stop, and arrow navigation
that carries the selection along, skips disabled buttons and wraps. The
alignment picks the axis and the horizontal one follows the writing
direction. The multiple selection mode keeps a tab stop per button.

The group resolves the tab stop and publishes it on ButtonGroupContext;
each button applies it to its own host, because its shadow root delegates
focus and an inner tabindex="-1" would leave the host in the tab order.
syncSelection becomes syncState, covering both selected and disabled.

The role and the disabled state move from an element of the shadow root
onto the host through addInternalsController, matching the other container
components, so that an author's aria-label names the group.

Also collapses the two selection event emitters into one writer, resolves
the tab stop once per interaction instead of once per emitted event, and
drops a dead selected-items reset and a slot guard the slot controller
already provides.
Copilot AI lite review requested due to automatic review settings September 17, 2026 08:23
@rkaraivanov rkaraivanov added a11y When the issue or PR is related to accessibility button-group labels Sep 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Resolve the missing aria-orientation and restore tabindex when group ownership ends.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds roving focus, keyboard navigation, and host-level ARIA semantics to single-selection button groups.

Changes:

  • Adds selection-aware tab stops and arrow-key navigation.
  • Moves group roles and disabled state to the host.
  • Updates context, tests, and changelog.
File summaries
File Description
src/internals/context.ts Extends button-group context state.
src/components/button-group/toggle-button.ts Applies group tab-stop state.
src/components/button-group/button-group.ts Implements roving focus and host ARIA semantics.
src/components/button-group/button-group.spec.ts Adds accessibility and keyboard-navigation coverage.
CHANGELOG.md Documents the new behavior.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/components/button-group/button-group.ts
Comment thread src/components/button-group/toggle-button.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Five moderate findings remain in focus, context, and tabindex lifecycle handling.

Review details

Suppressed comments (5)

src/components/button-group/button-group.ts:307

  • When a non-tab-stop button is moved from this group to another still-connected parent, disconnectedCallback never runs. If _tabStop remains unchanged, _updateTabStop() does not publish, so the moved button keeps the group's tabindex="-1" and remains excluded from the tab order outside the group. Republish the context or otherwise update/restore buttons whenever slot membership changes, even when the selected tab stop itself is unchanged.
  private _handleSlotChange(): void {
    this._enforceSingleSelection();
    this._updateTabStop();

src/components/button-group/button-group.ts:268

  • getRoot(this).activeElement resolves the active element in the containing root, not in this component's shadow root. When the group is used inside another shadow root, that value is the igc-button-group host rather than the focused slotted toggle button, so _getFocusedButton() returns null and all arrow navigation becomes a no-op. Resolve the focused item from the group's own focus chain (for example, by checking each toggle button's delegated shadow-root focus) instead.
    const button = getRoot(this).activeElement?.closest(
      IgcToggleButtonComponent.tagName
    );

src/components/button-group/toggle-button.ts:80

  • This cleanup breaks detach/reattach of a non-tab-stop button. The async context is retained across the detach, but reconnecting does not trigger willUpdate; if _tabStop did not change, the provider never republishes, so the button returns without tabindex="-1" and becomes a second tab stop. Reconcile the managed tabindex on reconnect (or otherwise republish the group state after reattachment).
  public override disconnectedCallback(): void {
    if (this._context.value) {
      this.removeAttribute('tabindex');
    }

src/components/button-group/toggle-button.ts:80

  • The group overwrites the host's tabindex while the button is slotted, but this unconditional removal loses any tabindex supplied by the author before the button joined the group. After removal, a button that started with tabindex="-1" (or another explicit value) is no longer in its original tab order, contradicting the standalone behavior described below. Preserve and restore the pre-group value instead of always removing the attribute.
  public override disconnectedCallback(): void {
    if (this._context.value) {
      this.removeAttribute('tabindex');
    }

src/components/button-group/toggle-button.ts:82

  • A button moved from one group to another remains bound to the first AsyncContextConsumer: its hostConnected() returns early when a consumer already exists, so it never requests the new group's context. After this callback removes the old tabindex, the new group cannot publish its roving tab-stop state to the button, and state callbacks still target the old group. The context membership must be refreshed on reconnect/reparenting.
  public override disconnectedCallback(): void {
    if (this._context.value) {
      this.removeAttribute('tabindex');
    }

    super.disconnectedCallback();
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

… group

The group takes over the tab order of its buttons to run the roving tab
index, and gave back nothing when a button left it. A button that carried
an author tabindex came out of the group without one.

Each button now records the tab order it had when a group first took over
and restores it on disconnect. Adds regression tests for the surrounding
lifecycle: moving a button between parents and between groups, detaching
and re-attaching one, and arrow navigation from inside another shadow root.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Fix the stale-context tabindex mutation after disconnection; also correct the contradictory test comment.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

src/components/button-group/button-group.spec.ts:1076

  • This test comment contradicts the expected value and the ARIA default: a radiogroup with no aria-orientation is treated as horizontal, not vertical. Please correct the comment so it does not document the opposite behavior.
      // A `radiogroup` that announces no orientation is taken to be vertical.
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/components/button-group/toggle-button.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Two moderate keyboard-navigation issues remain unresolved.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

src/components/button-group/button-group.ts:268

  • When the group itself is inside another shadow root, getRoot(this).activeElement resolves to the group host rather than the focused toggle button (the added nested-shadow-root test exercises this case). Consequently closest('igc-toggle-button') returns null and every arrow key becomes a no-op. Resolve the focused button from the group's assigned buttons (for example with :focus) before falling back to the containing root.

src/components/button-group/button-group.ts:188

  • The custom skip callback replaces the key-bindings controller's default input/textarea/select skip, but only checks the group state. Arrow events from any slotted descendant that is not one of the group's direct buttons therefore reach this controller, are preventDefault()ed by bindingDefaults, and then no-op because _getFocusedButton() returns null; this breaks native keyboard behavior for nested controls (and for the explicitly unsupported nested toggle buttons). Restrict the callback to events whose composed path contains one of _buttons.
        skip: () => this.disabled || this._isMultiple,
        bindingDefaults: { preventDefault: true, repeat: true },
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@rkaraivanov
rkaraivanov merged commit 6769cee into master Sep 17, 2026
8 checks passed
@rkaraivanov
rkaraivanov deleted the rkaraivanov/button-group-aria-improvements branch September 17, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y When the issue or PR is related to accessibility button-group

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants