Skip to content

Add aria-actions interactive demo#151

Open
janewman wants to merge 22 commits into
MicrosoftEdge:mainfrom
janewman:user/janewman/aria-actions-demo
Open

Add aria-actions interactive demo#151
janewman wants to merge 22 commits into
MicrosoftEdge:mainfrom
janewman:user/janewman/aria-actions-demo

Conversation

@janewman

@janewman janewman commented Jun 16, 2026

Copy link
Copy Markdown
Member

Single-page demo illustrating the proposed aria-actions ARIA attribute (w3c/aria PR 1805) across three composite-widget patterns: tabs with per-tab action buttons, listbox with per-option actions, and a data grid with row-level actions.

Requires the AriaActions Blink runtime feature flag (chrome://flags/#enable-experimental-web-platform-features or --enable-blink-features=AriaActions).

See: https://janewman.github.io/Demos/aria-actions/ for a live rendering.

janewman and others added 18 commits June 16, 2026 14:10
Single-page demo illustrating the proposed `aria-actions` ARIA attribute
(w3c/aria PR 1805) across three composite-widget patterns: tabs with
per-tab action buttons, listbox with per-option actions, and a data
grid with row-level actions.

Requires the `AriaActions` Blink runtime feature flag
(`chrome://flags/#enable-experimental-web-platform-features` or
`--enable-blink-features=AriaActions`).

The root README index row for the new demo is intentionally left for
a follow-up commit.
- Replace window.alert() in the delegated data-action click handler with
  a polite live region (#action-toast, role=status aria-live=polite
  aria-atomic=true). Toast is always in the DOM, visible only when
  non-empty, auto-clears after 4s, and honours prefers-reduced-motion
  and forced-colors. No focus is moved.

- Reframe forward-looking prose now that the attribute is shipping behind
  a flag: "a proposed ARIA attribute" -> "a new ARIA attribute"
  (index.html:57, README.md:5); "Spec status … still under review" ->
  "Reference … defines the attribute" (index.html:69-77); "still under
  discussion in PR 1805 … the consensus targets …" -> "defined by
  PR 1805 and targets …" (index.html:352-355). Runtime-flag and
  Firefox/Safari-haven't-shipped wording in the README is left untouched
  because it's still accurate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Verification pass on top of 9b189a2 using axe-core 4.11.4 against
http://localhost:8765/. Fixes the real findings; the only remaining
axe violation is `aria-valid-attr` on the new `aria-actions`
attribute itself, which axe-core does not yet recognize (expected —
the attribute is the subject of the demo).

- code: set explicit color: var(--text). Inline <code> inside
  .notice-list and .demo-section > p was inheriting --text-muted,
  which on --code-bg (#6b7280 on #f3f4f6) is ~4.26:1 — under the
  WCAG 1.4.3 AA 4.5:1 threshold. Switching to --text yields
  >12:1 in both themes. Resolves all 23 color-contrast occurrences
  axe-core reported.

- .action-toast: switch background from --accent (#60a5fa in dark
  mode) to --text and color to --bg-card. The previous pairing was
  ~2.52:1 in dark mode (axe runs in light by default and missed
  this; computed manually). The inverted-surface pairing is ~14:1
  light / ~13:1 dark.

- announceAction(): clear textContent before setting, with a 30ms
  setTimeout, so a click on the same action button twice in a row
  re-announces. Several screen readers ignore live-region updates
  whose textContent is unchanged from the prior value.

Manual checks (also clean):
- Heading order h1 -> h2s preserved; no new headings.
- Toast has no focusable children, pointer-events: none — focus is
  never moved into it.
- forced-colors: active still maps toast surfaces to system colors.
- prefers-reduced-motion: reduce already disables the fade via the
  global * transition-duration override; the toast-scoped rule
  remains as belt-and-suspenders.

Not run on this machine: VoiceOver/NVDA SR smoke. Axe-core covers
~20-50% of WCAG; manual SR + keyboard pass before pushing is still
recommended (per the original task spec).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The original demo only "fake-fired" actions (alert -> toast). That trains
the wrong mental model: aria-actions exists so AT users can invoke things
that *do something* to the focused item, mirroring real-world patterns
like VS Code tabs and Teams participants (see Sarah Higley's explainer
gist, and the APG tabs-actions canonical example which has no toast at
all because the action's effect IS the feedback).

This change deletes the floating #action-toast and rewires each action
button to toggle a real, reversible visible state on its target item.
Per-section polite live regions announce the change, and per-section
Reset buttons restore starting state so the demo stays replayable.

Visible effects:
- Tabs: Close strikes through the tab and appends " (closed)";
  Pin prefixes the tab name with 📌.
- Listbox: Edit italicises and appends " (edited)"; Delete strikes
  through and appends " (deleted)".
- Grid: View highlights the row (--accent-light bg); Archive dims
  the row and rewrites the Status cell text to "Archived" (the
  original status is cached on a data-* attr so toggle/Reset can
  restore it — CSS-only swap wouldn't reach the a11y tree).
- All effects toggle on repeat-click; Reset clears everything.

Live-region strategy:
- Each .demo-section has its own .action-status (role=status, aria-live
  =polite, aria-atomic=true) co-located with the widget, so AT users
  hear the announce in context rather than from a global overlay, and
  sighted users see "Last action: …" where they just clicked.
- announceInto() clears msgEl.textContent then sets after 30ms so
  repeat-click on the same action re-fires the announce (ATs commonly
  drop unchanged textContent updates).
- Reset routes through the same announce path with "Section reset".

a11y suffixes (" (closed)", " (edited)", " (deleted)") and the Pin 📌
prefix use CSS ::before/::after content — included in the computed
accessible name in all major engines (Chromium / WebKit / Gecko per
accname-1.2 §2.F.iii), so AT users navigating to an item after an
action was invoked still discover its state, not just at click-time.
Suffix colour is var(--text) (not --text-muted) to keep WCAG 1.4.3 AA
contrast across both themes against every container background the
items can sit in.

Visual treatment in forced-colors:
- closed tab / deleted option fall back to GrayText (line-through
  survives HCM as a text-decoration property).
- viewing row uses Highlight/HighlightText with forced-color-adjust:
  none so the highlight remains visible.
- archived row resets opacity (HCM ignores it anyway) and switches to
  GrayText.

Verification on this branch:
- All 23 puppeteer-driven functional checks pass (toggle state, status
  text per section, status-cell text swap on archive, Reset clears
  state + announces "Section reset", repeat-click re-announces).
- axe-core 4.11.4 (wcag2a/aa, wcag21a/aa, best-practice) reports only
  the 9 expected aria-valid-attr false positives on the new
  `aria-actions` attribute (axe doesn't yet know about it; that's the
  whole subject of the demo). 0 color-contrast, 0 other violations.
- Visual screenshots in both prefers-color-scheme: light and dark
  render the state cues correctly.

README updated with a "What the action buttons do" paragraph so
reviewers know what to expect when they click around.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Re-open

Layout change: each tab's Close/Pin buttons now sit immediately to the
right of the tab they target, inside a per-tab .tab-group wrapper inside
the tablist. Matches the APG tabs-actions canonical example's
.tab-wrapper pattern. The buttons remain SIBLINGS of the role="tab"
button (not children), so Children Presentational on role="tab" is
preserved. The wrapper carries role="none" so it doesn't introduce a
spurious generic into the tablist's child roles.

Renaming: the Close button toggles its visible text and aria-label
between "Close" / "Close <tab>" and "Re-open" / "Re-open <tab>" as
data-closed flips on the tab. Pin/Edit/Delete/View/Archive keep static
labels — Close is the only action where the next-click verb genuinely
differs from the action verb. Reset section restores all Close buttons
to "Close" along with the rest of the section state.

Notes:
- A new HTML comment + notice-list bullet document the axe-core
  aria-required-children warning this pattern triggers (the tablist
  has button descendants that aren't role="tab"). aria-actions is
  precisely what makes the co-location safe; the APG demo triggers
  the same warning. Treated as a known axe-vs-spec tension, not a
  defect. axe still reports 9 expected aria-valid-attr FPs on
  aria-actions itself plus this 1 aria-required-children = 10 total.
- Keyboard navigation unchanged: the tablist's keydown handler uses
  querySelectorAll('[role="tab"]') so ArrowLeft/Right still cycle
  between tabs only (not action buttons). Verified by puppeteer
  test "ArrowRight from tab-1 focuses tab-2 (not close-tab-1)".
- CSS: .tab-group is inline-flex (align-items: center) holding the
  tab + .tab-actions-inline cluster. Action buttons are sized down
  (padding 0.2rem 0.55rem, font 0.8rem) so a tab + 2 actions stays
  vertically balanced. Selected-tab underline still sits on the
  tablist's border via margin-bottom: -2px on .tab.
- The old single .tab-actions row CSS was removed; its rules are
  replaced by .tab-actions-inline.

Verification:
- 16/16 puppeteer-core functional checks (test2.mjs) pass:
  - Initial Close label + aria-label.
  - After-close → button textContent "Re-open" + aria-label
    "Re-open index.html"; tab gets data-closed.
  - After-re-open → reverts cleanly.
  - Pin does NOT rename (only Close does).
  - Reset restores "Close" label even when currently "Re-open".
  - close-tab-1 is in same .tab-group as tab-1, inside the tablist,
    but NOT inside [role="tab"].
  - ArrowRight from tab-1 focuses tab-2 (not close-tab-1).
- axe-core 4.11.4: 10 violations (9 aria-valid-attr on aria-actions
  + 1 aria-required-children on .tablist — both expected and
  documented inline).
- Visual screenshots in light + dark mode confirm the
  [index.html] [Close] [Pin] [style.css] [Close] [Pin] [app.js] [Close] [Pin]
  inline layout and that the selected-tab underline still renders
  under the tab label (not the whole group).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Each tab's Close and Pin buttons are now small square icon buttons
(~26px). The Close button shows a stroke-based "X"; the Pin button
shows an Octicon-derived pushpin (MIT). When Close toggles a tab to
data-closed, the icon swaps to a Lucide-style rotate-ccw arrow (the
"re-open" / undo state) and the aria-label + title flip to
"Re-open <tab>". Pin keeps a single icon both ways — pinned state is
already conveyed by the 📌 prefix on the tab itself, and another
click just unpins.

Accessibility:
- aria-label remains the source of truth for screen readers. Both
  Close and Pin carry "Close <tab>" / "Pin <tab>" at all times; the
  Close button's label flips to "Re-open <tab>" when the tab is
  data-closed. The SVGs themselves have aria-hidden="true"
  focusable="false" so they don't leak into the a-name.
- title attribute mirrors aria-label so sighted hover gives the same
  text the SR users hear, useful now that the visible label is gone.
- SVG paths use stroke="currentColor" / fill="currentColor" so dark
  theme and forced-colors modes propagate without extra rules.
- Keyboard semantics, role exposure, and ARIA wiring are unchanged.
  The tablist's keydown handler still queries '[role="tab"]', so
  arrow-key navigation still cycles between tabs (not action
  buttons) — verified.

CSS:
- New .icon-btn class on the action buttons in .tab-actions-inline:
  1.6rem square, padding 0, inline-flex center, default border
  matches the existing button look. Replaces the older padded text
  button rules.
- .icon child is sized 0.95rem (Close + Pin, 16-unit viewBox) and
  bumped to 1.05rem for [data-icon="reopen"] which uses a 24-unit
  viewBox for the rotate-ccw glyph (so the rendered weight stays
  visually consistent).

JS:
- performAction() swaps btn.innerHTML between CLOSE_SVG and
  REOPEN_SVG and updates data-icon + aria-label + title atomically
  when action === "close". Pin/Edit/Delete/View/Archive unchanged.
- resetSection() restores Close buttons to CLOSE_SVG along with the
  rest of the section state.

Verification:
- 25/25 puppeteer-core checks (test2.mjs) pass — initial state
  (data-icon, aria-label, title, exactly 1 svg.icon descendant per
  button), close toggle (data-icon=reopen, aria-label=Re-open <tab>,
  title matches, SVG viewBox switches to 24x24), re-open toggle
  reverts the SVG to 16x16, Pin does NOT swap its icon, Reset
  restores everything, and ArrowRight from tab-1 still focuses
  tab-2 (not close-tab-1).
- axe-core 4.11.4 still reports the documented 10 violations
  (9 aria-valid-attr on aria-actions + 1 aria-required-children
  on the co-located tablist pattern); no new violations.
- Light and dark screenshots verify each tab shows
  [name] [X] [pushpin], and the closed tab shows
  [strikethrough name (closed)] [rotate-ccw] [pushpin].

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In some browser/cache states the .icon { width: 0.95rem; height: 0.95rem }
CSS rule on inline SVG wasn't taking effect, leaving zero-size SVG
boxes inside the icon buttons (visually empty buttons).

Adding explicit width="14" height="14" to the 16-unit-viewBox icons
(Close, Pin) and width="16" height="16" to the 24-unit-viewBox icon
(Re-open) makes the SVGs render even before/without the CSS rule.
The CSS rule still wins for further size tuning when it applies.

Both the static markup in the tab action buttons and the JS
constants CLOSE_SVG / REOPEN_SVG carry the attributes.

Verified visually on Jacques's browser: hard refresh reveals the
icons; previously empty buttons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adopt the experimental `focusgroup` attribute (Open UI scoped-
focusgroup variant) for declarative arrow-key navigation in the
tablist and listbox widgets. When the browser doesn't support
focusgroup, the existing JS roving-tabindex + arrow-key handlers
remain as a fallback. Mirrors the pattern already used by the
sibling `/focusgroup/` demos in this repo.

What's added:
- Tablist: `focusgroup="tablist nomemory"` on the [role="tablist"]
  div. The `tablist` token implies `inline wrap` by default, which
  matches the demo's existing Left/Right + wrap-around behaviour.
- Each `.tab-actions-inline` span carries `focusgroup="none"` so
  the Close/Pin/Re-open buttons stay reachable via Tab but are
  excluded from the tablist's arrow-key scope.
- Listbox: `focusgroup="listbox nomemory"` on the [role="listbox"]
  <ul>. The `listbox` token defaults to `block` (Up/Down). The
  side-panel action buttons are already siblings of the listbox,
  so no `focusgroup="none"` is needed for them.
- `focusgroupstart` marks the initially active tab + option.

Feature detection:
- Single sentinel at the top of <script>:
    var FOCUSGROUP_SUPPORTED = "focusgroup" in HTMLElement.prototype;
  This is the same probe shared.js in /focusgroup/ uses.
- On selection, both `activate()` (tabs) and `select()` (listbox)
  now:
    * Move the `focusgroupstart` attribute to the newly active item
      so re-entry via Tab lands on the active item, not on the
      last-arrowed-to one (focusgroup="… nomemory").
    * When focusgroup IS supported, set tabindex=0 on every item
      (focusgroup collapses them into a single sequential tab stop).
    * When focusgroup is unsupported, keep the roving tabindex=0/-1
      pattern.
- `focusin` listener added to each container so selection-follows-
  focus also works when focusgroup itself moves focus (the JS
  `keydown` handler never sees those arrow keys; focusgroup
  intercepts them).

Arrow-key gating:
- In the keydown handler, ArrowLeft/Right (tabs) and ArrowUp/Down
  (listbox) early-return when FOCUSGROUP_SUPPORTED — focusgroup
  has already moved focus by the time the event reaches us, so
  doing it again would double-step.
- Home/End and the tablist's Delete key shortcut are handled in JS
  in both modes (focusgroup does not bind them, per the
  /focusgroup/tablist.html and /focusgroup/listbox.html notice
  lists).

Grid:
- Unchanged. There is no canonical focusgroup behavior token for
  the row-only navigation pattern this grid uses (no `grid` token
  in the existing /focusgroup/ demos), so the grid keeps its
  pure-JS arrow-key handler.

Notice-list and README updated to mention the focusgroup adoption
and fallback path.

Verification:
- New test-fg.mjs runs every keyboard-nav check twice: once with
  Chrome launched plain (FOCUSGROUP_SUPPORTED=false → JS fallback)
  and once with --enable-experimental-web-platform-features
  (FOCUSGROUP_SUPPORTED=true → native focusgroup). All 12 checks
  pass in both runs: ArrowRight tab-1→tab-2→tab-3→tab-1 (with
  wrap), ArrowRight skips action buttons, selection-follows-focus
  updates aria-selected, Shift+Tab re-entry lands on the selected
  option, Home/End still navigate, Delete on a tab still fires
  Close.
- Existing test2.mjs (25/25 icon-button + layout checks) still
  passes.
- axe-core unchanged at the documented 10 expected violations.
- Light + dark screenshots show no visual regression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Builds on the just-re-applied focusgroup commit by:
- Dynamically loading the Microsoft focusgroup polyfill
  (@microsoft/focusgroup-polyfill, MIT) from jsDelivr when the
  browser doesn't ship `focusgroup` natively. The polyfill provides
  the same arrow-key navigation as native focusgroup, including
  for the tablist + listbox in this demo.
- Showing a small status pill in the page header reporting which
  arrow-key navigation path is currently active for the tablist +
  listbox: "focusgroup: native" / "focusgroup: polyfill" /
  "focusgroup: JS fallback" (with corresponding accent / accent /
  warning tone). Visible in both light and dark theme; HCM mapping
  to Canvas/CanvasText/Highlight.

Mode lifecycle (focusgroupMode):
  - native:   browser supports focusgroup; no polyfill load attempted
  - loading:  polyfill import in flight (initial state when !native)
  - polyfill: polyfill loaded + `polyfill()` succeeded
  - fallback: polyfill import or invocation failed (offline / CDN
              blocked / load error); JS handles arrow keys

Implementation details:
- focusgroupActive() returns true for native OR polyfill modes; the
  per-widget IIFEs use it to early-return from arrow-key handlers
  and (with focusgroupManagesTabindex(), which mirrors active mode)
  to set tabindex=0 on every item so focusgroup/polyfill sees them
  as keyboard-focusable candidates (the polyfill skips items with
  tabindex=-1 during item discovery, so leaving roving markup in
  place hid all but the selected item from it).
- onFocusgroupReady() registers callbacks that run after the
  polyfill transitions from loading→polyfill (or →fallback). Each
  per-widget IIFE registers a hook that re-applies the tabindex
  regime appropriate for the final mode.
- loadPolyfill() waits for BOTH the dynamic `import()` AND
  DOMContentLoaded before calling `mod.polyfill()` (no args = whole
  document). Passing `document` as an arg silently no-ops because
  the polyfill API expects an Element subtree or no arg.
- Polyfill URL pinned to major version 1 on jsDelivr:
  https://cdn.jsdelivr.net/npm/@microsoft/focusgroup-polyfill@1/build/index.min.mjs
  (the npm registry's `module` field is `./build/index.mjs`; the
  matching minified bundle is `./build/index.min.mjs`).

Verification:
- New test-status.mjs run in two browser configurations:
  * Plain Chrome 149 → "focusgroup: polyfill" + 4/4 arrow-nav checks.
  * Chrome 149 with --enable-experimental-web-platform-features →
    "focusgroup: native" + 4/4 arrow-nav checks.
- New test-offline.mjs blocks jsdelivr requests via
  page.setRequestInterception; status correctly reports "focusgroup:
  JS fallback" and the JS arrow-key handler still navigates tab-1 →
  tab-2.
- Existing test-fg.mjs (12/12 per run) and test2.mjs (25/25 icon
  + layout checks) both pass.
- axe-core unchanged at the documented 10 expected violations
  (9 aria-valid-attr false positives on aria-actions itself +
  1 aria-required-children on the co-located tablist pattern).
- Light + dark screenshots show the new pill rendering in the
  page header with appropriate theme contrast.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…cusGroup

Replace the inline `"focusgroup" in HTMLElement.prototype ||
"focusGroup" in HTMLElement.prototype` probe with a call to the
polyfill's exported `supportsFocusGroup()`. The polyfill is the
authoritative source of truth for native-support detection — using
its own probe means we can't drift out of sync with future spec
evolution (e.g. an IDL rename or a new short-circuit condition the
polyfill adds).

Flow change:
- Always fetch the polyfill module on page load (~3 KB gzipped from
  jsDelivr).
- After both the import and DOMContentLoaded resolve, branch on
  `mod.supportsFocusGroup()`:
    * true  → focusgroupMode = "native"  (mod.polyfill() would
              itself no-op here, per the polyfill's own guard)
    * false → mod.polyfill() then focusgroupMode = "polyfill"
- On import failure or invocation throw, focusgroupMode =
  "fallback" and our JS roving-tabindex + arrow-key handlers stay
  in charge.

Trade-off: even native-supporting browsers (Chromium 151+) now
download the polyfill module to learn that they don't need it.
That's the cost of delegating detection. The polyfill is tiny and
the fetch is async + lazy, so the JS roving fallback is
operational the whole time the module is loading.

Side fix: removed the special-case in onFocusgroupReady that ran
hooks synchronously when the mode was already "native" at script
parse — there's no longer a parse-time "native" detection, so the
hook always defers to the post-load runFocusgroupReadyHooks() call.

Verification:
- test-status.mjs: both browser configurations produce the right
  status pill and 4/4 arrow-nav results.
    PLAIN Chrome → "focusgroup: polyfill" (no native, polyfill used)
    Chrome --enable-experimental-web-platform-features →
                  "focusgroup: native" (detected via the polyfill's
                  own supportsFocusGroup)
- test-offline.mjs: jsdelivr blocked → "focusgroup: JS fallback"
  and arrow keys still work via the JS roving handler.
- test2.mjs: 25/25 icon-button + layout checks still pass.
- axe-core unchanged at 10 expected violations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e comments

Two findings from a self-review of the focusgroup commits.

1. Race: focusgroupstart stale after a loading-window click.

   While the polyfill module is in flight (focusgroupMode ===
   "loading"), focusgroupActive() returns false, so activate() and
   select() take the JS-fallback branch and only update tabindex +
   aria-selected. focusgroupstart stays on the initial item (tab-1
   or opt-1). If the user clicks a non-default tab/option during
   the loading window, the polyfill then activates with the stale
   focusgroupstart attached to the wrong item.

   With `nomemory`, the focusgroup uses focusgroupstart (and only
   focusgroupstart) to pick the Tab re-entry point. So Tab-out then
   Tab-back lands on tab-1 even though tab-2 was clicked and is
   currently aria-selected="true".

   Fix: the onFocusgroupReady hooks (tabs + listbox) now ALSO
   re-sync focusgroupstart based on aria-selected, in addition to
   setting tabindex=0. Verified by test-race.mjs:
   - Throttles the polyfill response by 800ms via
     page.setRequestInterception so the loading window is real.
   - Clicks tab-2 during the window, confirms aria-selected=true on
     tab-2 but focusgroupstart still on tab-1.
   - Waits for polyfill to load, confirms focusgroupstart now on
     tab-2 (and tabindex=0 on all tabs).
   - Tabs forward into the page and confirms re-entry lands on
     tab-2, not tab-1.
   Without the fix, re-entry would have landed on tab-1.

2. Stale comments: three inline comments at the bottom of
   activate(), the tabs onFocusgroupReady, and the listbox
   onFocusgroupReady claimed "only set tabindex=0 in native mode,
   the polyfill manages tabindex itself and would otherwise fight
   us". The code in all three places actually sets tabindex=0 in
   both native AND polyfill modes (which is correct — the polyfill
   rejects items with tabindex<=-1 during item discovery, so the
   roving markup would hide all but the selected item). The top-
   level comment block already had the right explanation; the
   inline comments were leftover from an earlier (incorrect) draft.
   Updated all three to match the actual behaviour.

Also: removed a stray aria-actions/index.html.bak left over from an
earlier sed -i.bak run; no .gitignore entry covered it, so a
reflexive `git add -A` could have committed the 37 KB backup.

Verification re-run after the changes:
- test-status.mjs:    polyfill 4/4, native 4/4
- test-offline.mjs:   JS fallback 1/1
- test-fg.mjs:        fallback 12/12, native 12/12
- test2.mjs:          25/25 icon-button + layout checks
- test-race.mjs:      PASS focusgroupstart re-synced after polyfill load
- axe-core 4.11.4:    still at 10 expected violations

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Brings the third (and last) linear-focus widget under focusgroup
along with the tablist and listbox.

HTML:
- tbody: focusgroup="toolbar block nomemory nowrap"
    * toolbar token doesn't imply a role, so the explicit
      role="grid" on the <table> and role="row" on each <tr> stay
      authoritative.
    * block axis: Up/Down only (matches the demo's current
      keyboard model).
    * nomemory: re-entry uses focusgroupstart (which the JS
      synchronises to whichever row currently has focus) instead
      of the polyfill's last-focused memory.
    * nowrap: explicit, matches the JS-fallback Math.min/max
      behaviour. Without this, the polyfill's default for the
      toolbar token (unspecified in the spec, demo-verified as
      no-wrap) might change in future polyfill versions.
- First row: focusgroupstart attached.
- Each .row-actions span: focusgroup="none" so the in-row
  View/Archive buttons stay reachable by Tab from the focused
  row but are excluded from row-level arrow navigation. Mirrors
  the .tab-actions-inline pattern.

JS (grid IIFE):
- focusRow() and the focusin handler branch on focusgroupActive():
  when active, leave row tabindex alone (browser/polyfill manages
  it) and only move focusgroupstart; when not active, do the
  existing roving-tabindex dance.
- syncButtonTabindex() runs in every mode — the per-row Tab
  routing of action buttons is the JS's job regardless of
  focusgroup (focusgroup="none" excludes them from arrow nav
  but doesn't manage their tabindex).
- Arrow keys (Up/Down) early-return when focusgroupActive(); the
  browser or polyfill has already moved focus.
- Home/End and Escape (from in-row button -> row) are handled in
  JS in every mode — focusgroup doesn't bind those keys.
- New applyFocusgroupRowState() helper called at init time
  (when focusgroup is already active) and inside the
  onFocusgroupReady hook (when the polyfill resolves into
  "polyfill" mode). Sets tabindex=0 on every row and pins
  focusgroupstart to whichever row currently has the active
  tabindex=0 from the initial markup.

Notice list and README updated. The README's previous
"grid is JS-only because there is no canonical focusgroup token"
line is replaced with the actual setup.

Verification:
- New test-grid.mjs runs 9 checks across PLAIN (polyfill),
  NATIVE (--enable-experimental-web-platform-features), and
  OFFLINE (jsdelivr blocked) browser configurations:
    ArrowDown row-1->row-2->row-3, no wrap at row-3,
    ArrowUp row-3->row-2, Home -> row-1, End -> row-3,
    Tab from row-2 -> view-row-2, Escape view-row-2 -> row-2,
    click archive-row-2 fires action.
  9/9 pass in every mode.
- All existing suites still pass:
    test-status.mjs (polyfill+native): 4+4
    test-offline.mjs: 1
    test-fg.mjs (fallback+native): 12+12
    test-race.mjs (loading-window race): PASS
    test2.mjs (icon-button + layout): 25
- axe-core unchanged at 10 expected violations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per user feedback: the listbox example had broken Tab ordering — with
its options collapsed to a single focusgroup tab stop but the side
panel's six per-option Edit/Delete buttons all individually
sequential, the Tab sequence through the section was unwieldy and
didn't match what AT users would expect.

Rather than redesign the listbox to fix it, removing the entire
section. The tablist and grid still cover the aria-actions story:
- tabs demonstrate the inline (children-presentational) sibling-
  action pattern with co-located buttons;
- the grid demonstrates the row-level aria-actions pattern with
  in-row buttons.

What's removed:
- §3 Listbox with Actions HTML section (the <ul role="listbox">, its
  three <li role="option"> items, the .bookmark-actions side panel,
  the per-option Edit/Delete buttons, the section's status row and
  Reset button, and the section's notice list).
- The in-page nav link to #listbox-with-actions.
- The listbox JS IIFE (selection, roving tabindex, arrow/Home/End,
  Escape-from-panel handler).
- The `edit` and `delete` entries from the ACTIONS table — they were
  only used by the listbox options, so removing them simplifies the
  table to the four actions actually demonstrated (close, pin, view,
  archive).
- All listbox-only CSS: .listbox-with-actions, .listbox,
  .listbox-option (+ :hover, :last-child, [aria-selected]),
  .bookmark-actions (+ .action-row, button), and the
  data-edited/data-deleted state visuals + forced-colors mapping.

What's updated:
- README: "The demo covers three composite-widget patterns" -> two
  patterns. Removed the listbox mention from the keyboard-nav
  paragraph and the "What the action buttons do" paragraph.
- Notice list bullet for the tabs section: "listbox and grid"
  -> "grid".
- Comment at the top of the tab-group docblock: same fix.
- Status-pill comment in style.css: "tablist + listbox"
  -> "tablist + grid".
- <noscript> banner: "tab, listbox, and grid widgets"
  -> "tab and grid widgets".

What stays:
- The notice-list bullet that mentions "tabs, options, rows,
  gridcells, treeitems" as roles that can carry aria-actions is
  spec-derived doc, not a reference to our removed listbox. Left
  intact.

Verification (new consolidated test-final.mjs across all three
focusgroup modes — PLAIN/polyfill, NATIVE, OFFLINE/JS-fallback):
- 12/12 pass per mode.
- Includes a guard that the listbox section, its anchor, and the
  in-page nav link are all gone.
- Existing tab+grid behaviours (arrow nav, wrap, Delete-as-close,
  archive cell-text swap, in-row Tab to View button) all still
  pass.
- axe-core down from 10 -> 7 expected violations (the 3 listbox
  options no longer carry the aria-actions attribute that axe
  flags as unknown).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The grid's "View" buttons now open a per-row native HTML popover
showing the order's details (customer, status, ordered date, item
count, total). Replaces the previous data-viewing row-highlight,
which on its own didn't actually demonstrate "view".

HTML:
- Each View button gains popovertarget="popover-row-N" with
  popovertargetaction="show" (not the default "toggle"), so
  re-clicking View while the popover is open is a no-op rather
  than silently closing it. That keeps the polite-status announce
  ("Viewing details for: order …") in sync with the popover's
  actual state.
- Three new <div popover role="dialog"> elements after </table>
  (siblings of the <table> because <div> is not a valid child of
  <tbody> or <td>). role="dialog" gives AT users a clean
  "dialog Order #N" announce on open; the accessible name comes
  from aria-labelledby on the header h3.
- Each popover has a close button with popovertargetaction="hide"
  pointing back at its own popover — no JS needed for the
  close mechanics.

JS:
- ACTIONS.view changed from a toggling { attr: "data-viewing", … }
  entry to a non-toggling { attr: null, on: "Viewing details for" }.
- performAction() early-returns for cfg.attr === null after
  announcing — non-toggling actions skip the data-* flip path.
- resetSection() skips falsy attr in the toggle-clearing loop, and
  closes any open popover in the section via :popover-open
  + hidePopover().
- Grid Esc handler now defers to the popover's close-watcher when
  any popover in the section is open. Without this, pressing Esc
  with focus on the View button (the popover invoker) would have
  preventDefault'd the Esc keydown, suppressing the close-watcher
  per the popover spec and stranding the popover open.

CSS:
- Removed .data-grid tbody tr[data-viewing] rule + its HCM mapping.
  View no longer toggles row state.
- New .row-details-popover block: panel itself (width clamped via
  min(90vw, 22rem), margin auto for top-layer centering, themed
  bg/text/border), ::backdrop (rgba dim), .popover-header (flex
  row with h3 + close button), .popover-close (basic styling),
  .popover-body (CSS grid for <dt>/<dd> pairs), HCM mapping
  (Canvas/CanvasText/border-color: CanvasText).

Notice list updated with a bullet describing the View → popover
behaviour. Comment block above the popovers documents the design
choices (sibling-of-table placement, role="dialog", show-not-toggle
action, browser-handled mechanics).

Verification:
- New test-popover.mjs runs 12 checks (all pass): view opens
  popover, announce fires, re-click stays open (no toggle), Esc
  closes cleanly (popover spec close-watcher not stranded), Esc
  returns focus to View button, Esc from non-View action still
  routes to row (existing behavior preserved), Reset closes any
  open popover, role=dialog + aria-labelledby resolve, click-
  outside light-dismiss works.
- test-final.mjs (12/12) across all three focusgroup modes
  (PLAIN/polyfill, NATIVE, OFFLINE/JS-fallback) still passes.
- axe-core unchanged at 7 expected violations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two related polish fixes from user testing of the popover:

1. The "×" glyph was not centered within its 1.75rem square close
   button. The button had no flex/grid alignment, so the glyph sat
   at the baseline-default position (top-left-ish for a multiplication
   sign). Fix: display: inline-flex + align-items: center +
   justify-content: center on .popover-close, and bump font-size
   from 1.25rem to 1.5rem so the X is visually proportionate to the
   button square.

2. Opening the popover left focus on the View button (the invoker),
   per the popover spec for popovers with no autofocus descendant.
   Screen reader users had no automatic indication that the popover
   had appeared beyond the polite-status announce, and sighted
   keyboard users had to manually Tab into the popover. Adding
   `autofocus` to each .popover-close button moves focus into the
   popover on open so AT immediately announces the dialog's
   accessible name + the focused close control. Esc still closes
   the popover and returns focus to the View invoker (browser-
   handled via the popover spec).

Verification:
- New test-autofocus.mjs (6/6) confirms focus lands on
  .popover-close inside the right popover for all three View
  buttons, and Esc returns focus to the matching view-row-N
  invoker.
- test-popover.mjs still 12/12; tests previously relied on focus
  staying on View after open, but Esc handling routes through the
  spec's close-watcher in both cases so the existing checks still
  pass with focus inside the popover.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mirrors the existing Close ↔ Re-open self-renaming behaviour on tab
action buttons. When a row is in archived state, its action button
reads "Unarchive" with aria-label "Unarchive order <N>"; when not
archived, it reads "Archive". The next-click verb matches what the
button is about to do.

Archive uses textContent + aria-label only — no SVG icon swap (it's
a text button, unlike the tab Close icon button) and no title (the
text is already visible to sighted users; a title would create a
redundant hover tooltip). Reset section restores all archive
buttons to "Archive" along with the rest of the section state.

Verification:
- New test-archive-rename.mjs (13/13): initial state, click flips
  text + aria-label to Unarchive, click again reverts to Archive,
  status-cell text toggles correctly alongside, Reset restores
  even when currently in Unarchive state.
- Existing test-popover.mjs (12/12) and test-final.mjs (12/12 per
  mode) still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@smhigley smhigley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I love the examples! Some super minor nits, overall LGTM

Comment thread aria-actions/index.html Outdated
Comment thread aria-actions/index.html Outdated
Comment thread aria-actions/index.html
================================================================ */
var ACTIONS = {
close: { attr: "data-closed", on: "Closed", off: "Reopened" },
pin: { attr: "data-pinned", on: "Pinned", off: "Unpinned" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

so for pin, rather than announcing something, add an aria-pressed on there -- that'd be the more canonical way to code it.

I'm not sure how that interacts with aria-actions, but that is the best practice for that sort of action button & it'd probably be worth having that as an example to play with.

Comment thread aria-actions/index.html
Comment thread aria-actions/index.html Outdated
janewman and others added 4 commits June 24, 2026 11:30
Per @smhigley review on PR MicrosoftEdge#151: with role="grid" on the <table>,
the implicit roles on <tr>/<th>/<td> already produce row/columnheader/
gridcell in the accessibility tree, so the explicit role attributes
are noise. Drop them and refresh the surrounding comment + the
prose bullet that referenced "role=row" on the rows.

Update three JS selectors that targeted the explicit role attribute:

  tbody [role=row]      -> tbody > tr
  closest([role=row])   -> closest(tr)
  getAttribute(role)    -> rows.indexOf(current) check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per @smhigley review on PR MicrosoftEdge#151: combining title and aria-label
makes the accessible name and description the same string, which
some screen readers narrate twice. Browser tooltips are also
inaccessible (keyboard- and AT-unfriendly). Keep aria-label as
the single source of the accessible name.

Removed:
- title="..." on all six Close/Pin icon buttons in the tablist.
- btn.setAttribute("title", ...) in performAction's close branch
  (the aria-label still swaps Close <-> Re-open).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per @smhigley review on PR MicrosoftEdge#151: aria-pressed is the canonical way
to code a toggle action button — it gives AT users persistent state
("pressed" / "not pressed") that an announce alone can't.

Changes:
- All three #pin-tab-N buttons get aria-pressed="false" in markup.
- performAction's new pin branch flips aria-pressed and swaps the
  accessible name between Pin and Unpin (matching the Close <-> Re-open
  and Archive <-> Unarchive precedent in the same demo). Polite-status
  announce is preserved so the demo still illustrates the announce
  half of the aria-actions story.
- resetSection now restores aria-pressed=false + the Pin label.
- Refreshed the inline source comment on self-renaming actions to
  describe the dual mechanism (pressed for state + announce for
  narration), and added a bullet to the file-tabs prose noting that
  Pin is a true toggle.
- Drive-by: dropped a stale b.setAttribute("title", lbl) in
  resetSection's close-button reset that the prior commit missed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per @smhigley review on PR MicrosoftEdge#151: the row-details panels were
[popover] + role="dialog" with a dim ::backdrop, but Tab could leak
out of the popover and land on the (visually backdropped) content
behind it — so the panel only LOOKED modal. Convert each panel to a
native <dialog> opened with showModal(), which gives a real focus
trap + inert background for free and matches what the backdrop was
visually promising.

HTML changes:
- View buttons swap popovertarget="popover-row-N" popovertargetaction=
  "show" for aria-haspopup="dialog" + data-dialog-target="...".
  aria-haspopup advertises the dialog to AT; data-dialog-target tells
  the JS which dialog to open.
- The three <div popover role="dialog"> become <dialog>. <dialog>
  has implicit role="dialog", so the explicit role is dropped
  (matches the same review's spirit of stripping redundant roles).
- The close button drops popovertarget*+autofocus in favour of
  data-dialog-close. <dialog>.showModal() autofocuses the first
  focusable, which is the close button — same end result.
- The explanatory comment + the prose bullet are rewritten to
  describe the <dialog>+showModal() semantics.

JS changes:
- performAction's view branch now calls dialog.showModal() on the
  target dialog (via data-dialog-target).
- Added a delegated [data-dialog-close] click handler that calls
  .close() on the closest dialog.
- Added a per-dialog click handler that closes on backdrop click
  (e.target === d) — <dialog> doesn't light-dismiss by default, and
  the prior [popover] implementation did, so we restore the UX.
- The keydown-Esc guard that defers to the popover when one is open
  in the section now also recognises <dialog open>.
- resetSection now closes any open <dialog> in the section in
  addition to any open [popover].

CSS: the comment block above .row-details-popover is updated to
describe the <dialog> + showModal() mechanism; selectors and
declarations are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@janewman janewman marked this pull request as ready for review June 24, 2026 19:10
Comment thread aria-actions/index.html
grid.querySelectorAll('tbody > tr'));

function rowButtons(row) {
return Array.prototype.slice.call(row.querySelectorAll("button"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm so lazy I always just do [...row.querySelectorAll("button")] nowadays :D. You can directly call .forEach on the NodeList returned from querySelectorAll without converting it to an array, since it looks like the forEach is the only operation it's used for. But also I get wanting to convert it just in case.

(none of this matters, just saw it and thought it was interesting!)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants