Skip to content

feat(core): selection controller#6402

Open
nikkimk wants to merge 44 commits into
mainfrom
nikkimk/poc-selection-controller
Open

feat(core): selection controller#6402
nikkimk wants to merge 44 commits into
mainfrom
nikkimk/poc-selection-controller

Conversation

@nikkimk

@nikkimk nikkimk commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Description

  • Proof-of-concept for a controller to handle single or multi-selection.
  • Documentation with demos and code snippets for implementing the controller.
  • Refactor of 2nd-gen tabs to leverage focus group navigation and selection controllers.

Motivation and context

This controller could handle selection for tabs, accordions, radio groups, swatch groups with selectable swatches, button groups that function as radio groups, menus, and pickers.

Related issue(s)

  • fixes SWC-2098

Screenshots (if appropriate)


Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed at the Accessibility Practices for this feature, see: Aria Practices
  • I have added automated tests to cover my changes.
  • I have included a well-written changeset if my change needs to be published.
  • I have included updated documentation if my change required it.

Reviewer's checklist

  • Includes a Github Issue with appropriate flag or Jira ticket number without a link
  • Includes thoughtfully written changeset if changes suggested include patch, minor, or major features
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

Manual review test cases

  • Selection Controller

    1. Checkout the branch (selection controller docs only show in internally dev mode).
    2. Start second gen storybook locally.
    3. Verify that the documentation is sufficient and the examples work as they should.
    4. Verify that automated tests pass.
    5. Verify the examples pass manual keyboard and screenreader testing.
  • Tabs

    1. Go to Tabs documentation
    2. Verify that the examples work.
    3. Verify that the public API has not changed.
    4. Verify no visual regressions.
    5. Verify that automated tests pass.
    6. Verify the examples pass manual keyboard and screenreader testing.
  • Is the creation and implementation of this controller useful for tabs, accordions, radio groups, swatch groups with selectable swatches, button groups that function as radio groups, menus, and pickers? Or is it easier just to let each custom element handle its own selection management?

Device review

  • Did it pass in Desktop?
  • Did it pass in (emulated) Mobile?
  • Did it pass in (emulated) iPad?

@nikkimk nikkimk self-assigned this Jun 12, 2026
@changeset-bot

changeset-bot Bot commented Jun 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eb5a302

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@adobe/spectrum-wc Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

📚 Branch Preview Links

🔍 Gen1 Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-6402

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@nikkimk nikkimk marked this pull request as ready for review June 15, 2026 15:07
@nikkimk nikkimk requested a review from a team as a code owner June 15, 2026 15:07
@nikkimk nikkimk added the Status:Ready for review PR ready for review or re-review. label Jun 15, 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
<DocsFooter />
```

#### Controller MDX template

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Controllers require different documentation, like how to use them with a host, so they should have different rules.

10. **BEHAVIORS STORIES** - Built-in functionality (if applicable)
11. **ACCESSIBILITY STORIES** - A11y demonstration

#### Controller section order

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Controllers require different documentation, like how to use them with a host, so they should have different rules.

const items = this.getEligibleItems();
if (items.length === 0) {
for (const el of this.getRawItems()) {
// getRawItems() calls getItems() which may return [] when the host

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Needed to make adjustments once the controller was implemented with a host that can be disabled.

// Fall back to event.code when event.key is empty (synthetic test events).
// Real browser events always populate event.key, so this never affects
// numpad disambiguation (e.g. Numpad6 NumLock-off sets key='ArrowRight').
const key = event.key || event.code;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

When used with selection controller, there were issues with conflicts in keyboard events.

nikkimk and others added 8 commits June 15, 2026 11:44
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ies (#6403)

* feat(*): rename /shared to /_lit-styles

* docs(css, ai): document process and features of shared style directories
* docs(color-handle): accessbility migration-analysis

* docs(color-handle): added notes from adobe a11y

* docs(color-loupe): added adaptive border guidance
…pectrum-web-components into nikkimk/poc-selection-controller
nikkimk and others added 5 commits June 15, 2026 12:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pectrum-web-components into nikkimk/poc-selection-controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nikkimk nikkimk changed the title feat(core): proof-of-concept selection controller feat(core): selection controller Jun 16, 2026
@nikkimk nikkimk added the High priority PR review PR is a high priority and should be reviewed ASAP label Jun 16, 2026
@Rajdeepc

Rajdeepc commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@nikkimk, Nice work here. These are some of my high level observations.

  1. The API already encodes Tabs-specific assumptions as general rules like defaultToFirstSelectable's fallback assumes "disabled" means getItems() returns [] but this may not hold for Accordion/Picker.
    Can you please validate against a second consumer (Accordion maybe?) before treating the API as stable.

  2. SelectionController and FocusgroupNavigationController must be manually sequenced by the consumer, with the ordering invariant living only in a code comment in Tabs.base.ts. How can we mitigate this for the consumer? Maybe add a cmbined layer?

  3. Tabs works around the lack of a "commit selection without dispatching the public event" mode by toggling an unguarded _suppressSelectionChangeEvent flag. This is where a bug is waiting to happen (stuck true on exception) that every future consumer will reinvent.
    We need to add a first-class syncSelectedItem(item, { silent: true })-style API to the controller.

  4. We need to put this as a breaking change - change event order is changed. confirmSelectionChange now dispatches Tabs' change event before state (selected, tab.selected) is updated .
    Existing Tabs customers will break, so worth calling it out.

It would be great if you can split this PR into "lvalidate the controller" and "migrate Tabs".

@coveralls

coveralls commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 28966159336

Coverage increased (+0.003%) to 96.257%

Details

  • Coverage increased (+0.003%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 39169
Covered Lines: 37903
Line Coverage: 96.77%
Relevant Branches: 6460
Covered Branches: 6018
Branch Coverage: 93.16%
Branches in Coverage %: Yes
Coverage Strength: 461.47 hits per line

💛 - Coveralls

return true;
}

if (this.options.confirmSelectionChange) {

@miwha-adobe miwha-adobe Jul 7, 2026

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.

Question: in a change handler, should tabs.selected be the newly selected tab or the old one?

Right now if you listen for change and read tabs.selected (or the clicked tab's selected), you get the previous tab, not the one just selected.

This is because change is dispatched from confirmSelectionChange, which runs before the selection is committed. 1st-gen updated selected first, so handlers saw the new value.

@miwha-adobe miwha-adobe Jul 7, 2026

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.

Supporting Test (Not in your PR, just what I ran locally off your branch):

// ──────────────────────────────────────────────────────────────
// TEST: Change event — selected is the new tab inside the handler
// ──────────────────────────────────────────────────────────────

export const ChangeHandlerSelectedTest: Story = {
  render: () => html`
    <swc-tabs selected="1" accessible-label="Change handler selected test">
      <swc-tab tab-id="1">Tab 1</swc-tab>
      <swc-tab tab-id="2">Tab 2</swc-tab>
      <swc-tab-panel tab-id="1"><p>Panel 1</p></swc-tab-panel>
      <swc-tab-panel tab-id="2"><p>Panel 2</p></swc-tab-panel>
    </swc-tabs>
  `,
  play: async ({ canvasElement, step }) => {
    const tabs = await getComponent<Tabs>(canvasElement, 'swc-tabs');

    await step(
      'selected is the new tab inside the change handler',
      async () => {
        let selectedInHandler: string | undefined;
        tabs.addEventListener(
          'change',
          () => {
            selectedInHandler = tabs.selected;
          },
          { once: true }
        );

        (canvasElement.querySelector('swc-tab[tab-id="2"]') as Tab).click();
        await tabs.updateComplete;

        expect(selectedInHandler).toBe('2'); // fails today: reads '1'
      }
    );
  },
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — confirmSelectionChange now runs after mutators and internal state are applied for the candidate transition, and reverts everything if it returns false, matching 1st-gen's apply-then-revert-on-cancel pattern. tabs.selected and the clicked tab's own .selected both read the new value inside a change listener now.

Added ChangeHandlerSelectedTest in tabs.test.ts as given (extended slightly to also assert the clicked tab's own .selected, not just tabs.selected).

*/
private focusTabAtIndex(index: number): void {
if (!this._tabs.length) {
private readonly _handleNavigationActiveChange = (event: Event): void => {

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.

I'm not sure if or how often this would happen, but in _handleNavigationActiveChange it selects whatever the focus controller reports as "active," and refresh() re-parks the roving tab stop on the first tab, which reads as an active change and gets selected as if the user navigated there.

Example: keyboard-activation="automatic", tab 2 selected, disable then re-enable. Selection jumps to tab 1 and change fires. Same thing if a tab was pre-selected on mount. Should selection follow only real focus moves, not refresh()? Manual mode is unaffected.

@miwha-adobe miwha-adobe Jul 7, 2026

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.

Supporting Test (Not in your PR, just what I ran locally off your branch)

// ──────────────────────────────────────────────────────────────
// TEST: Automatic activation — mount keeps the pre-selection
// ──────────────────────────────────────────────────────────────

export const AutomaticActivationMountTest: Story = {
  render: () => html`
    <swc-tabs
      selected="2"
      keyboard-activation="automatic"
      accessible-label="Automatic activation mount test"
    >
      <swc-tab tab-id="1">Tab 1</swc-tab>
      <swc-tab tab-id="2">Tab 2</swc-tab>
      <swc-tab tab-id="3">Tab 3</swc-tab>
      <swc-tab-panel tab-id="1"><p>Panel 1</p></swc-tab-panel>
      <swc-tab-panel tab-id="2"><p>Panel 2</p></swc-tab-panel>
      <swc-tab-panel tab-id="3"><p>Panel 3</p></swc-tab-panel>
    </swc-tabs>
  `,
  play: async ({ canvasElement, step }) => {
    const tabs = await getComponent<Tabs>(canvasElement, 'swc-tabs');

    await step('mount preserves the pre-selected tab', async () => {
      // Expected: the pre-selected tab wins over the default-to-first behavior.
      expect(tabs.selected).toBe('2'); // fails today: comes back '1'
      const tab2 = canvasElement.querySelector('swc-tab[tab-id="2"]') as Tab;
      expect(tab2.selected).toBe(true);
    });
  },
};

// ──────────────────────────────────────────────────────────────
// TEST: Automatic activation — disable toggle keeps the selection
// ──────────────────────────────────────────────────────────────

export const AutomaticActivationDisableToggleTest: Story = {
  render: () => html`
    <swc-tabs
      selected="2"
      keyboard-activation="automatic"
      accessible-label="Automatic activation disable test"
    >
      <swc-tab tab-id="1">Tab 1</swc-tab>
      <swc-tab tab-id="2">Tab 2</swc-tab>
      <swc-tab tab-id="3">Tab 3</swc-tab>
      <swc-tab-panel tab-id="1"><p>Panel 1</p></swc-tab-panel>
      <swc-tab-panel tab-id="2"><p>Panel 2</p></swc-tab-panel>
      <swc-tab-panel tab-id="3"><p>Panel 3</p></swc-tab-panel>
    </swc-tabs>
  `,
  play: async ({ canvasElement, step }) => {
    const tabs = await getComponent<Tabs>(canvasElement, 'swc-tabs');

    await step('disabling then re-enabling keeps the selection', async () => {
      // Reset in case mount already moved the selection, so this step is
      // independent and isolates the disable/enable behavior.
      tabs.selected = '2';
      await tabs.updateComplete;

      let changeCount = 0;
      const onChange = (): void => {
        changeCount += 1;
      };
      tabs.addEventListener('change', onChange);

      tabs.disabled = true;
      await tabs.updateComplete;
      tabs.disabled = false;
      await tabs.updateComplete;

      tabs.removeEventListener('change', onChange);

      // Toggling `disabled` is not a user selection.
      expect(tabs.selected).toBe('2'); // fails today: comes back '1'
      expect(changeCount).toBe(0); // fails today: a spurious change fires
    });
  },
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed this exactly — traced it and _navigation.refresh() (on mount, and on disable/re-enable) was dispatching focusgroupNavigationActiveChange indistinguishably from real keyboard navigation, so automatic mode had no way to tell them apart.

Fixed by adding a reason: 'keyboard' | 'focus' | 'programmatic' | 'refresh' field to FocusgroupNavigationActiveChangeDetail. _handleNavigationActiveChange now only acts on 'keyboard' / 'focus'. Added both AutomaticActivationMountTest and AutomaticActivationDisableToggleTest verbatim, plus controller-level coverage in focusgroup-navigation-controller.test.ts exercising all four reasons directly, including the exact disable/re-enable pattern from your example.

this._navigation.setActiveItem(selectedTab as HTMLElement);
} else if (this.selected) {
// No tab with this id — reset the public property.
this.selected = '';

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.

Question: should setting selected = '' clear the selection?

Right now setting tabs.selected = '' (or an unknown id) half-clears it: the property reads empty, but the old tab stays highlighted while its panel hides, so the tab looks selected with nothing showing.

1st-gen cleared every tab first, so '' left nothing selected.

@miwha-adobe miwha-adobe Jul 7, 2026

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.

Supporting Test (Not in your PR, just what I ran locally off your branch)

// ──────────────────────────────────────────────────────────────
// TEST: Clearing selection — selected = '' deselects the tab
// ──────────────────────────────────────────────────────────────

export const ClearSelectionTest: Story = {
  render: () => html`
    <swc-tabs selected="2" accessible-label="Clear selection test">
      <swc-tab tab-id="1">Tab 1</swc-tab>
      <swc-tab tab-id="2">Tab 2</swc-tab>
      <swc-tab-panel tab-id="1"><p>Panel 1</p></swc-tab-panel>
      <swc-tab-panel tab-id="2"><p>Panel 2</p></swc-tab-panel>
    </swc-tabs>
  `,
  play: async ({ canvasElement, step }) => {
    const tabs = await getComponent<Tabs>(canvasElement, 'swc-tabs');

    await step('setting selected to "" deselects the tab', async () => {
      const tab2 = canvasElement.querySelector('swc-tab[tab-id="2"]') as Tab;
      expect(tab2.selected, 'tab 2 starts selected').toBe(true);

      tabs.selected = '';
      await tabs.updateComplete;

      // Clearing the property should leave no tab looking selected.
      expect(tabs.selected).toBe('');
      expect(
        tab2.selected,
        'tab 2 should be deselected when the selection clears'
      ).toBe(false); // fails today: stays true
    });
  },
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Root cause: else if (this.selected) was checking the truthiness of the value that had just been reset — so selected = '' could never enter that branch. Fixed _syncSelectionController to always clear the selection (_selection.clearAll({ silent: true })) whenever no tab matches this.selected, whether it's empty or an unknown id.

That required teaching SelectionController.clearAll() / setSelectedItem(null, ...) to accept { silent: true } as a way to bypass the single-mode "can't be emptied by clicking" restriction, since Tabs' selection controller uses mode: 'single'.

Added ClearSelectionTest verbatim, and strengthened the existing "selecting nonexistent value clears selection" test to also assert the previously-selected tab deselects — that missing assertion is exactly what let this ship originally.

@nikkimk

nikkimk commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@nikkimk, Nice work here. These are some of my high level observations.

  1. The API already encodes Tabs-specific assumptions as general rules like defaultToFirstSelectable's fallback assumes "disabled" means getItems() returns [] but this may not hold for Accordion/Picker.
    Can you please validate against a second consumer (Accordion maybe?) before treating the API as stable.
  2. SelectionController and FocusgroupNavigationController must be manually sequenced by the consumer, with the ordering invariant living only in a code comment in Tabs.base.ts. How can we mitigate this for the consumer? Maybe add a cmbined layer?
  3. Tabs works around the lack of a "commit selection without dispatching the public event" mode by toggling an unguarded _suppressSelectionChangeEvent flag. This is where a bug is waiting to happen (stuck true on exception) that every future consumer will reinvent.
    We need to add a first-class syncSelectedItem(item, { silent: true })-style API to the controller.
  4. We need to put this as a breaking change - change event order is changed. confirmSelectionChange now dispatches Tabs' change event before state (selected, tab.selected) is updated .
    Existing Tabs customers will break, so worth calling it out.

It would be great if you can split this PR into "lvalidate the controller" and "migrate Tabs".

Thanks for the thorough review @Rajdeepc — went through all four:

  1. Fixed rather than just flagged — removed the "empty getItems() means disabled" assumption from defaultToFirstSelectable entirely. Also validated against Accordion as the second consumer you suggested. That validation found a real limit worth knowing about: SelectionController assumes it's the sole owner of selection state, which isn't true for Accordion (open is independently settable per item). Landed a narrower "bookkeeping-only" integration for it (enableInteraction: false) rather than forcing full ownership — details in the controller's docs under "Bookkeeping-only usage."
  2. Fixed the actual cache-staleness bug in FocusgroupNavigationController rather than adding a combined layer, ass not all components using selection controller will also use focus group navigation and vice versa — setActiveItem() no longer depends on refresh() running first, so the sequencing in Tabs.base.ts is now a nice-to-have, not a load-bearing requirement.
  3. Added exactly the { silent: true } API you described, on all five mutating methods. _suppressSelectionChangeEvent is gone.
  4. This turned out to be fixable, not just disclosable — confirmSelectionChange now runs after state is applied and reverts on false, matching 1st-gen's behavior. tabs.selected reads correctly inside a change listener again, so I pulled the breaking-change note back out.

On splitting into two PRs — I didn't do that, and at this point doing it after the fact (rather than before mixing the changes) would add more overhead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

High priority PR review PR is a high priority and should be reviewed ASAP Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants