LYT-1120: give widget dialogs resolving accessible names and announce form states - #676
Conversation
Addresses defects 1 and 2 of #675. Defect 1: form/slideout, subscription/slideout and subscription/bar were not covered by #673 and still rendered with no pf-widget-container, no role and no accessible name. Wrap them the way the message equivalents are wrapped. Defect 2: the aria-labelledby/aria-describedby added in #673 pointed at ids that only existed as class names, so neither reference resolved. Rather than add more static ids, make the id and reference pairing a single JS step and drop both from the templates. setupWidgetAria hands out ids from a counter so that widgets open at the same time cannot collide - the static ids were already a duplicate id bug on any page showing two dialogs - and only sets a reference when the element it points at will hold text, so an empty headline no longer names a dialog with an empty string. Bar layouts have no headline element at all, so their message names the dialog instead. Ids come from a counter rather than the widget id on purpose: prefixing with config.id made pathfora's own [id*="ab-widget"] selectors match the inner elements, and customer code could do the same. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses defect 3 of #675. A form state is revealed by CSS alone, which is silent, so a screen reader user submitted a lead capture form and heard nothing. Three things were wrong at once: no live region or focus change marked the swap, the dialog's aria references stayed pinned to the original headline and message (still contributing their text through the reference even though the CSS had set them to display: none), and the button the user just activated became display: none, dropping focus to <body>. Dialog layouts now rename the container after the state's own headline and message and take focus, which is what gets it read out. Inline widgets sit in the page's own flow and should not steal focus, so their state is marked role=status and announced politely instead. The modal and gate focus trap recomputed: it captured its set of focusable elements once at open time, so after a state swap Tab called focus() on hidden elements and focus went nowhere. It now filters to elements that are actually rendered, on every tab. Aria reference ids are namespaced under the widget id rather than handed out from a counter. Pathfora already rejects duplicate widget ids, so this is unique, and the form and its states each get their own namespace. Note that a substring match on a widget id - [id*="my-id"] - now also matches the headline and message inside it; the A/B specs did this and are scoped to .pf-widget as a result. Also in this area: - drop transition: opacity from the state classes. Nothing about the swap changes opacity, but declaring a transition on the widget root overrode .slide-transition(), costing slideouts and bars their slide-out animation when the state delay closed them. - scope the showDelay focus call to its own widget. It used a bare document.querySelector('.pf-widget-ok'), which focuses whichever widget comes first in the document and throws outright when the widget was configured with okShow: false. Not covered here: field validation failures are a separate path that returns silently after adding CSS classes, and announcing those needs new user facing copy. Filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
teijas
left a comment
There was a problem hiding this comment.
This lands what #673 didn't: every one of the 15 templates now gets a resolving accessible name, the new spec resolves each aria reference against the whole document with a length-1 assertion so a dangling or duplicated id fails, and the focus-trap recompute plus the scoped showDelay focus are both straight fixes. Suite passes at 300 locally, and the dist/ rebuild is exactly the source change (templates reproduced from prepareTemplates() match; no foreign hunks).
Three things worth a look, none blocking:
-
The inline live region is created at the moment it's revealed.
announceFormStateputsrole="status"on the state div in the same tick the.successclass takes it fromdisplay: noneto rendered, so the accessibility tree sees a live region appear with its text already inside rather than a change inside an existing region. Chromium tends to announce that; Firefox and VoiceOver are known to be unreliable on it. A more robust shape is a persistent, rendered, visually-hiddenrole="status"element built alongside the state divs, into whichannounceFormStatecopies the state's headline and message text (ideally a frame later). That also keeps the Confirm/Cancel button labels out of the atomic read. Not provable in karma, so this is a flag rather than a blocker, but it's the one path where a screen-reader user could still hear nothing. -
A widget id containing whitespace would split the aria reference.
aria-labelledbyis a space-separated IDREF list, soconfig.id = "spring promo"yieldsaria-labelledby="spring promo-pf-widget-headline", which parses as two dangling refs.getElementByIdon the root tolerates spaces, so this is a failure mode only the new references have, and the only validation on ids is truthiness (prepare-widget.js:23). Cheap guard: collapse/\s+/gin the namespace, or fall back to a counter when the id has whitespace. If Experience ids can never contain spaces, ignore this. -
The focus-ring rule is load-bearing, not belt-and-braces. The comment and PR body say browsers wouldn't draw a ring on the programmatically focused container anyway. Per the
:focus-visibleheuristics, when script moves focus while the currently focused element matches:focus-visible(a keyboard user pressing Enter on Confirm), the newly focused element matches too, so without this rule Chrome would drawoutline: autoaround the full-viewport gate/modal container. Keep the rule; fix the comment so nobody removes it later.
Two notes for the description rather than the code: the showDelay fix has no test (okShow: false + showDelay used to throw), and slideouts/bars are still not perceived when they appear, since nothing moves focus or announces on open. That's pre-existing, and this PR makes them correctly named once reached, but it's worth stating so the customer expectation on ZD 22915 is set right.
cthorn-cs
left a comment
There was a problem hiding this comment.
Reviewed the diff and ran the suite locally against this branch (300/300) and against develop — nice work; the ARIA rework is well-reasoned and the tests are genuinely falsifiable (I spot-checked the state-tab test: it fails on develop as expected, confirming it pins the recompute). A few things:
1. Focus trap only handles forward Tab. show-widget.js branches on keyCode !== 9 and never checks ev.shiftKey, and its only correction is "focus first." So Shift+Tab from the first focusable leaks out of the dialog, and from the last it wraps to first instead of previous. This is pre-existing, but since this PR is the focus-trap fix, is reverse-tab intentionally out of scope, or worth handling here?
2. Inline success/error announcement. For inline widgets, announce-form-state.js sets role="status" on a display:none element that's already populated and reveals it in the same tick. A live region generally announces only text injected after it's live, so NVDA/JAWS may say nothing here (VoiceOver is more forgiving). The dialog layouts avoid this by moving focus. Given the PR already notes SR behavior is untested — did you get a chance to try inline on a real SR? An always-present empty region you then inject into is the more reliable pattern.
Minor:
role="dialog"is now on the non-modalbarandslideoutlayouts. Valid ARIA, but for a persistent promo bar a plain region might fit better — was "dialog" the intended semantic for those two?- Scope nit: the "riskiest part" note lists three newly-wrapped templates, but
message/slideoutandmessage/baralso gain apf-widget-container(same risk class — looks fine, just for completeness). Anddisplay-conditions.spec.jsis in the diff but not mentioned. - A dialog configured with neither headline nor message ends up with
role="dialog"and no accessible name — narrow edge, fine to leave, just flagging.
Announce inline form states through a live region that is already rendered and empty when the state text arrives, rather than putting role=status on the state element in the same tick the CSS reveals it - a region that enters the accessibility tree already holding its text is the case screen readers skip. The region takes the state's headline and message only, so the implicit aria-atomic on role=status does not read the Confirm and Cancel labels out with them. Trap Shift+Tab as well as Tab. The handler only corrected forward tabbing, so Shift+Tab from the first focusable element leaked out of the top of the dialog, and from anywhere outside it wrapped to the first element rather than the last. Give bar layouts role=region instead of role=dialog. A persistent promo bar is not something a user opens, acts on and dismisses, and as a named region it lands in the landmarks list instead. Both bars are named by their message, as before - bar layouts have no headline element. Focus a delayed widget's confirm button once the widget is really on screen. The focus call ran as soon as the widget was appended, while it is still visibility: hidden for another 50ms, so nothing in it could take focus and the call was silently doing nothing. Caught by the regression test for the scoping fix in 440e4f0. Rewrite the comment above .pf-widget-container:focus. The rule is load-bearing, not cosmetic: when script moves focus while the element losing it matches :focus-visible - a keyboard user pressing Enter on Confirm - the element receiving it matches too, so without the rule Chrome draws outline: auto around the whole full-viewport gate or modal container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
|
Thanks both — pushed as The inline live region — @teijas (1), @cthorn-cs (2)You were both describing the same shape, and you were right. Rebuilt as you specced it: Also took the point about the atomic read — the region gets the state's headline and message only, never its buttons. The spec now sets @cthorn-cs — no, I have not had it in front of a real screen reader, and the karma tests still cannot prove what NVDA actually says. What they can now prove is that the region is present, rendered and empty before submit and populated after, which is the part that was structurally wrong before. Shift+Tab — @cthorn-cs (1)Pulled in rather than deferred; you are right that it is odd to land the focus-trap fix without it. Both directions are corrected now, with three tests: backward wrap from the first element, backward re-entry from outside the dialog, and the forward wrap still working.
|
Fixes all three defects in #675 (LYT-1120 / ZD 22915, Clorox). Three commits —
545bb25covers defects 1 and 2,
440e4f0covers defect 3, and2e6ed83is the review round.#673closed #670 by adding dialog semantics to themessageslideout and bar. It didnot deliver a working accessible name on any layout it touched, and it did not reach the
formandsubscriptionlayouts at all — which is why Clorox retested v1.2.21, sawtheir "present message" Experiences improve, and their "capture lead" Experiences still
read out as nothing.
Defect 1 — form and subscription layouts had no dialog semantics
src/templates/form/slideout.html,subscription/slideout.htmlandsubscription/bar.htmlrendered with nopf-widget-container, noroleand noaccessible name. Each is now wrapped the way the
messageequivalents already are.This changes the DOM shape of three templates, so it is the riskiest part of the diff.
(@cthorn-cs — checked, and it really is only these three:
message/slideout.htmlandmessage/bar.htmlalready had apf-widget-containerondevelop, so their diff here isjust the removal of the dangling static aria attributes, not a new wrapper.)
Checks done before making it:
.pf-widget-containeris only styled under.pf-widget-modal/.pf-widget-gate, no LESS rule in the slideout or bar paths uses achild selector, and nothing in
src/rollup/**traverses the widget root's directchildren. All three were then rendered in Chrome against the built bundle — layout
unchanged.
Defect 2 — the aria references did not resolve
message/slideout.htmlandmessage/bar.htmlcarriedaria-labelledby="pf-widget-headline", but no element had that id — the stringexisted only as a class name. Both references dangled.
baradditionally pointed at aheadline element that does not exist in that layout.
Rather than scatter more static ids through the templates, the id and reference pairing
is now a single JS step —
describe-widget-container.js, called fromsetup-widget-aria.js— and both the static ids and the template-levelaria-labelledby/aria-describedbyare gone from all 15 templates. The helper:collide. The old static ids were already a duplicate-id bug on any page showing two
dialogs.
headline(the default) no longer names a dialog with an empty string.
barby its message, since that layout has no headline element to point at.Reviewer note. Because ids are namespaced under the widget id, a substring match on a
widget id —
[id*="my-widget"]— now also matches the headline and message inside it.The A/B specs did exactly this and are scoped to
.pf-widget[id*=…]as a result. Anycustomer code doing substring or prefix id matching would see the same extra hits. An
earlier revision used an opaque counter to avoid this; the widget id was chosen instead
because pathfora already rejects duplicate widget ids, and it is far easier to debug.
Defect 3 — form states were not exposed after submit
A form state is revealed by CSS alone (
.pf-widget.successhides the headline, messageand form, then re-shows the ones inside
.success-state), which is silent. Three thingswere wrong at once:
still contribute their text through the reference even though the CSS had set them to
display: none— so the dialog kept reporting "Join our list" while the screen showed"Thanks!";
display: none, dropping focus to<body>.Dialog layouts now rename the container after the state's own headline and message and
take focus, which is what gets it read out and keeps a keyboard user from being dumped at
the top of the page. Inline widgets sit in the page's own flow and should not steal focus,
so they announce politely through a live region instead.
On the live region shape (@teijas, @cthorn-cs). The first revision put
role="status"on the state element itself, in the same tick the
.successclass took it fromdisplay: noneto rendered — so the region entered the accessibility tree with its textalready inside, which is the case NVDA and JAWS skip. It now works the way both of you
described:
construct-state-live-region.jsbuilds a rendered, visually-hidden, emptyrole="status"element alongside the state divs at widget-construction time, andannounceFormStatewrites the state's text into it a tick after the reveal. It is clippedrather than
display: none, which would take the announcement out of the tree with it.The region receives the state's headline and message only, never its buttons —
role="status"carries an implicitaria-atomic, so the earlier shape would have read"Thank You. We have received your submission. Confirm Cancel". The spec asserts the region
is present, empty and rendered before submit, and holds exactly
"Thanks!. We got it."after — withokShow: trueset, so a regression that re-includedthe buttons fails.
A live region was deliberately not used on the dialog layouts: pairing one with a
focus move risks the same text being spoken twice, and focusing a dialog that has a name
and a description has better screen-reader support than relying on a
display:none→visible toggle being noticed.
On
.pf-widget-container:focus { outline: none; }(@teijas). Keeping it, and thecomment above it is rewritten to say why. The original comment claimed browsers would not
draw a ring on a programmatically focused container anyway — which is wrong, and would
have invited someone to delete the rule later. Per the
:focus-visibleheuristics, whenscript moves focus while the element losing it matches
:focus-visible— a keyboarduser pressing Enter on Confirm — the element receiving it matches too, so without this
rule Chrome draws
outline: autoaround the full-viewport gate or modal container. Thecomment now says "load-bearing, not cosmetic. Do not remove."
Focus trap
show-widget.jscaptured its set of focusable elements once, at open time. After a stateswap that set still pointed into the hidden form, so Tab called
.focus()ondisplay: noneelements and focus went nowhere — a keyboard user was stranded. It nowrecomputes on every Tab, filtered to elements that are actually rendered
(
getClientRects().length > 0, which is correct for theposition: fixedmodal contentwhere
offsetParentis not). The handler also read the globaleventinstead of its ownev; fixed while rewriting the same expression.Shift+Tab (@cthorn-cs). Pulled in rather than deferred, since this PR is the
focus-trap fix. The handler branched on
keyCode !== 9and never checkedshiftKey, andits only correction was "focus first" — so Shift+Tab from the first focusable element
leaked out of the top of the dialog, and from outside the dialog it wrapped to the first
element instead of the last. It now corrects both directions, with three tests: backward
wrap from the first element, backward re-entry from outside, and the forward wrap still
working.
Also in this area
barlayouts arerole="region", notrole="dialog"(@cthorn-cs). A persistentpromo bar is not something a user opens, acts on and dismisses, so as a named region it
lands in the landmarks list instead — which is the more useful place for it. Slideouts
keep
role="dialog": they are dismissible, they can carry a form, and their state swapdepends on the rename-and-focus path. Both bars are still named by their message, since
barhas no headline element.transition: opacitydropped from the state classes. Nothing about the state swapchanges opacity, but declaring a transition on the widget root overrode
.slide-transition()— costing slideouts and bars their slide-out animation on theauto-close that fires a few seconds later, undoing part of LYT-1004: restore slideout and bar open/close animations #672.
showDelayfocus call scoped to its own widget, and moved to when the widget isactually visible. It used a bare
document.querySelector('.pf-widget-ok').focus(),which focuses whichever widget comes first in the document and throws outright when the
widget was configured with
okShow: false. Writing the regression pin @teijas asked forturned up a second bug in the same two lines: the focus ran as soon as the widget was
appended, while it is still
visibility: hiddenfor another 50ms — and nothing inside ahidden subtree can take focus, so the call had never once worked. It now runs from
the same callback that adds the
openedclass. Two tests: a delayedokShow: falsewidget opens without throwing and steals no focus, and a delayed widget opened behind an
existing one focuses its own confirm button.
Verification
test/acceptance/accessibility.spec.jsis new: 28 tests covering all 12 named-containertype/layout combinations, the empty-headline and empty-message cases, two widgets open at
once, the success and error state re-description, focus landing on the container, the
inline live region, Tab not reaching hidden elements, the focus trap in both directions,
and delayed-widget focus. Each aria reference is resolved against the whole document, so a
dangling or duplicated id fails the test.
Suite is at 306 passing (
developbaseline is 278). Every new test was confirmed tofail against the unmodified source first — including the six added this round: reverting
the four fixes individually failed exactly the tests that pin them.
Rendered in Chrome against the built bundle: every dialog reports
role=dialogwithresolving references; submitting a form slideout, modal, gate and inline widget gives the
expected name, description,
role="status"and focus target in each case; Tab after agate's success state lands on the visible button; and a slideout showing a success state
keeps its full
translate, opacity, visibilitytransition.Two caveats worth knowing:
references resolve, the live region is rendered and empty before its text arrives, and
focus moves; they do not prove NVDA or VoiceOver says the right thing. Screen readers still cannot read Experience content after #673 (form, subscription, and form-state cases) #675 carries the
same caveat.
or announces on open for those layouts, so a screen-reader user learns a slideout or bar
exists only on reaching it by navigation. This PR makes them correctly named once
reached, which is what ZD 22915 reported, but it does not make them interrupt. Worth
setting the customer expectation on that explicitly — it is pre-existing and unchanged
here.
stylesheet. pathfora async-loads
https://c.lytics.io/static/pathfora.min.cssatruntime, and that production stylesheet wins the cascade over a local build — it
initially made the animation fix look like it had not worked. Disable that sheet when
checking CSS locally.
dist/is rebuilt withNODE_ENV=production gulp build, consistent with how #672 and#673 shipped.
Not in scope
Filed separately rather than folded in here:
widgetFormValidateaddsinvalid/bad-validationclasses andreturns — no announcement, noaria-invalid, and thefocus()calls are guarded oni === 0so they focus the first field in the listrather than the first failing one. A spoken message needs new default user-facing copy,
which is a product decision. Worth noting the
errorformState only ever fires forconfirmAction.waitForAsyncResponse, so a server error is the only error a form cancurrently report — the far more common validation error reports nothing.
barlayouts never get state divs.construct-widget-layout.jsbuilds them formodal/slideout/gate/inlineonly, so a bar withformStateshides its contentand shows nothing for 3s.
button-action.js:38,55are no-op statements (callbackTypes.MODAL_CANCEL;with noassignment), so every confirm and cancel callback — form states or not — receives
undefinedas its first argument.headlinenormsgends up withrole="dialog"and no accessible name (@cthorn-cs).
describe-widget-container.jsclears the referencerather than pointing at an empty element, which is the correct half of the fix — there
is simply no text to name it with. Naming it would mean inventing copy, which is a
product decision, so it is left flagged rather than guessed at.
not fixed here, so this PR stays one concern;
aria-labelledbyis a space-separatedIDREF list, so
config.id = "spring promo"yields two dangling refs while the root'sgetElementByIdkeeps working. The only validation on ids today is truthiness(
prepare-widget.js:23), so the right fix is at validation, not at the reference site.🤖 Generated with Claude Code