Skip to content

feat(core): added LiveSelectionController#6486

Merged
nikkimk merged 18 commits into
mainfrom
nikkimk/self-owning-selection
Jul 14, 2026
Merged

feat(core): added LiveSelectionController#6486
nikkimk merged 18 commits into
mainfrom
nikkimk/self-owning-selection

Conversation

@nikkimk

@nikkimk nikkimk commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Description

  • Proof-of-concept for a controller to handle single or multi-selection for components that own their own selection.
  • Documentation with demos and code snippets for implementing the controller.
  • Refactor of 2nd-gen swc-accordion to leverage live selection controller.

Motivation and context

This controller could handle selection for accordions, menus, and pickers.

Related issue(s)

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

  • Live selection controller

    1. Go to Live selection controller
    2. Verify that the examples work.
    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.
  • Accordion

    1. Go to Accordion 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 Jul 8, 2026
@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5ca3b69

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

@nikkimk nikkimk changed the title Nikkimk/self owning selection feat(core): selection controller Jul 8, 2026
@nikkimk nikkimk changed the title feat(core): selection controller feat(core): added LiveSelectionController controller Jul 8, 2026
@nikkimk nikkimk changed the title feat(core): added LiveSelectionController controller feat(core): added LiveSelectionController Jul 8, 2026
@nikkimk nikkimk mentioned this pull request Jul 8, 2026
16 tasks
@coveralls

coveralls commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29357924290

Coverage remained the same at 96.257%

Details

  • Coverage remained the same as 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.46 hits per line

💛 - Coveralls

@github-actions

github-actions Bot commented Jul 8, 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-6486

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 added Status:Ready for review PR ready for review or re-review. High priority PR review PR is a high priority and should be reviewed ASAP labels Jul 10, 2026
@nikkimk
nikkimk marked this pull request as ready for review July 10, 2026 15:08
@nikkimk
nikkimk requested a review from a team as a code owner July 10, 2026 15:08

@miwha-adobe miwha-adobe 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.

Nice work! A few questions

changedProperties.get('allowMultiple') === true &&
!this.allowMultiple
) {
this.selectionController.refresh();

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.

Quick question on intent: is initial-state enforcement meant to be out of scope here? Since the controller is event-driven and only refresh()es on the transitions in this file (same as the reference pattern in the docs), a single-mode accordion authored with two open items renders with both open, and nothing reconciles it until the first user toggle. Same goes for a direct .open = true, since no toggle event fires. I confirmed it on the branch (openCount=2 of 2).

Totally fine if that's intended! Just wondering whether it's worth a refresh() on first render / slotchange, or if a valid initial state is the author's responsibility (either way, might be nice to note in the docs). Test below in case it's useful.

export const InitialSingleSelectEnforcedTest: Story = {
  render: () => html`
    <swc-accordion accessible-label="Initial single-select">
      <swc-accordion-item open><span slot="label">One</span><p>1</p></swc-accordion-item>
      <swc-accordion-item open><span slot="label">Two</span><p>2</p></swc-accordion-item>
    </swc-accordion>
  `,
  play: async ({ canvasElement }) => {
    const accordion = await getComponent<Accordion>(canvasElement, 'swc-accordion');
    await accordion.updateComplete;
    const items = Array.from(
      canvasElement.querySelectorAll('swc-accordion-item')
    ) as AccordionItem[];
    await Promise.all(items.map((i) => i.updateComplete));
    expect(items.filter((i) => i.open).length).toBe(1); // fails today: 2
  },
};

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.

Good catch. The controller is event-driven, so it never saw those initial open attributes. Fixed by adding a firstUpdated() override in AccordionBase that calls selectionController.refresh() after the first render, at which point the slot is populated and assignedItems()returns all items. Added the test you suggested; it now passes. I also updated the documentation accordingly.

if (!this.options.readSelected(source as T)) {
return;
}
for (const item of this.options.getItems()) {

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.

You might want to double-check this section for nested accordions. It looks like an inner item's toggle (bubbles/composed) also reaches the outer controller, and since the inner item isn't in the outer's getItems(), it closes the outer's open panels, including the one wrapping the nested accordion. So opening a nested panel collapses its outer panel (outerItem1.open flips true to false on the branch). Doesn't look tested yet. Was nesting in scope?

export const NestedAccordionIsolationTest: Story = {
  render: () => html`
    <swc-accordion accessible-label="Outer">
      <swc-accordion-item open>
        <span slot="label">Outer 1</span>
        <swc-accordion accessible-label="Inner">
          <swc-accordion-item><span slot="label">Inner 1</span><p>inner</p></swc-accordion-item>
        </swc-accordion>
      </swc-accordion-item>
      <swc-accordion-item><span slot="label">Outer 2</span><p>o2</p></swc-accordion-item>
    </swc-accordion>
  `,
  play: async ({ canvasElement, step }) => {
    const outer = canvasElement.querySelector('swc-accordion') as Accordion;
    await outer.updateComplete;
    const outerItem1 = canvasElement.querySelector('swc-accordion-item') as AccordionItem;
    const innerItem1 = outerItem1.querySelector('swc-accordion-item') as AccordionItem;
    await Promise.all([outerItem1.updateComplete, innerItem1.updateComplete]);
    await step('opening a nested item does not collapse the outer item', async () => {
      (innerItem1.shadowRoot?.querySelector('#header') as HTMLElement).click();
      await new Promise((r) => setTimeout(r, 0));
      await outer.updateComplete;
      expect(innerItem1.open).toBe(true);
      expect(outerItem1.open).toBe(true); // fails today: false
    });
  },
};

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.

Thanks for catching this. Added an items.includes(source) check. The source element is retrieved via composedPath()[0]; if it isn't one of the outer accordion's own items, the controller exits early. Inner accordion items bubble their swc-accordion-item-toggle event all the way to the outer controller, but are not in the outer group's getItems() list, so the outer items are unaffected. Added your test to verifies this.

@Rajdeepc Rajdeepc 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.

Nice work! I like the shape of these controllers. Two small things below neither blocking.

if (this.currentMode !== 'single') {
return;
}
const source = event.target;

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.

This works fine for Accordion since items are direct light-DOM children of the host, but event.target can get retargeted at shadow boundaries. If we ever point this at menu/picker where items might sit behind an extra shadow root the could resolve a wrong element.
event.composedPath()[0] would be preferable similar what you have done in SelectionController

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.

Resolved as you suggested.

}

private readonly handleEvent = (event: Event): void => {
if (this.currentMode !== 'single') {

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.

Though likely rare but if you can validate that would be great.
You are checkingcurrentMode once synchronously here, but you are not re-checking inside the deferred microtask. If mode (as a getter) flips from single to multiple in the same tick between the event firing and the microtask running, the deferred close still executes under single-mode assumptions.

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. The synchronous check before queueMicrotask stays as a fast exit (avoids queuing a microtask at all in multiple mode). Added a second currentMode check at the top of the callback to handle the race condition where allowMultiple might be set between the event and the microtask execution.

@nikkimk
nikkimk requested review from Rajdeepc and miwha-adobe July 13, 2026 14:59
@nikkimk nikkimk added the Status:Ready for re-review PR has had its feedback addressed and is once again ready for review. label Jul 13, 2026

@miwha-adobe miwha-adobe 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.

Looking good! ✨

@Rajdeepc Rajdeepc 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.

Nice work! Thanks

@nikkimk
nikkimk enabled auto-merge (squash) July 14, 2026 15:05
@nikkimk nikkimk added the run_vrt Triggers the Chromatic VRT run for 2nd-gen label Jul 14, 2026
@nikkimk
nikkimk merged commit bf02e76 into main Jul 14, 2026
38 checks passed
@nikkimk
nikkimk deleted the nikkimk/self-owning-selection branch July 14, 2026 19:06
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 run_vrt Triggers the Chromatic VRT run for 2nd-gen Status:Ready for re-review PR has had its feedback addressed and is once again ready for review. Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants