@astryxdesign/core 0.7.3-canary.2468b8a → 0.7.3-canary.3133fb0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -939,8 +939,9 @@ function DayCell({
939
939
  // Mark today's cell programmatically (APG date-picker pattern), not just
940
940
  // visually, so screen-reader users can identify the current date.
941
941
  ,
942
- "aria-current": state.isToday ? 'date' : undefined,
943
- disabled: isDisabled
942
+ "aria-current": state.isToday ? 'date' : undefined
943
+ // A disabled day stays focusable through aria-disabled (spec:AST-085
944
+ // FR1); arrows still skip it and its click and hover are guarded.
944
945
  // Initial roving tab-stop seed; useGridFocus owns it after mount.
945
946
  ,
946
947
  tabIndex: isTabbableDay ? 0 : -1,
@@ -1 +1 @@
1
- {"version":3,"file":"Pagination.d.ts","sourceRoot":"","sources":["../../src/Pagination/Pagination.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAI5C,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,SAAS,CAAC;AAMlD;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,MAAM,oBAAoB,CAAC;AAE3D,uCAAuC;AACvC,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,CAAC;AAEzC,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAC3C,SAAS,CAAC,WAAW,CAAC,EACtB,UAAU,CACX;IACC,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAE7B,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,oCAAoC;IACpC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAGtD;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAGlB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,yCAAyC;IACzC,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAG9C;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,iBAAiB,CAAC;IAC5B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,IAAI,CAAC,EAAE,cAAc,CAAC;IAGtB,wDAAwD;IACxD,UAAU,CAAC,EAAE,OAAO,CAAC;IAGrB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,qCAAqC;IACrC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAwHD;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,GACnB,CAAC,MAAM,GAAG,KAAK,CAAC,EAAE,CA6CpB;AAMD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,QAAQ,EACR,YAAY,EACZ,UAAU,EACV,UAAU,EAAE,cAAc,EAC1B,OAAO,EACP,QAAQ,EAAE,YAAiB,EAC3B,eAAe,EACf,gBAAgB,EAChB,OAAiB,EACjB,SAAS,EACT,YAAY,EAAE,gBAAgB,EAC9B,IAAI,EAAE,QAAY,EAClB,YAAgB,EAChB,IAAW,EACX,UAAkB,EAClB,KAAK,EAAE,cAAc,EACrB,aAAa,EAAE,MAAM,EACrB,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,IAAI,EACR,EAAE,eAAe,sCAwfjB;yBAhhBe,UAAU"}
1
+ {"version":3,"file":"Pagination.d.ts","sourceRoot":"","sources":["../../src/Pagination/Pagination.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAI5C,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,SAAS,CAAC;AAMlD;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,MAAM,oBAAoB,CAAC;AAE3D,uCAAuC;AACvC,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,CAAC;AAEzC,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAC3C,SAAS,CAAC,WAAW,CAAC,EACtB,UAAU,CACX;IACC,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAE7B,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,oCAAoC;IACpC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAGtD;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAGlB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,yCAAyC;IACzC,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAG9C;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,iBAAiB,CAAC;IAC5B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,IAAI,CAAC,EAAE,cAAc,CAAC;IAGtB,wDAAwD;IACxD,UAAU,CAAC,EAAE,OAAO,CAAC;IAGrB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,qCAAqC;IACrC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAwHD;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,GACnB,CAAC,MAAM,GAAG,KAAK,CAAC,EAAE,CA6CpB;AAMD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,QAAQ,EACR,YAAY,EACZ,UAAU,EACV,UAAU,EAAE,cAAc,EAC1B,OAAO,EACP,QAAQ,EAAE,YAAiB,EAC3B,eAAe,EACf,gBAAgB,EAChB,OAAiB,EACjB,SAAS,EACT,YAAY,EAAE,gBAAgB,EAC9B,IAAI,EAAE,QAAY,EAClB,YAAgB,EAChB,IAAW,EACX,UAAkB,EAClB,KAAK,EAAE,cAAc,EACrB,aAAa,EAAE,MAAM,EACrB,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,IAAI,EACR,EAAE,eAAe,sCA0fjB;yBAlhBe,UAAU"}
@@ -496,8 +496,11 @@ export function Pagination({
496
496
  // needs to focus the dot, which some browsers (Safari) skip for
497
497
  // buttons, so focus it explicitly.
498
498
  ,
499
- onClick: e => e.currentTarget.focus(),
500
- disabled: isDisabled,
499
+ onClick: e => e.currentTarget.focus()
500
+ // Disabled dots stay focusable (spec:AST-085 FR1); the page
501
+ // only follows focus while enabled (handleDotsFocus).
502
+ ,
503
+ "aria-disabled": isDisabled || undefined,
501
504
  ...mergeProps(themeProps('pagination-dot', {
502
505
  active: isActive ? 'active' : null,
503
506
  size
@@ -1 +1 @@
1
- {"version":3,"file":"TreeListItem.d.ts","sourceRoot":"","sources":["../../src/TreeList/TreeListItem.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAC7D,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAoB3C,OAAO,KAAK,EAAC,eAAe,EAAE,eAAe,EAAC,MAAM,iBAAiB,CAAC;AA6RtE,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC/B,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC7B,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC,YAAY,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,WAAW,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,eAAe,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,OAAO,EAAE,eAAe,CAAC;IACzB;;;;OAIG;IACH,OAAO,EAAE,eAAe,CAAC;IACzB,uEAAuE;IACvE,gBAAgB,CAAC,EAAE,SAAS,CAAC;IAC7B,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,UAAU,EAAE,OAAO,CAAC;CACrB;AAMD,wBAAgB,YAAY,CAAC,EAC3B,EAAE,EACF,KAAK,EACL,WAAW,EACX,YAAY,EACZ,UAAU,EACV,MAAM,EACN,SAAS,EACT,KAAK,EACL,OAAO,EACP,IAAI,EACJ,MAAM,EACN,UAAkB,EAClB,UAAkB,EAClB,WAAW,EACX,kBAAkB,EAClB,WAAW,EACX,MAAM,EACN,eAAe,EACf,UAAU,EACV,QAAQ,EACR,OAAO,EACP,OAAO,EACP,gBAAgB,EAChB,QAAQ,EACR,OAAO,EACP,UAAU,GACX,EAAE,yBAAyB,+BA+S3B;yBA1Ue,YAAY"}
1
+ {"version":3,"file":"TreeListItem.d.ts","sourceRoot":"","sources":["../../src/TreeList/TreeListItem.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAC7D,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAoB3C,OAAO,KAAK,EAAC,eAAe,EAAE,eAAe,EAAC,MAAM,iBAAiB,CAAC;AA8RtE,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC/B,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC7B,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC,YAAY,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,WAAW,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,eAAe,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,OAAO,EAAE,eAAe,CAAC;IACzB;;;;OAIG;IACH,OAAO,EAAE,eAAe,CAAC;IACzB,uEAAuE;IACvE,gBAAgB,CAAC,EAAE,SAAS,CAAC;IAC7B,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,UAAU,EAAE,OAAO,CAAC;CACrB;AAMD,wBAAgB,YAAY,CAAC,EAC3B,EAAE,EACF,KAAK,EACL,WAAW,EACX,YAAY,EACZ,UAAU,EACV,MAAM,EACN,SAAS,EACT,KAAK,EACL,OAAO,EACP,IAAI,EACJ,MAAM,EACN,UAAkB,EAClB,UAAkB,EAClB,WAAW,EACX,kBAAkB,EAClB,WAAW,EACX,MAAM,EACN,eAAe,EACf,UAAU,EACV,QAAQ,EACR,OAAO,EACP,OAAO,EACP,gBAAgB,EAChB,QAAQ,EACR,OAAO,EACP,UAAU,GACX,EAAE,yBAAyB,+BAkT3B;yBA7Ue,YAAY"}
@@ -27,6 +27,7 @@ import { useLinkComponent } from "../Link/useLinkComponent.js";
27
27
  import { TreeListBranches } from "./TreeListBranches.js";
28
28
  import { themeProps } from "../utils/themeProps.js";
29
29
  import { useTranslator } from "../i18n/index.js";
30
+ import { disabledInteractionProps } from "../utils/disabledInteraction.js";
30
31
 
31
32
  // =============================================================================
32
33
  // Styles
@@ -311,13 +312,16 @@ export function TreeListItem({
311
312
  // Stable identity for TreeList's activateItem selector — do not
312
313
  // remove without also updating TreeList.tsx.
313
314
  ,
314
- "data-tree-toggle": "",
315
- disabled: isDisabled
315
+ "data-tree-toggle": ""
316
+ // Disabled stays focusable (spec:AST-085 FR1) but inert (FR2).
317
+ ,
318
+ "aria-disabled": isDisabled || undefined
316
319
  // Roving tabindex lives on the treeitem row; the chevron toggle is not
317
320
  // a separate tab stop. Row-level Enter/Space forwards to this button.
318
321
  ,
319
322
  tabIndex: -1,
320
323
  onClick: handleToggle,
324
+ ...(isDisabled ? disabledInteractionProps() : null),
321
325
  ...mergeProps(
322
326
  // Stable theme target for the expand/collapse control. `data-tree-toggle`
323
327
  // stays as the functional activation hook; this adds a themeable
@@ -365,7 +369,8 @@ export function TreeListItem({
365
369
  }) : onClick != null ? /*#__PURE__*/_jsx("button", {
366
370
  type: "button",
367
371
  onClick: onClick,
368
- disabled: isDisabled,
372
+ "aria-disabled": isDisabled || undefined,
373
+ ...(isDisabled ? disabledInteractionProps() : null),
369
374
  "aria-labelledby": labelId,
370
375
  "aria-describedby": description != null ? descriptionId : undefined
371
376
  // Roving tabindex lives on the treeitem row; inner action is not a
@@ -1 +1 @@
1
- {"version":3,"file":"useGridFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useGridFocus.ts"],"names":[],"mappings":"AAsBA;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAEhB;;;;;;;;;;;;OAYG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAEjD;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,WAAW,GAAG,IAAI,CAAC;IAE3D;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAE5D;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAE3D;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IAExB;;;;;;;;;;;;;;;;;;OAkBG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE;;OAEG;IACH,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC;;OAEG;IACH,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD;;;;OAIG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAE3C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,EAAE,MAAM,IAAI,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,EAAE,mBAAmB,GAC3B,kBAAkB,CAAC,CAAC,CAAC,CAuYvB"}
1
+ {"version":3,"file":"useGridFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useGridFocus.ts"],"names":[],"mappings":"AAsBA;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAEhB;;;;;;;;;;;;OAYG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAEjD;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,WAAW,GAAG,IAAI,CAAC;IAE3D;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAE5D;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAE3D;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IAExB;;;;;;;;;;;;;;;;;;OAkBG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE;;OAEG;IACH,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC;;OAEG;IACH,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD;;;;OAIG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAE3C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,EAAE,MAAM,IAAI,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,EAAE,mBAAmB,GAC3B,kBAAkB,CAAC,CAAC,CAAC,CAsZvB"}
@@ -163,7 +163,16 @@ export function useGridFocus(options) {
163
163
  for (const el of targets) {
164
164
  setTabIndex(el, el === tabbable ? 0 : -1);
165
165
  }
166
- }, [getFocusTargets, setTabIndex]);
166
+ // A cell that stopped being focusable (an aria-disabled day, for example,
167
+ // which stays reachable by focus) can still hold the stop from earlier
168
+ // navigation; it never stays tabbable, so the grid keeps one Tab stop.
169
+ for (const cell of getCells()) {
170
+ const el = cellFocusable(cell) ? null : resolveFocusTarget(cell);
171
+ if (el != null) {
172
+ setTabIndex(el, -1);
173
+ }
174
+ }
175
+ }, [getFocusTargets, getCells, cellFocusable, resolveFocusTarget, setTabIndex]);
167
176
 
168
177
  // Keep the tab stop valid across renders (cells added/removed, focusability
169
178
  // toggled). Runs after every commit but only when roving tabindex is on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.3-canary.2468b8a",
3
+ "version": "0.7.3-canary.3133fb0",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,8 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
- "@astryxdesign/a11y-spec": "0.7.3-canary.2468b8a",
796
- "@astryxdesign/cli": "0.7.3-canary.2468b8a",
795
+ "@astryxdesign/a11y-spec": "0.7.3-canary.3133fb0",
796
+ "@astryxdesign/cli": "0.7.3-canary.3133fb0",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -3,11 +3,11 @@ schema_version: 3
3
3
  template_version: 3
4
4
  kind: component
5
5
  id: component:BottomSheet
6
- authority: draft
6
+ authority: current
7
7
  archive_reason: null
8
8
  superseded_by: null
9
- approved_by: null
10
- approved_at: null
9
+ approved_by: imdreamrunner
10
+ approved_at: 2026-10-21
11
11
  owners: [cixzhang]
12
12
  review_triggers: [theming, layout, public-api]
13
13
  verified_by:
@@ -15,7 +15,6 @@ verified_by:
15
15
  packages/core/src/BottomSheet/BottomSheet.test.tsx,
16
16
  packages/core/src/BottomSheet/BottomSheetPanel.test.tsx,
17
17
  packages/core/src/BottomSheet/BottomSheetKeyboard.test.tsx,
18
- packages/core/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts,
19
18
  packages/core/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts,
20
19
  apps/storybook/stories/BottomSheet.stories.tsx,
21
20
  scripts/check-knowledge.mjs,
@@ -38,52 +37,24 @@ system_specs: [spec:AST-025]
38
37
  ## Intent
39
38
 
40
39
  BottomSheet presents caller-provided content in a panel that rises from the
41
- bottom edge. This draft records current consumer anatomy, keyboard-reachable
42
- scrolling, theming reachability, and the Content area's container padding.
40
+ bottom edge. This record covers consumer anatomy, the native dialog every sheet
41
+ renders, keyboard-reachable scrolling, theming reachability, and the Content
42
+ area's container padding. Where the shipped sheet does not yet meet a
43
+ requirement, the row carries a present-tense implementation gap note.
43
44
 
44
45
  ## Compatibility and migration
45
46
 
46
- - Released default preserved: `no` — with no `padding` prop and no theme
47
- padding, the Content area insets by `--spacing-4` on every logical edge
48
- - Compatibility class: breaking default, scheduled for a minor release. Content
49
- whose only child is a padded Section keeps its geometry because that Section
50
- escapes the inset; content that supplies its own inset is padded twice until
51
- it drops that inset or sets `padding={0}`
47
+ - Released default preserved: `yes`; with no `padding` prop and no theme
48
+ padding, the Content area insets by `--spacing-4` on every logical edge, and
49
+ `padding={0}` keeps an unpadded Content area
50
+ - Compatibility class: records the shipped anatomy, padded Content area, and
51
+ native dialog; no public prop, default, or export changes
52
52
  - Controlled/uncontrolled behavior: unchanged
53
- - Migration decision: `padding={0}` restores the released unpadded Content area
53
+ - Migration decision: none; the precedence rule under Transformation and
54
+ precedence order is the whole contract
54
55
 
55
56
  Consumer migration instructions belong in consumer docs and release notes.
56
57
 
57
- ### DEP-0009 — Deprecate the unpadded default Content area of `BottomSheet`
58
-
59
- | Field | Value |
60
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
61
- | id | `DEP-0009` |
62
- | cleanup | `CLN-0018` |
63
- | package | `@astryxdesign/core` |
64
- | surface | Default inset of the `BottomSheet` Content area when no `padding` prop and no theme padding are set |
65
- | old contract | The Content area has no padding |
66
- | replacement | `padding={0}` keeps the unpadded Content area; `padding` and theme padding already resolve before the default |
67
- | direct authority | `component:BottomSheet` Compatibility and migration. Owners: `cixzhang` |
68
- | warning | Consumer docs and the release notes name `DEP-0009` and `padding={0}`. No runtime warning (`spec:AST-017` FR29) |
69
- | migration | Mechanical: add `padding={0}` to a BottomSheet that sets no padding, unless its only child is a padded Section or a Layout |
70
- | codemod | `astryx upgrade` adds `padding={0}` before any spread, skips sheets whose only child is a core Section or Layout, and marks a nested padding-less Layout for review |
71
- | downstream | In-repo sheets that supply their own inset pass `padding={0}`; consumer examples rely on the default inset |
72
- | transition | no prior transition |
73
- | state | `proposed` |
74
- | open decision | This record is draft and needs approval |
75
- | target plan | `CLN-0018` in the next minor release |
76
-
77
- ### CLN-0018 — BottomSheet pads its Content area by default
78
-
79
- | Field | Value |
80
- | --------- | ------------------------------------------------------------------------------------------------------------------------------------ |
81
- | id | `CLN-0018` |
82
- | lifecycle | `DEP-0009` |
83
- | delta | With no `padding` prop and no theme padding, the Content area insets by `--spacing-4` on every logical edge and publishes that inset |
84
- | rollback | Restore the unpadded default from the final-patch baseline |
85
- | state | `pending` — lands in a declared minor with its `[breaking]` Changeset |
86
-
87
58
  ## Ownership boundary
88
59
 
89
60
  **Owns**
@@ -95,7 +66,9 @@ Consumer migration instructions belong in consumer docs and release notes.
95
66
  `architecture:container-padding`.
96
67
  - Standalone sheet presentation, including its optional native-dialog Scrim.
97
68
  - The native dialog every sheet renders, standalone or switcher-managed
98
- (`component:BottomSheetSwitcher` DEC-1's decided structure).
69
+ (`component:BottomSheetSwitcher` DEC-1). Implementation gap: a
70
+ switcher-managed sheet renders a panel inside the switcher's one shared
71
+ dialog and owns no dialog of its own.
99
72
 
100
73
  **Does not own / non-goals**
101
74
 
@@ -104,7 +77,7 @@ Consumer migration instructions belong in consumer docs and release notes.
104
77
  `main`.
105
78
  - The presentation lifecycle of its dialog and the flow's scrim ownership when
106
79
  the sheet participates in a BottomSheetSwitcher — owned by BottomSheetSwitcher
107
- (`component:BottomSheetSwitcher` DEC-1's decided structure).
80
+ (`component:BottomSheetSwitcher` DEC-1).
108
81
  - Shared layer hosting or dismissal policy.
109
82
 
110
83
  ## Public concepts
@@ -114,12 +87,12 @@ in `BottomSheet.doc.mjs`.
114
87
 
115
88
  ## Behavioral and layout contract
116
89
 
117
- | ID | Candidate invariant | Basis | Draft review state |
118
- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | -------------------------------------------------- |
119
- | FR1 | The current presented sheet contains one Sheet panel, one scrolling Content area, and one decorative Handle; a Scrim is present only in scrim-backed presentation. | Current source, docs, and tests | Verified current behavior; no new behavior decided |
120
- | FR2 | The Sheet panel carries `bottom-sheet`; Content area, Handle, and Scrim carry no BottomSheet public target. | Current source, docs, and tests | Verified current behavior; no target change |
121
- | FR3 | Content renders in one real observed content box. Effective overflow, containment, and keyboard access come from `useScrollableArea`; the named overflowing body delegates forward Tab entry to a safe first sequential native link/button. | `spec:AST-025`; issue #5207; tests | Focus-time prototype for owner review |
122
- | FR4 | The Content area pads caller content by the sheet's container inset and publishes the applied inset to descendants as a container publisher. | `architecture:container-padding` | Admitted; implementation pending |
90
+ | ID | Invariant | Basis |
91
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
92
+ | FR1 | The current presented sheet contains one Sheet panel, one scrolling Content area, and one decorative Handle; a Scrim is present only in scrim-backed presentation. | Current source, docs, and tests |
93
+ | FR2 | The Sheet panel carries `bottom-sheet`; Content area, Handle, and Scrim carry no BottomSheet public target. | Current source, docs, and tests |
94
+ | FR3 | Content renders in one real observed content box. Effective overflow, containment, and keyboard access come from `useScrollableArea`; the named overflowing body delegates forward Tab entry to a safe first sequential native link/button. | `spec:AST-025`; tests |
95
+ | FR4 | The Content area pads caller content by the sheet's container inset and publishes the applied inset to descendants as a container publisher. | `architecture:container-padding` |
123
96
 
124
97
  ### Allowed variation
125
98
 
@@ -134,8 +107,8 @@ in `BottomSheet.doc.mjs`.
134
107
  without a Scrim.
135
108
  - A switcher-managed sheet renders the same panel anatomy in its own dialog while
136
109
  BottomSheetSwitcher coordinates that dialog's lifecycle and the flow's one Scrim
137
- (`component:BottomSheetSwitcher` DEC-1's decided structure, not shipped
138
- anatomy).
110
+ (`component:BottomSheetSwitcher` DEC-1). Implementation gap: the panel
111
+ renders inside the switcher's one shared dialog.
139
112
 
140
113
  ### Transformation and precedence order
141
114
 
@@ -3,17 +3,16 @@ schema_version: 3
3
3
  template_version: 4
4
4
  kind: component
5
5
  id: component:BottomSheetSwitcher
6
- authority: draft
6
+ authority: current
7
7
  archive_reason: null
8
8
  superseded_by: null
9
- approved_by: null
10
- approved_at: null
9
+ approved_by: imdreamrunner
10
+ approved_at: 2026-10-21
11
11
  owners: [cixzhang]
12
12
  review_triggers: [public-api, behavior, layout, theming, accessibility]
13
13
  verified_by:
14
14
  [
15
15
  packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx,
16
- packages/core/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts,
17
16
  apps/storybook/stories/BottomSheetSwitcher.stories.tsx,
18
17
  scripts/check-knowledge.mjs,
19
18
  ]
@@ -35,13 +34,10 @@ system_specs: [spec:AST-002, spec:AST-027]
35
34
  ## Intent
36
35
 
37
36
  BottomSheetSwitcher coordinates a mutually exclusive sequence of BottomSheet
38
- children. This draft records verified shipped and remediated behavior, and the
39
- hosting requirements its decision log decides — each child presented in its own
40
- native dialog — without adding a prop, changing a default, or settling the
41
- open modality, identifier, or theming decisions below. A row whose review state
42
- is "Owner decision" is the decided requirement, not shipped behavior; the
43
- representative states that name a sheet's own dialog, the retained dialog, or
44
- the flow wrapper describe DEC-1's decided structure in the same way.
37
+ children, each presented in its own native dialog (DEC-1), without adding a
38
+ prop or changing a default. Where the shipped switcher does not yet meet a
39
+ requirement, the row carries a present-tense implementation gap note. The
40
+ modality, identifier, and theming questions below stay open.
45
41
 
46
42
  ## Compatibility and migration
47
43
 
@@ -61,6 +57,8 @@ Consumer migration instructions belong in consumer docs and release notes.
61
57
 
62
58
  - The presentation lifecycle of every directly nested BottomSheet child's native
63
59
  dialog: which is presented, retained during a handoff, or closed.
60
+ Implementation gap: the switcher renders one dialog of its own and hosts
61
+ every child as a panel inside it; no child owns a dialog yet.
64
62
  - Which child sheet is active, retained during a handoff, or hidden.
65
63
  - Which sheet dialog hosts the flow (its ref, `id`, and dismissal
66
64
  registration) and which paints the flow's one scrim, plus
@@ -81,30 +79,27 @@ Consumer migration instructions belong in consumer docs and release notes.
81
79
 
82
80
  ## Public concepts
83
81
 
84
- | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
85
- | ------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------ |
86
- | Active sheet | `null` or one nested BottomSheet `sheetId` | Selects the only interactive child, or closes the flow | All presentations | `null` is closed; the prop is required | `component:BottomSheetSwitcher` | released | A non-matching or duplicate id is not given a safe public fallback by the current contract |
87
- | Shared presentation | `true` / `false` through `hasScrim` | Selects the current modal scrim-backed host or non-modal no-scrim host | Entire flow | `true` | `component:BottomSheetSwitcher` | released | Boolean only |
88
- | Child identity | Non-empty `sheetId` on each direct BottomSheet child | Associates controlled selection, labeling, purpose, and transition state with a child | Direct nested BottomSheet children | none | `component:BottomSheet` | released | Empty ids warn and stay hidden; duplicate-id behavior is not specified |
89
- | Dismissal request | `onActiveSheetChange(null)` | Reports an allowed implicit close without taking control from the caller | Escape/platform close, scrim click, or swipe according to active child purpose | none | `component:BottomSheetSwitcher`; `family:overlay-dismissal` owns ordering | released | The caller may retain its controlled value |
90
- | Dialog surface | inherited dialog props plus `ref` and `onCancel` | Dialog props and handlers, `onCancel` included, reach every sheet dialog; `ref`, `id`, and `data-testid` resolve to the host dialog, which is never null for an object ref while a sheet is mounted (owner decision, DEC-2) | Entire flow | none | `component:BottomSheetSwitcher` | released | Component-owned semantics and handlers retain precedence |
82
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
83
+ | ------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------ |
84
+ | Active sheet | `null` or one nested BottomSheet `sheetId` | Selects the only interactive child, or closes the flow | All presentations | `null` is closed; the prop is required | `component:BottomSheetSwitcher` | released | A non-matching or duplicate id is not given a safe public fallback by the current contract |
85
+ | Shared presentation | `true` / `false` through `hasScrim` | Selects the current modal scrim-backed host or non-modal no-scrim host | Entire flow | `true` | `component:BottomSheetSwitcher` | released | Boolean only |
86
+ | Child identity | Non-empty `sheetId` on each direct BottomSheet child | Associates controlled selection, labeling, purpose, and transition state with a child | Direct nested BottomSheet children | none | `component:BottomSheet` | released | Empty ids warn and stay hidden; duplicate-id behavior is not specified |
87
+ | Dismissal request | `onActiveSheetChange(null)` | Reports an allowed implicit close without taking control from the caller | Escape/platform close, scrim click, or swipe according to active child purpose | none | `component:BottomSheetSwitcher`; `family:overlay-dismissal` owns ordering | released | The caller may retain its controlled value |
88
+ | Dialog surface | inherited dialog props plus `ref` and `onCancel` | Dialog props and handlers, `onCancel` included, reach every sheet dialog; `ref`, `id`, and `data-testid` resolve to the host dialog, which is never null for an object ref while a sheet is mounted (DEC-2). Implementation gap: every dialog prop, `ref`, `id`, and `data-testid` reaches the switcher's one shared dialog | Entire flow | none | `component:BottomSheetSwitcher` | released | Component-owned semantics and handlers retain precedence |
91
89
 
92
90
  ## Behavioral and layout contract
93
91
 
94
- Draft requirements identify their basis so observed code is not mistaken for an
95
- intentional decision. This draft cannot clear the gaps it records.
96
-
97
- | ID | Candidate invariant | Basis | Draft review state |
98
- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
99
- | FR1 | Zero or one child sheet is interactive for each controlled `activeSheet` value; all other children are hidden or retained inert and `aria-hidden` during a transition. | Current source, docs, and tests | Verified shipped behavior |
100
- | FR2 | Each child sheet owns one native dialog. Opening a sheet presents its dialog once; a handoff presents the entering sheet's dialog above the retained one and closes the retained dialog only after its panel has faded. Returning to a sheet whose dialog is still open beneath a newer one re-presents it so the interactive sheet is the topmost layer. | DEC-1 | Owner decision |
101
- | FR3 | During a handoff, the entering sheet is above the retained sheet. A taller retained sheet aligns down to a shorter entering sheet, and the retained sheet fades only after required transform motion completes. | Current source and transition tests | Verified shipped behavior; exact visual treatment remains human-reviewed |
102
- | FR4 | Closing retains the outgoing sheet through its exit, then closes the dialog. A modal flow restores focus to the element captured when that modal flow opened. | Current source and focus tests | Verified shipped behavior |
103
- | FR5 | `hasScrim=true` uses `showModal()`, a native backdrop, and native focus containment, and locks page scroll: the page does not scroll while a modal flow is presented; the lock is held continuously across a handoff, never released and re-acquired between steps; it still holds beneath a `Dialog` opened from a step; it is released after the flow's exit (owner decision, DEC-1). `hasScrim=false` uses `show()` without a backdrop or scroll lock and leaves the page interactive (verified current behavior). In a non-modal flow the sheet dialogs order themselves with local values inside the switcher's isolated flow wrapper (owner decision, DEC-1); the page-level value that wrapper carries remains a `spec:AST-027` conformance gap. | DEC-1; `spec:AST-027` FR2, FR4, FR8 | Modal host and the wrapper's local ordering: owner decision. Non-modal `show()` host: verified current behavior. The wrapper's page-level value remains a `spec:AST-027` conformance gap |
104
- | FR6 | The active child purpose governs implicit dismissal: `info` allows Escape, scrim click, and swipe; `form` allows Escape; `required` blocks all three and exposes `alertdialog`. | Current BottomSheet docs and switcher tests | Verified shipped behavior |
105
- | FR7 | While visible, the switcher participates in `family:overlay-dismissal`; one Escape or platform close request reaches only the topmost present layer, and descendants receive a deeper logical layer scope. | `family:overlay-dismissal` and the audit regression test | Settled remediation in this audit |
106
- | FR8 | One backdrop paints the flow's scrim at a time: the retained sheet's dialog keeps it through a handoff and the entering dialog takes it over without a fade when the retained dialog closes. A stale gesture from an outgoing sheet cannot change the flow's scrim, and unmounting a retained sheet cannot keep its dialog open or the flow's modal layer held. | DEC-1; current tests | Owner decision |
107
- | FR9 | The switcher forwards neutral dialog attributes, styling inputs, and composed handlers, `onCancel` included, to every sheet dialog, so a handler fires for whichever sheet dialog raises the event; renders `id` and `data-testid` on the host dialog only, so each appears once in the document; attaches its ref to the host dialog; and preserves its owned ARIA and dismissal behavior. An object ref's `current` is never `null` while a sheet is mounted. A callback ref follows React's semantics on a handoff: a cleanup it returned runs when it detaches from the old host; a callback ref without a cleanup is called with `null` on detach, then with the new host. | DEC-1, DEC-2; `architecture:public-component-api` INV8 | Owner decision |
92
+ | ID | Invariant | Basis |
93
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
94
+ | FR1 | Zero or one child sheet is interactive for each controlled `activeSheet` value; all other children are hidden or retained inert and `aria-hidden` during a transition. | Current source, docs, and tests |
95
+ | FR2 | Each child sheet owns one native dialog. Opening a sheet presents its dialog once; a handoff presents the entering sheet's dialog above the retained one and closes the retained dialog only after its panel has faded. Returning to a sheet whose dialog is still open beneath a newer one re-presents it so the interactive sheet is the topmost layer. Implementation gap: the switcher presents one shared dialog once per flow and hosts every child as a panel inside it; a handoff swaps panels in that dialog, and no dialog is re-presented. | DEC-1 |
96
+ | FR3 | During a handoff, the entering sheet is above the retained sheet. A taller retained sheet aligns down to a shorter entering sheet, and the retained sheet fades only after required transform motion completes. | Current source and transition tests |
97
+ | FR4 | Closing retains the outgoing sheet through its exit, then closes the dialog. A modal flow restores focus to the element captured when that modal flow opened. | Current source and focus tests |
98
+ | FR5 | `hasScrim=true` uses `showModal()`, a native backdrop, and native focus containment, and locks page scroll: the page does not scroll while a modal flow is presented; the lock is held continuously across a handoff, never released and re-acquired between steps; it still holds beneath a `Dialog` opened from a step; it is released after the flow's exit (DEC-1). `hasScrim=false` uses `show()` without a backdrop or scroll lock and leaves the page interactive. In a non-modal flow the sheet dialogs order themselves with local values inside the switcher's isolated flow wrapper (DEC-1); the page-level value that wrapper carries remains a `spec:AST-027` conformance gap. Implementation gap: a script focus trap runs on top of the modal dialog's native containment; a non-modal flow is one shared dialog carrying the page-level value itself, with no flow wrapper. | DEC-1; `spec:AST-027` FR2, FR4, FR8 |
99
+ | FR6 | The active child purpose governs implicit dismissal: `info` allows Escape, scrim click, and swipe; `form` allows Escape; `required` blocks all three and exposes `alertdialog`. | Current BottomSheet docs and switcher tests |
100
+ | FR7 | While visible, the switcher participates in `family:overlay-dismissal`; one Escape or platform close request reaches only the topmost present layer, and descendants receive a deeper logical layer scope. | `family:overlay-dismissal` and the audit regression test |
101
+ | FR8 | One backdrop paints the flow's scrim at a time: the retained sheet's dialog keeps it through a handoff and the entering dialog takes it over without a fade when the retained dialog closes. A stale gesture from an outgoing sheet cannot change the flow's scrim, and unmounting a retained sheet cannot keep its dialog open or the flow's modal layer held. Implementation gap: the one backdrop is the shared dialog's, so no takeover between dialogs happens. | DEC-1; current tests |
102
+ | FR9 | The switcher forwards neutral dialog attributes, styling inputs, and composed handlers, `onCancel` included, to every sheet dialog, so a handler fires for whichever sheet dialog raises the event; renders `id` and `data-testid` on the host dialog only, so each appears once in the document; attaches its ref to the host dialog; and preserves its owned ARIA and dismissal behavior. An object ref's `current` is never `null` while a sheet is mounted. A callback ref follows React's semantics on a handoff: a cleanup it returned runs when it detaches from the old host; a callback ref without a cleanup is called with `null` on detach, then with the new host. Implementation gap: every forwarded input, `ref`, `id`, and `data-testid` reaches the one shared dialog, which never changes on a handoff. | DEC-1, DEC-2; `architecture:public-component-api` INV8 |
108
103
 
109
104
  ### Allowed variation
110
105
 
@@ -116,8 +111,9 @@ intentional decision. This draft cannot clear the gaps it records.
116
111
 
117
112
  ### Representative states
118
113
 
119
- The states below describe DEC-1's decided structure where they name a sheet's
120
- own dialog, the retained dialog, or the flow wrapper.
114
+ Where a state names a sheet's own dialog, the retained dialog, or the flow
115
+ wrapper, the shipped switcher meets it with its one shared dialog instead; FR2
116
+ and FR5 record that implementation gap.
121
117
 
122
118
  | State | Required invariant | Allowed variation |
123
119
  | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
@@ -125,7 +121,7 @@ own dialog, the retained dialog, or the flow wrapper.
125
121
  | First open | Matching child is visible and interactive in its own dialog. | Child-owned height and content. |
126
122
  | Handoff | Entering child is interactive in its own dialog; retained child's dialog stays open, inert, and hidden from accessibility APIs until it fades, and keeps painting the one scrim. | Alignment offset depends on measured panel geometry. |
127
123
  | Closing | Outgoing child is retained inert while exit motion completes; modal focus and scroll ownership remain until close. | Theme-controlled duration. |
128
- | Modal | Native modal host, backdrop, native focus containment, and the page scroll lock are active. | Active child's `purpose` controls implicit dismissal. |
124
+ | Modal | Native modal host, backdrop, native focus containment, and the page scroll lock are active. Implementation gap: a script focus trap runs on top of the native containment. | Active child's `purpose` controls implicit dismissal. |
129
125
  | Non-modal | Native non-modal host has no backdrop or scroll lock and background content remains interactive. | The current clipping/stacking limitation is a conformance gap, not an allowed exception. |
130
126
 
131
127
  ### Transformation and precedence order
@@ -144,7 +140,8 @@ own dialog, the retained dialog, or the flow wrapper.
144
140
  Native modality holds across a modal flow's child handoffs because a sheet
145
141
  dialog is modal throughout, and the one page scroll lock is held for the
146
142
  whole flow rather than per step. A non-modal flow paints no backdrop and
147
- holds no lock.
143
+ holds no lock. Implementation gap: one shared dialog is presented
144
+ throughout, so a handoff has one dialog open, not two.
148
145
  - **PR2 — Stable child registration.** Parent rerenders and changing consumer ref
149
146
  identities do not unregister a mounted child or cancel an active handoff.
150
147
 
@@ -160,11 +157,11 @@ own dialog, the retained dialog, or the flow wrapper.
160
157
 
161
158
  ## Design relationships
162
159
 
163
- | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
164
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------- | ------------------ |
165
- | Shared dialog | Transparent host for the flow's interaction and accessibility boundary; DEC-1 decides one such host per child sheet | Current source and public docs; DEC-1 for the per-sheet clause | Structural | FR2, FR5, AR1 |
166
- | Sheet panels | Delegate visible panel, content, handle, and gesture presentation to BottomSheet | `component:BottomSheet` draft as observational context | Prominent | FR1, FR3 |
167
- | Scrim | Optional native backdrop that dims and blocks the page in modal presentation; DEC-1 decides that one sheet dialog paints it at a time | Current source and public docs; DEC-1 for the one-painter clause | Supporting | FR5, FR8 |
160
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
161
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -------------- | ------------------ |
162
+ | Shared dialog | Transparent host for the flow's interaction and accessibility boundary, one per child sheet (DEC-1). Implementation gap: one such host serves the whole flow | Current source and public docs; DEC-1 for the per-sheet clause | Structural | FR2, FR5, AR1 |
163
+ | Sheet panels | Delegate visible panel, content, handle, and gesture presentation to BottomSheet | `component:BottomSheet` as observational context | Prominent | FR1, FR3 |
164
+ | Scrim | Optional native backdrop that dims and blocks the page in modal presentation; one sheet dialog paints it at a time (DEC-1) | Current source and public docs; DEC-1 for the one-painter clause | Supporting | FR5, FR8 |
168
165
 
169
166
  ### Theming anatomy
170
167
 
@@ -198,16 +195,16 @@ own dialog, the retained dialog, or the flow wrapper.
198
195
  - `architecture:public-component-api` and `spec:AST-002` own the released prop,
199
196
  DOM, ref, and compatibility surface.
200
197
  - `spec:AST-027` identifies the non-modal `show()` plus page-level `z-index` path
201
- as a migration gap; this draft does not treat it as an exception.
198
+ as a migration gap, not an exception.
202
199
 
203
200
  ## Verification map
204
201
 
205
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
206
- | ---------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
207
- | FR1–FR4, FR8 | `BottomSheetSwitcher.test.tsx` | closed, first open, handoffs in either completion order, rapid replacement, unmount, close | A child becomes interactive at the wrong time, a retained child disappears early, a second backdrop paints, or a sheet dialog's lifecycle breaks. | `audit:BottomSheetSwitcher/behavior` |
208
- | FR5–FR7, AR1–AR3 | `BottomSheetSwitcher.test.tsx`; `BottomSheetNativeModal.a11y.browser.spec.ts` | modal/non-modal, info/form/required, nested layer, IME, focus return, scroll lock in engines, non-modal local ordering | Modality, dismissal order, naming, inertness, scroll lock, or focus behavior diverges, or a sheet dialog takes a page-level z-index. | `audit:BottomSheetSwitcher/accessibility` |
209
- | FR9 | `BottomSheetSwitcher.test.tsx`; export checks | ref across open/handoff/close/unmount, callback-ref cleanup, `id` once, DOM/ARIA/data/events, class, style, xstyle | A supported input is dropped or duplicated, an object ref is null while a sheet is mounted or skips a cleanup, or an input overrides component-owned semantics accidentally. | `audit:BottomSheetSwitcher/api` |
210
- | Theming anatomy | `scripts/check-knowledge.mjs`; theming tests | dialog, delegated sheet panel, scrim | A non-painting host gains a target, a delegated panel loses its owner, or the scrim gap disappears without review. | `audit:BottomSheetSwitcher/theming` |
202
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
203
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
204
+ | FR1–FR4, FR8 | `BottomSheetSwitcher.test.tsx` | closed, first open, handoffs in either completion order, rapid replacement, unmount, close | A child becomes interactive at the wrong time, a retained child disappears early, a second backdrop paints, or a sheet dialog's lifecycle breaks. | `audit:BottomSheetSwitcher/behavior` |
205
+ | FR5–FR7, AR1–AR3 | `BottomSheetSwitcher.test.tsx`; `BottomSheetNativeModal.a11y.browser.spec.ts` (implementation gap: not yet in the tree) | modal/non-modal, info/form/required, nested layer, IME, focus return, scroll lock in engines, non-modal local ordering | Modality, dismissal order, naming, inertness, scroll lock, or focus behavior diverges, or a sheet dialog takes a page-level z-index. | `audit:BottomSheetSwitcher/accessibility` |
206
+ | FR9 | `BottomSheetSwitcher.test.tsx`; export checks | ref across open/handoff/close/unmount, callback-ref cleanup, `id` once, DOM/ARIA/data/events, class, style, xstyle | A supported input is dropped or duplicated, an object ref is null while a sheet is mounted or skips a cleanup, or an input overrides component-owned semantics accidentally. | `audit:BottomSheetSwitcher/api` |
207
+ | Theming anatomy | `scripts/check-knowledge.mjs`; theming tests | dialog, delegated sheet panel, scrim | A non-painting host gains a target, a delegated panel loses its owner, or the scrim gap disappears without review. | `audit:BottomSheetSwitcher/theming` |
211
208
 
212
209
  ## Decision log
213
210
 
@@ -382,25 +382,35 @@ describe('Calendar', () => {
382
382
  // ─── Date Constraints ────────────────────────────────────────
383
383
 
384
384
  it('respects min date constraint', () => {
385
- render(<Calendar focusDate="2026-01-01" min="2026-01-10" />);
385
+ const onChange = vi.fn();
386
+ render(
387
+ <Calendar focusDate="2026-01-01" min="2026-01-10" onChange={onChange} />,
388
+ );
386
389
 
387
390
  // Day 5 should be disabled (before min)
388
391
  const day5 = getDayButton(5);
389
- expect(day5).toBeDisabled();
392
+ expect(day5).toHaveAttribute('aria-disabled', 'true');
393
+ // spec:AST-085 FR1: focusable through aria-disabled, not natively disabled;
394
+ // a click still selects nothing.
395
+ expect(day5).not.toHaveAttribute('disabled');
396
+ day5.focus();
397
+ expect(day5).toHaveFocus();
398
+ day5.click();
399
+ expect(onChange).not.toHaveBeenCalled();
390
400
 
391
401
  // Day 15 should be enabled (after min)
392
402
  const day15 = getDayButton(15);
393
- expect(day15).not.toBeDisabled();
403
+ expect(day15).not.toHaveAttribute('aria-disabled', 'true');
394
404
  });
395
405
 
396
406
  it('respects max date constraint', () => {
397
407
  render(<Calendar focusDate="2026-01-01" max="2026-01-20" />);
398
408
 
399
409
  const day25 = getDayButton(25);
400
- expect(day25).toBeDisabled();
410
+ expect(day25).toHaveAttribute('aria-disabled', 'true');
401
411
 
402
412
  const day15 = getDayButton(15);
403
- expect(day15).not.toBeDisabled();
413
+ expect(day15).not.toHaveAttribute('aria-disabled', 'true');
404
414
  });
405
415
 
406
416
  // ─── Initial Visible Month ───────────────────────────────────
@@ -424,14 +434,20 @@ describe('Calendar', () => {
424
434
  render(<Calendar min="2027-03-04" max="2027-06-30" />);
425
435
 
426
436
  expect(screen.getByText('March 2027')).toBeInTheDocument();
427
- expect(getDayButton(4, 'March', 2027)).not.toBeDisabled();
437
+ expect(getDayButton(4, 'March', 2027)).not.toHaveAttribute(
438
+ 'aria-disabled',
439
+ 'true',
440
+ );
428
441
  });
429
442
 
430
443
  it('opens on max when the window is entirely in the past', () => {
431
444
  render(<Calendar min="2019-01-01" max="2019-04-30" />);
432
445
 
433
446
  expect(screen.getByText('April 2019')).toBeInTheDocument();
434
- expect(getDayButton(30, 'April', 2019)).not.toBeDisabled();
447
+ expect(getDayButton(30, 'April', 2019)).not.toHaveAttribute(
448
+ 'aria-disabled',
449
+ 'true',
450
+ );
435
451
  });
436
452
 
437
453
  it('keeps max in the last pane in the two-month layout', () => {
@@ -466,7 +482,7 @@ describe('Calendar', () => {
466
482
 
467
483
  // January 4, 2026 is a Sunday - should be disabled
468
484
  const sunday = getDayButton(4);
469
- expect(sunday).toBeDisabled();
485
+ expect(sunday).toHaveAttribute('aria-disabled', 'true');
470
486
  });
471
487
 
472
488
  it('caps the end date to maxRangeSpan once a start is picked', async () => {
@@ -475,15 +491,43 @@ describe('Calendar', () => {
475
491
  render(<Calendar mode="range" focusDate="2026-01-01" maxRangeSpan={7} />);
476
492
 
477
493
  // Before a start is picked every day is selectable.
478
- expect(getDayButton(20)).not.toBeDisabled();
494
+ expect(getDayButton(20)).not.toHaveAttribute('aria-disabled', 'true');
479
495
 
480
496
  // Pick Jan 10 as the start. A 7-day window spans Jan 4–16 (start ± 6).
481
497
  await user.click(getDayButton(10));
482
498
 
483
- expect(getDayButton(16)).not.toBeDisabled(); // 6 days after — the edge
484
- expect(getDayButton(17)).toBeDisabled(); // 7 days after — beyond the cap
485
- expect(getDayButton(4)).not.toBeDisabled(); // 6 days before — symmetric
486
- expect(getDayButton(3)).toBeDisabled(); // 7 days before — beyond the cap
499
+ expect(getDayButton(16)).not.toHaveAttribute('aria-disabled', 'true'); // 6 days after — the edge
500
+ expect(getDayButton(17)).toHaveAttribute('aria-disabled', 'true'); // 7 days after — beyond the cap
501
+ expect(getDayButton(4)).not.toHaveAttribute('aria-disabled', 'true'); // 6 days before — symmetric
502
+ expect(getDayButton(3)).toHaveAttribute('aria-disabled', 'true'); // 7 days before — beyond the cap
503
+ });
504
+
505
+ it('keeps one Tab stop when the span cap disables the day that held it', async () => {
506
+ const user = userEvent.setup();
507
+
508
+ render(<Calendar mode="range" focusDate="2026-01-01" maxRangeSpan={7} />);
509
+
510
+ // Arrow from Jan 1 to Jan 25 so Jan 25 holds the grid's Tab stop.
511
+ getDayButton(1).focus();
512
+ await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}');
513
+ for (let i = 0; i < 3; i += 1) {
514
+ await user.keyboard('{ArrowRight}');
515
+ }
516
+ expect(getDayButton(25)).toHaveFocus();
517
+
518
+ // Picking Jan 10 as the start disables Jan 25 (beyond the 7-day cap).
519
+ await user.click(getDayButton(10));
520
+ expect(getDayButton(25)).toHaveAttribute('aria-disabled', 'true');
521
+
522
+ // spec:AST-085 FR1: a disabled day stays focusable but never keeps the
523
+ // grid's Tab stop, so the grid still has exactly one, on an enabled day.
524
+ const grid = screen.getByRole('grid');
525
+ const stops = Array.from(
526
+ grid.querySelectorAll<HTMLElement>('button[data-date][tabindex="0"]'),
527
+ );
528
+ expect(stops).toHaveLength(1);
529
+ expect(stops[0]).not.toHaveAttribute('aria-disabled', 'true');
530
+ expect(getDayButton(25)).toHaveAttribute('tabindex', '-1');
487
531
  });
488
532
 
489
533
  it('enforces minRangeSpan once a start is picked', async () => {
@@ -496,11 +540,11 @@ describe('Calendar', () => {
496
540
 
497
541
  // The picked start itself stays enabled — it is the active selection
498
542
  // anchor, not an unreachable end.
499
- expect(getDayButton(10)).not.toBeDisabled();
500
- expect(getDayButton(11)).toBeDisabled(); // 1 day apart — too short
501
- expect(getDayButton(12)).not.toBeDisabled(); // 3-day span — allowed
502
- expect(getDayButton(8)).not.toBeDisabled(); // 3-day span the other way
503
- expect(getDayButton(9)).toBeDisabled(); // 2-day span — too short
543
+ expect(getDayButton(10)).not.toHaveAttribute('aria-disabled', 'true');
544
+ expect(getDayButton(11)).toHaveAttribute('aria-disabled', 'true'); // 1 day apart — too short
545
+ expect(getDayButton(12)).not.toHaveAttribute('aria-disabled', 'true'); // 3-day span — allowed
546
+ expect(getDayButton(8)).not.toHaveAttribute('aria-disabled', 'true'); // 3-day span the other way
547
+ expect(getDayButton(9)).toHaveAttribute('aria-disabled', 'true'); // 2-day span — too short
504
548
  });
505
549
 
506
550
  it('commits a same-day range with the default minimum', async () => {
@@ -565,8 +609,8 @@ describe('Calendar', () => {
565
609
 
566
610
  // …and the start is cleared: the days that minRangeSpan disabled around
567
611
  // the old anchor are selectable again, so a new start can be placed there.
568
- expect(getDayButton(11)).not.toBeDisabled();
569
- expect(getDayButton(9)).not.toBeDisabled();
612
+ expect(getDayButton(11)).not.toHaveAttribute('aria-disabled', 'true');
613
+ expect(getDayButton(9)).not.toHaveAttribute('aria-disabled', 'true');
570
614
  });
571
615
 
572
616
  it('announces the cleared range in the provider locale', async () => {
@@ -603,7 +647,7 @@ describe('Calendar', () => {
603
647
 
604
648
  // A picked single date must not disable far-away days.
605
649
  await user.click(getDayButton(10));
606
- expect(getDayButton(25)).not.toBeDisabled();
650
+ expect(getDayButton(25)).not.toHaveAttribute('aria-disabled', 'true');
607
651
  });
608
652
 
609
653
  it('keeps a controlled value that is wider than maxRangeSpan (selection-only)', () => {
@@ -621,7 +665,7 @@ describe('Calendar', () => {
621
665
 
622
666
  // No selection is in progress (both endpoints are set), so no anchor →
623
667
  // every day is pickable and both endpoints render selected.
624
- expect(getDayButton(20)).not.toBeDisabled();
668
+ expect(getDayButton(20)).not.toHaveAttribute('aria-disabled', 'true');
625
669
  expect(getDayButton(5).closest('[role="gridcell"]')).toHaveAttribute(
626
670
  'aria-selected',
627
671
  'true',
@@ -1713,7 +1757,7 @@ describe('Calendar', () => {
1713
1757
  render(<Calendar focusDate="2026-01-01" min="2026-01-05" />);
1714
1758
 
1715
1759
  const day1 = getDayButton(1);
1716
- expect(day1).toBeDisabled();
1760
+ expect(day1).toHaveAttribute('aria-disabled', 'true');
1717
1761
 
1718
1762
  const day8 = getDayButton(8);
1719
1763
  day8.focus();
@@ -1741,7 +1785,7 @@ describe('Calendar', () => {
1741
1785
  render(<Calendar focusDate="2026-01-01" dateConstraints={[disableJan8]} />);
1742
1786
 
1743
1787
  const day8 = getDayButton(8);
1744
- expect(day8).toBeDisabled();
1788
+ expect(day8).toHaveAttribute('aria-disabled', 'true');
1745
1789
 
1746
1790
  const day15 = getDayButton(15);
1747
1791
  day15.focus();
@@ -1372,7 +1372,8 @@ function DayCell({
1372
1372
  // Mark today's cell programmatically (APG date-picker pattern), not just
1373
1373
  // visually, so screen-reader users can identify the current date.
1374
1374
  aria-current={state.isToday ? 'date' : undefined}
1375
- disabled={isDisabled}
1375
+ // A disabled day stays focusable through aria-disabled (spec:AST-085
1376
+ // FR1); arrows still skip it and its click and hover are guarded.
1376
1377
  // Initial roving tab-stop seed; useGridFocus owns it after mount.
1377
1378
  tabIndex={isTabbableDay ? 0 : -1}
1378
1379
  onClick={() => !state.effectivelyDisabled && onDayClick(date)}
@@ -1047,13 +1047,19 @@ describe('DateRangeInput range-span forwarding', () => {
1047
1047
  fireEvent.click(getButton('Open calendar'));
1048
1048
 
1049
1049
  // Before a start is picked, a far-off day is selectable.
1050
- expect(dayButton('2026-01-20')).not.toBeDisabled();
1050
+ expect(dayButton('2026-01-20')).not.toHaveAttribute(
1051
+ 'aria-disabled',
1052
+ 'true',
1053
+ );
1051
1054
 
1052
1055
  fireEvent.click(dayButton('2026-01-10') as HTMLButtonElement);
1053
1056
 
1054
1057
  // A 7-day window spans start ± 6 days: Jan 16 is the edge, Jan 17 is out.
1055
- expect(dayButton('2026-01-16')).not.toBeDisabled();
1056
- expect(dayButton('2026-01-17')).toBeDisabled();
1058
+ expect(dayButton('2026-01-16')).not.toHaveAttribute(
1059
+ 'aria-disabled',
1060
+ 'true',
1061
+ );
1062
+ expect(dayButton('2026-01-17')).toHaveAttribute('aria-disabled', 'true');
1057
1063
  });
1058
1064
 
1059
1065
  it('disables a preset whose range violates the span cap', () => {
@@ -763,9 +763,13 @@ describe('Pagination', () => {
763
763
  />,
764
764
  );
765
765
  const dot = screen.getByRole('button', {name: 'Go to page 2'});
766
- expect(dot).toBeDisabled();
766
+ // spec:AST-085 FR1: the active dot keeps its tab stop, exposed as disabled.
767
+ expect(dot).not.toHaveAttribute('disabled');
768
+ expect(dot).toHaveAttribute('aria-disabled', 'true');
767
769
  dot.focus();
770
+ expect(dot).toHaveFocus();
768
771
  await user.keyboard('{ArrowRight}');
772
+ await user.keyboard('{Enter} ');
769
773
  expect(onChange).not.toHaveBeenCalled();
770
774
  });
771
775
  });
@@ -706,7 +706,9 @@ export function Pagination({
706
706
  // needs to focus the dot, which some browsers (Safari) skip for
707
707
  // buttons, so focus it explicitly.
708
708
  onClick={e => e.currentTarget.focus()}
709
- disabled={isDisabled}
709
+ // Disabled dots stay focusable (spec:AST-085 FR1); the page
710
+ // only follows focus while enabled (handleDotsFocus).
711
+ aria-disabled={isDisabled || undefined}
710
712
  {...mergeProps(
711
713
  themeProps('pagination-dot', {
712
714
  active: isActive ? 'active' : null,
@@ -942,6 +942,34 @@ describe('TreeList', () => {
942
942
  expect(screen.queryByText('File')).not.toBeInTheDocument();
943
943
  });
944
944
 
945
+ it("exposes a disabled row's toggle and action as aria-disabled and keeps them inert", () => {
946
+ const onClick = vi.fn();
947
+ const {container} = render(
948
+ <TreeList
949
+ items={underRoot([
950
+ {id: 'run', label: 'Run', onClick, isDisabled: true},
951
+ {...folder, isDisabled: true},
952
+ ])}
953
+ />,
954
+ );
955
+ // spec:AST-085 FR1/FR2: no native disabled anywhere, and a click
956
+ // dispatched straight on the inner controls does nothing.
957
+ expect(container.querySelector('[disabled]')).toBeNull();
958
+ const toggle = container.querySelector<HTMLElement>(
959
+ '[data-tree-toggle][aria-disabled="true"]',
960
+ )!;
961
+ expect(toggle).not.toBeNull();
962
+ toggle.click();
963
+ fireEvent.keyDown(toggle, {key: 'Enter'});
964
+ expect(screen.queryByText('File')).not.toBeInTheDocument();
965
+ const action = Array.from(container.querySelectorAll('button')).find(
966
+ button => button.textContent?.includes('Run'),
967
+ )!;
968
+ expect(action).toHaveAttribute('aria-disabled', 'true');
969
+ action.click();
970
+ expect(onClick).not.toHaveBeenCalled();
971
+ });
972
+
945
973
  it('leaves a role-based control in start or end content to itself', () => {
946
974
  // Native and role-based controls alike keep their own activation,
947
975
  // the same rule link rows get from the shared enlarged-surface hook.
@@ -38,6 +38,7 @@ import {TreeListBranches} from './TreeListBranches';
38
38
  import type {TreeListDensity, TreeListVariant} from './TreeListTypes';
39
39
  import {themeProps} from '../utils/themeProps';
40
40
  import {useTranslator} from '../i18n';
41
+ import {disabledInteractionProps} from '../utils/disabledInteraction';
41
42
 
42
43
  // =============================================================================
43
44
  // Styles
@@ -552,11 +553,13 @@ export function TreeListItem({
552
553
  // Stable identity for TreeList's activateItem selector — do not
553
554
  // remove without also updating TreeList.tsx.
554
555
  data-tree-toggle=""
555
- disabled={isDisabled}
556
+ // Disabled stays focusable (spec:AST-085 FR1) but inert (FR2).
557
+ aria-disabled={isDisabled || undefined}
556
558
  // Roving tabindex lives on the treeitem row; the chevron toggle is not
557
559
  // a separate tab stop. Row-level Enter/Space forwards to this button.
558
560
  tabIndex={-1}
559
561
  onClick={handleToggle}
562
+ {...(isDisabled ? disabledInteractionProps() : null)}
560
563
  {...mergeProps(
561
564
  // Stable theme target for the expand/collapse control. `data-tree-toggle`
562
565
  // stays as the functional activation hook; this adds a themeable
@@ -608,7 +611,8 @@ export function TreeListItem({
608
611
  <button
609
612
  type="button"
610
613
  onClick={onClick}
611
- disabled={isDisabled}
614
+ aria-disabled={isDisabled || undefined}
615
+ {...(isDisabled ? disabledInteractionProps() : null)}
612
616
  aria-labelledby={labelId}
613
617
  aria-describedby={description != null ? descriptionId : undefined}
614
618
  // Roving tabindex lives on the treeitem row; inner action is not a
@@ -298,7 +298,22 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
298
298
  for (const el of targets) {
299
299
  setTabIndex(el, el === tabbable ? 0 : -1);
300
300
  }
301
- }, [getFocusTargets, setTabIndex]);
301
+ // A cell that stopped being focusable (an aria-disabled day, for example,
302
+ // which stays reachable by focus) can still hold the stop from earlier
303
+ // navigation; it never stays tabbable, so the grid keeps one Tab stop.
304
+ for (const cell of getCells()) {
305
+ const el = cellFocusable(cell) ? null : resolveFocusTarget(cell);
306
+ if (el != null) {
307
+ setTabIndex(el, -1);
308
+ }
309
+ }
310
+ }, [
311
+ getFocusTargets,
312
+ getCells,
313
+ cellFocusable,
314
+ resolveFocusTarget,
315
+ setTabIndex,
316
+ ]);
302
317
 
303
318
  // Keep the tab stop valid across renders (cells added/removed, focusability
304
319
  // toggled). Runs after every commit but only when roving tabindex is on.