-
Notifications
You must be signed in to change notification settings - Fork 249
feat: set up accordion file structure, API, and a11y #6300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rise-erpelding
merged 28 commits into
swc-1854/accordion-migration
from
swc-1857-1858-1859
May 21, 2026
Merged
Changes from 25 commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
bede72f
chore: scaffold core package
rise-erpelding 79b82b3
chore: scaffold swc package
rise-erpelding 8c69ece
chore: add Chevron300Icon for xl accordion
rise-erpelding cb55ae1
chore: set up storybook story
rise-erpelding 8c61687
chore: commit preview.ts updates
rise-erpelding 629c233
feat: implement AccordionItem API and render template
rise-erpelding 176c95a
feat: implement Accordion API and propagation
rise-erpelding 5896710
feat: implement open and toggle logic
rise-erpelding 7318895
feat: wire up dynamic heading levels
rise-erpelding 1221bee
feat: implement disabled item functionality
rise-erpelding d4d945d
fix: handle space keyboard behavior (SWC-1487)
rise-erpelding b26f30f
refactor: address missing migration-setup items
rise-erpelding 4ee7d20
refactor: rename methods, align file names
rise-erpelding 885c98a
refactor: typing, heading rendering, sizing
rise-erpelding e2cb7d3
refactor: move toggle() into base, smooth a11y behavior
rise-erpelding 365577a
refactor: use assignedItems() helper
rise-erpelding 12d5760
test: add a11y tests
rise-erpelding e10e351
docs: add a11y docs
rise-erpelding b16028b
fix: freeze accordion open state when disabled
rise-erpelding b102a88
feat: 1st-gen deprecation warnings/tests
rise-erpelding b9b7d88
docs: jsdoc comments
rise-erpelding cf3c2a5
chore: changeset
rise-erpelding c940abb
fix: revert 1st-gen refactor work
rise-erpelding e63c363
chore(accordion): defer tests, deprecations, and changeset to part two
rise-erpelding e8dc485
fix: class selectors
rise-erpelding 4cc8384
refactor: use ObserveSlotPresence instead of bespoke method
rise-erpelding b6141bd
refactor: remove stop propagation on actions container
rise-erpelding 354f1d4
refactor: add open/close events
rise-erpelding File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
208 changes: 208 additions & 0 deletions
208
2nd-gen/packages/core/components/accordion/Accordion.base.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| /** | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| import { PropertyValues } from 'lit'; | ||
| import { property } from 'lit/decorators.js'; | ||
|
|
||
| import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; | ||
| import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; | ||
|
|
||
| import { | ||
| ACCORDION_VALID_SIZES, | ||
| type AccordionDensity, | ||
| type AccordionHeadingLevel, | ||
| type AccordionSize, | ||
| SWC_ACCORDION_ITEM_TOGGLE_EVENT, | ||
| } from './Accordion.types.js'; | ||
| import { AccordionItemBase } from './AccordionItem.base.js'; | ||
|
|
||
| /** | ||
| * Base class for accordion components. Manages item propagation, heading | ||
| * level, density, and the exclusive-open constraint. | ||
| * | ||
| * @attribute {boolean} allowMultiple - Reflected as `allow-multiple`. When set, | ||
| * multiple items may be open at the same time. | ||
| * @attribute {number} level - Heading level (2–6) applied to every item header. | ||
| * Values outside that range are clamped. | ||
| * @attribute {AccordionSize} size - Size applied to all items. | ||
| * @attribute {AccordionDensity} density - Vertical spacing between items. | ||
| * @attribute {boolean} quiet - Renders the accordion in its quiet visual variant. | ||
| * @attribute {boolean} disabled - Disables all items in the accordion. | ||
| * | ||
| * @slot - One or more `swc-accordion-item` elements. | ||
| */ | ||
| export abstract class AccordionBase extends SizedMixin(SpectrumElement, { | ||
| validSizes: ACCORDION_VALID_SIZES, | ||
| defaultSize: 'm', | ||
| }) { | ||
| // ────────────────── | ||
| // PUBLIC API | ||
| // ────────────────── | ||
|
|
||
| /** | ||
| * When set, multiple items may be open at the same time. By default only | ||
| * one item can be open. | ||
| */ | ||
| @property({ type: Boolean, reflect: true, attribute: 'allow-multiple' }) | ||
| public allowMultiple: boolean = false; | ||
|
|
||
| /** | ||
| * Heading level applied to every item header (2–6). Defaults to 3. | ||
| * Values outside that range are clamped. | ||
| */ | ||
| @property({ type: Number, reflect: true }) | ||
| public level: AccordionHeadingLevel = 3; | ||
|
|
||
| /** | ||
| * Size applied to all items. Defaults to `m`. | ||
| */ | ||
| declare public size: AccordionSize; | ||
|
|
||
| /** | ||
| * Controls vertical spacing between items. | ||
| * | ||
| * @default regular | ||
| */ | ||
| @property({ type: String, reflect: true }) | ||
| public density: AccordionDensity = 'regular'; | ||
|
|
||
| /** | ||
| * Renders the accordion in its quiet (no-border) visual variant. | ||
| */ | ||
| @property({ type: Boolean, reflect: true }) | ||
| public quiet: boolean = false; | ||
|
|
||
| /** | ||
| * Disables all items in the accordion. Individual items may also be | ||
| * disabled independently. | ||
| */ | ||
| @property({ type: Boolean, reflect: true }) | ||
| public disabled: boolean = false; | ||
|
|
||
| // ────────────────────── | ||
| // IMPLEMENTATION | ||
| // ────────────────────── | ||
|
|
||
| private assignedItems(): AccordionItemBase[] { | ||
| const slot = this.renderRoot?.querySelector('slot'); | ||
| if (!slot) { | ||
| return []; | ||
| } | ||
| return slot | ||
| .assignedElements({ flatten: true }) | ||
| .filter((el): el is AccordionItemBase => el instanceof AccordionItemBase); | ||
| } | ||
|
|
||
| private closeSiblingsOnOpen = (event: Event): void => { | ||
| if (this.disabled) { | ||
| event.preventDefault(); | ||
| return; | ||
| } | ||
| if (this.allowMultiple) { | ||
| return; | ||
| } | ||
| const toggling = event.target; | ||
| if (!(toggling instanceof AccordionItemBase)) { | ||
| return; | ||
| } | ||
| // Defer until after dispatch returns so that a canceled toggle (where the | ||
| // item reverts open back to false) does not incorrectly close siblings. | ||
| queueMicrotask(() => { | ||
| if (!toggling.open) { | ||
| return; | ||
| } | ||
| for (const item of this.assignedItems()) { | ||
| if (item !== toggling) { | ||
| item.open = false; | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| protected syncAccordionItems(): void { | ||
| for (const item of this.assignedItems()) { | ||
| item.setManagedHeading(this.level); | ||
| item.size = this.size; | ||
| item.setManagedParentDisabled(this.disabled); | ||
| } | ||
| } | ||
|
|
||
| private enforceExclusiveOpen(): void { | ||
| let foundOpen = false; | ||
| for (const item of this.assignedItems()) { | ||
| if (item.open) { | ||
| if (foundOpen) { | ||
| item.open = false; | ||
| } else { | ||
| foundOpen = true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public override connectedCallback(): void { | ||
| super.connectedCallback(); | ||
| this.addEventListener( | ||
| SWC_ACCORDION_ITEM_TOGGLE_EVENT, | ||
| this.closeSiblingsOnOpen | ||
| ); | ||
| } | ||
|
|
||
| public override disconnectedCallback(): void { | ||
| super.disconnectedCallback(); | ||
| this.removeEventListener( | ||
| SWC_ACCORDION_ITEM_TOGGLE_EVENT, | ||
| this.closeSiblingsOnOpen | ||
| ); | ||
| } | ||
|
|
||
| protected override update(changedProperties: PropertyValues): void { | ||
| if (changedProperties.has('level')) { | ||
| const clamped = Math.min( | ||
| 6, | ||
| Math.max(2, this.level) | ||
| ) as AccordionHeadingLevel; | ||
| if (this.level !== clamped) { | ||
| this.level = clamped; | ||
| } | ||
| } | ||
| if ( | ||
| changedProperties.has('level') || | ||
| changedProperties.has('size') || | ||
| changedProperties.has('disabled') | ||
| ) { | ||
| this.syncAccordionItems(); | ||
| } | ||
| // changedProperties.get() returns the previous value; this fires only when | ||
| // disabled transitions from true → false (re-enable). | ||
| if ( | ||
| changedProperties.has('disabled') && | ||
| changedProperties.get('disabled') === true && | ||
| !this.allowMultiple | ||
| ) { | ||
| this.enforceExclusiveOpen(); | ||
| } | ||
| super.update(changedProperties); | ||
| } | ||
|
|
||
| protected override firstUpdated(changedProperties: PropertyValues): void { | ||
| super.firstUpdated(changedProperties); | ||
| if (window.__swc?.DEBUG && !this.hasAttribute('density')) { | ||
| window.__swc.warn( | ||
| this, | ||
| `<${this.localName}> should have an explicit "density" attribute set. Defaulting to "regular".`, | ||
| 'https://opensource.adobe.com/spectrum-web-components/components/accordion/', | ||
| { type: 'api', level: 'low' } | ||
| ); | ||
| } | ||
| } | ||
| } |
29 changes: 29 additions & 0 deletions
29
2nd-gen/packages/core/components/accordion/Accordion.types.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| /** | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| import type { ElementSize } from '@spectrum-web-components/core/mixins/index.js'; | ||
|
|
||
| export const ACCORDION_VALID_SIZES = [ | ||
| 's', | ||
| 'm', | ||
| 'l', | ||
| 'xl', | ||
| ] as const satisfies readonly ElementSize[]; | ||
| export type AccordionSize = (typeof ACCORDION_VALID_SIZES)[number]; | ||
|
|
||
| export const ACCORDION_DENSITIES = ['compact', 'regular', 'spacious'] as const; | ||
| export type AccordionDensity = (typeof ACCORDION_DENSITIES)[number]; | ||
|
|
||
| export const ACCORDION_HEADING_LEVELS = [2, 3, 4, 5, 6] as const; | ||
| export type AccordionHeadingLevel = (typeof ACCORDION_HEADING_LEVELS)[number]; | ||
|
|
||
| export const SWC_ACCORDION_ITEM_TOGGLE_EVENT = 'swc-accordion-item-toggle'; |
153 changes: 153 additions & 0 deletions
153
2nd-gen/packages/core/components/accordion/AccordionItem.base.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| /** | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| import { property, state } from 'lit/decorators.js'; | ||
|
|
||
| import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; | ||
|
|
||
| import { | ||
| type AccordionHeadingLevel, | ||
| type AccordionSize, | ||
| SWC_ACCORDION_ITEM_TOGGLE_EVENT, | ||
| } from './Accordion.types.js'; | ||
|
|
||
| /** | ||
| * Base class for accordion item components. Manages open/disabled state, | ||
| * heading level (set by the parent accordion), and the toggle event. | ||
| * | ||
| * @attribute {boolean} open - Whether the accordion item panel is expanded. | ||
| * @attribute {boolean} disabled - Whether the accordion item is disabled. | ||
| * @attribute {AccordionSize} size - Size of the item. Inherited from the parent | ||
| * accordion when slotted; controls the chevron icon when used standalone. | ||
| * | ||
| * @slot label - The heading text for this accordion item. | ||
| * @slot actions - Optional actions rendered adjacent to the heading, outside | ||
| * the toggle button so they remain independently interactive. | ||
| * @slot - The panel content revealed when the item is open. | ||
| */ | ||
| export abstract class AccordionItemBase extends SpectrumElement { | ||
| // ────────────────── | ||
| // PUBLIC API | ||
| // ────────────────── | ||
|
|
||
| /** | ||
| * Whether the accordion item panel is expanded. | ||
| */ | ||
| @property({ type: Boolean, reflect: true }) | ||
| public get open(): boolean { | ||
| return this._open; | ||
| } | ||
|
|
||
| public set open(value: boolean) { | ||
| if (this.hasUpdated && !this.mayExpand() && value !== this._open) { | ||
| return; | ||
| } | ||
| if (value === this._open) { | ||
| return; | ||
| } | ||
| const oldValue = this._open; | ||
| this._open = value; | ||
| if (value) { | ||
| this.setAttribute('open', ''); | ||
| } else { | ||
| this.removeAttribute('open'); | ||
| } | ||
| this.requestUpdate('open', oldValue); | ||
| } | ||
|
|
||
| /** | ||
| * Whether the accordion item is disabled. A disabled item keeps its header | ||
| * in the tab order but blocks toggling. | ||
| */ | ||
| @property({ type: Boolean, reflect: true }) | ||
| public disabled: boolean = false; | ||
|
|
||
| /** | ||
| * The size of the item. Inherited from the parent accordion; controls which | ||
| * chevron icon is displayed. Has no effect when the item is used standalone. | ||
| */ | ||
| @property({ type: String, reflect: true }) | ||
| public size?: AccordionSize; | ||
|
|
||
| private _open = false; | ||
|
|
||
| // ────────────────────── | ||
| // INTERNAL STATE | ||
| // ────────────────────── | ||
|
|
||
| /** | ||
| * @internal | ||
| * Heading level (2–6) propagated by the parent accordion. Defaults to 3 | ||
| * for standalone items. | ||
| */ | ||
| @state() | ||
| protected headingLevel: AccordionHeadingLevel = 3; | ||
|
|
||
| /** | ||
| * @internal | ||
| * Set by the parent accordion when its own `disabled` is true. Causes the | ||
| * item to render as disabled (aria-disabled + inert panel) without clobbering | ||
| * the item's own `disabled` property, so the per-item state is preserved | ||
| * when the accordion is re-enabled. | ||
| */ | ||
| @state() | ||
| protected parentDisabled: boolean = false; | ||
|
|
||
| // ────────────────────── | ||
| // IMPLEMENTATION | ||
| // ────────────────────── | ||
|
|
||
| /** | ||
| * @internal | ||
| * Whether the item may change `open` (expand or collapse). When false, the | ||
| * `open` setter and `toggle()` leave state unchanged. | ||
| */ | ||
| protected mayExpand(): boolean { | ||
| return !this.disabled && !this.parentDisabled; | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| * Toggles the item open state. Guards for disabled, flips `open`, dispatches | ||
| * the toggle event, and reverts if the event is canceled. | ||
| */ | ||
| protected toggle(): void { | ||
| if (!this.mayExpand()) { | ||
| return; | ||
| } | ||
| this.open = !this.open; | ||
| const event = new Event(SWC_ACCORDION_ITEM_TOGGLE_EVENT, { | ||
| bubbles: true, | ||
| composed: true, | ||
| cancelable: true, | ||
| }); | ||
| if (!this.dispatchEvent(event)) { | ||
| this.open = !this.open; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| * Synchronizes parent-managed heading level onto the item. | ||
| */ | ||
| public setManagedHeading(heading: AccordionHeadingLevel): void { | ||
| this.headingLevel = heading; | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| * Synchronizes parent-managed disabled state onto the item. | ||
| */ | ||
| public setManagedParentDisabled(disabled: boolean): void { | ||
| this.parentDisabled = disabled; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to emit dedicated
openvscloseevents or at least use aCustomEventso we can pass the actual open or close state todetail?I think the system pattern is dedicated events, perhaps even
swc-open,swc-after-open,swc-close,swc-after-closebut I'm not entirely sure those are all necessary here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess the question is, do we need something like
swc-open,swc-after-open,swc-close,swc-after-close? Do we need to differentiate between open and close? 🤔It seems like we used those open/close events in overlay components in 1st-gen, but not in accordion. Are they needed in overlay because of the open/close animations and the time they take or because of something else, like how they can be triggered by hover/focus/other things rather than a click? And if we added some animation to the accordion panel would that change anything?
If we do need open/close events in 2nd-gen, then it seems like a
CustomEventwould be a better way to go. If not, usingEventand not passing anydetailsimplifies the implementation compared to its 1st-gen version.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels like if we don't at least add
detailto say the new/current state post-toggle, that we are putting the burden of "which is it?" on the consumer.Sometimes folks try to force things like fetching data into an accordion which they'd only want to do on open, for example.
React's version does include some animation, and we would probably want to wait for any transition time to do the
after-openjust because a user might "cancel" the opening before that completes, soafter-openbecomes a bit more definitive signal.You could pattern it after what I have in Tooltip, which also guards against duplicate events when there are more than one transitioning property. (And now makes me think since those are pretty generic maybe we should be sharing among any "visibility toggling" type of components)