From e5488b670756a29d6aa5001723c4565c160e928b Mon Sep 17 00:00:00 2001 From: Jonathan Sabutis Date: Mon, 12 Oct 2026 14:02:55 -0700 Subject: [PATCH] fix(accordion): mark an expanded item that cannot collapse as aria-disabled In a single accordion that is not collapsible, activating the trigger of the expanded item does nothing. The trigger still announced itself as an active, expanded button, so screen reader users had no signal that it could not be collapsed. The WAI-ARIA accordion pattern asks for aria-disabled="true" on that trigger. Set aria-disabled on the trigger when the item is expanded and the accordion is neither collapsible nor multiple. Disabled items keep the native disabled attribute only. The existing test that clicks the open trigger again now uses a forced click, since Playwright treats aria-disabled elements as not clickable. Fixes #3493 Co-Authored-By: Claude Opus 5.5 --- .changeset/accordion-locked-aria-disabled.md | 5 +++++ e2e/accordion.e2e.ts | 21 ++++++++++++++++++- e2e/models/accordion.model.ts | 12 +++++++++-- .../accordion/src/accordion.connect.ts | 5 ++++- 4 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 .changeset/accordion-locked-aria-disabled.md diff --git a/.changeset/accordion-locked-aria-disabled.md b/.changeset/accordion-locked-aria-disabled.md new file mode 100644 index 0000000000..0e9ee065d4 --- /dev/null +++ b/.changeset/accordion-locked-aria-disabled.md @@ -0,0 +1,5 @@ +--- +"@zag-js/accordion": patch +--- + +- Fixed issue where an expanded item that cannot be collapsed was not marked `aria-disabled`. diff --git a/e2e/accordion.e2e.ts b/e2e/accordion.e2e.ts index a848d4c3df..acfe71eaa8 100644 --- a/e2e/accordion.e2e.ts +++ b/e2e/accordion.e2e.ts @@ -48,7 +48,8 @@ test.describe("accordion", () => { test("then clicking the same trigger again: should not close the content", async () => { await I.clickTrigger("home") - await I.clickTrigger("home") + // the expanded trigger has aria-disabled, which playwright treats as not clickable + await I.clickTrigger("home", { force: true }) await I.seeContent("home") }) @@ -59,6 +60,24 @@ test.describe("accordion", () => { await I.seeContent("about") await I.dontSeeContent("home") }) + + test("expanded trigger that cannot collapse has aria-disabled", async () => { + await I.clickTrigger("home") + await I.seeTriggerIsLocked("home") + await I.dontSeeTriggerIsLocked("about") + + await I.clickTrigger("about") + await I.seeTriggerIsLocked("about") + await I.dontSeeTriggerIsLocked("home") + }) + + test("[collapsible=true] expanded trigger has no aria-disabled", async () => { + await I.controls.bool("collapsible") + + await I.clickTrigger("home") + await I.seeContent("home") + await I.dontSeeTriggerIsLocked("home") + }) }) test.describe("multiple / keyboard", () => { diff --git a/e2e/models/accordion.model.ts b/e2e/models/accordion.model.ts index 8b3b7989b2..52a0def6ef 100644 --- a/e2e/models/accordion.model.ts +++ b/e2e/models/accordion.model.ts @@ -27,8 +27,8 @@ export class AccordionModel extends Model { await this.getTrigger(id).focus() } - async clickTrigger(id: string) { - await this.getTrigger(id).click() + async clickTrigger(id: string, options?: { force?: boolean }) { + await this.getTrigger(id).click(options) } async seeTriggerIsFocused(id: string) { @@ -42,4 +42,12 @@ export class AccordionModel extends Model { async dontSeeContent(id: string) { await expect(this.getContent(id)).not.toBeVisible() } + + async seeTriggerIsLocked(id: string) { + await expect(this.getTrigger(id)).toHaveAttribute("aria-disabled", "true") + } + + async dontSeeTriggerIsLocked(id: string) { + await expect(this.getTrigger(id)).not.toHaveAttribute("aria-disabled") + } } diff --git a/packages/machines/accordion/src/accordion.connect.ts b/packages/machines/accordion/src/accordion.connect.ts index 2337069091..b09eb3130d 100644 --- a/packages/machines/accordion/src/accordion.connect.ts +++ b/packages/machines/accordion/src/accordion.connect.ts @@ -1,4 +1,4 @@ -import { dataAttr, getEventKey, isSafari } from "@zag-js/dom-query" +import { ariaAttr, dataAttr, getEventKey, isSafari } from "@zag-js/dom-query" import type { EventKeyMap, NormalizeProps, PropTypes } from "@zag-js/types" import type { Service } from "@zag-js/core" import { parts } from "./accordion.anatomy" @@ -91,6 +91,8 @@ export function connect( getItemTriggerProps(props) { const { value } = props const itemState = getItemState(props) + // an expanded item that cannot be collapsed is announced as disabled + const locked = itemState.expanded && !itemState.disabled && !prop("collapsible") && !multiple return normalize.button({ ...parts.itemTrigger.attrs, @@ -101,6 +103,7 @@ export function connect( "data-controls": dom.getItemContentId(scope, value), "aria-expanded": itemState.expanded, disabled: itemState.disabled, + "aria-disabled": ariaAttr(locked), "data-orientation": prop("orientation"), "data-state": itemState.expanded ? "open" : "closed", "data-focus": dataAttr(itemState.focused),