Add aria-actions interactive demo#151
Conversation
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>
…k otherwise" This reverts commit a56eafa.
…ck otherwise" This reverts commit 33534ef.
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
left a comment
There was a problem hiding this comment.
I love the examples! Some super minor nits, overall LGTM
| ================================================================ */ | ||
| var ACTIONS = { | ||
| close: { attr: "data-closed", on: "Closed", off: "Reopened" }, | ||
| pin: { attr: "data-pinned", on: "Pinned", off: "Unpinned" }, |
There was a problem hiding this comment.
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.
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>
| grid.querySelectorAll('tbody > tr')); | ||
|
|
||
| function rowButtons(row) { | ||
| return Array.prototype.slice.call(row.querySelectorAll("button")); |
There was a problem hiding this comment.
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!)
Single-page demo illustrating the proposed
aria-actionsARIA 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
AriaActionsBlink runtime feature flag (chrome://flags/#enable-experimental-web-platform-featuresor--enable-blink-features=AriaActions).See: https://janewman.github.io/Demos/aria-actions/ for a live rendering.