v1.3.0 - #678
Merged
Merged
v1.3.0#678
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>
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>
…-names LYT-1120: give widget dialogs resolving accessible names and announce form states
✅ 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v1.3.0
Releases the widget dialog accessibility work (#676, LYT-1120) plus a docs example fix. Every widget dialog now resolves an accessible name, form success/error states are announced, and the modal/gate focus trap actually holds.
Why 1.3.0 and not 1.2.11
The accessibility fixes could not be made without changing the DOM that widgets render. Consumers style and script against that DOM, so this is a consumer-visible change rather than a bug fix, and it gets a minor bump.
Three things changed in the rendered markup:
.pf-widget-containernow wraps layouts that never had one.form/slideout,subscription/slideoutandsubscription/barpreviously rendered their close button, body and content as direct children of.pf-widget. They now sit inside a.pf-widget-containerthat carriesrole="dialog"/role="region"— there was nowhere else to hang the role and the accessible name. Our own LESS is descendant-based so it needed no changes, but any customer CSS using a direct-child selector (.pf-widget > .pf-widget-content) or DOM traversal that assumed a flat structure will need updating.Headline and message elements now carry generated ids. The old templates hardcoded
id="pf-widget-headline"/id="pf-widget-message", which is invalid the moment two widgets are open at once — duplicate ids meanaria-labelledbyresolves to whichever one the browser finds first. Ids are now generated per widget and namespaced under the widget id (<widget-id>-pf-widget-headline). Anything selecting on the old static ids will no longer match, and — as our own AB test specs hit — a loose[id*="my-widget"]selector now matches the headline and message too, not just the widget root.Inline form widgets render a new
.pf-widget-announcementelement. Inline widgets sit in the page's own flow and must not steal focus, so they announce their form state through a visually hiddenrole="status"live region instead. It is built with the state elements and stays in the document, clipped rather thandisplay: none, because a live region has to already be rendered and empty for its text to be announced when it arrives.None of this breaks a default installation — no config changes, no API changes, and widgets render and behave the same visually. But the markup contract shifted, so anyone with custom CSS or scripts against widget internals should verify before upgrading. That is exactly the "added functionality in a backwards compatible manner, with a notable change to what we render" case a minor bump is for.
Accessibility changes
aria-labelledby/aria-describedbyare now wired up at runtime against the widget's real headline and message, and cleared when the widget has no text to point at — a reference to an empty element leaves a dialog just as unnamed as no reference at all. Bar layouts, which have no headline element, are named by their message and moved fromrole="dialog"torole="region"; they are not dialogs.display: noneelements. The set is now recomputed on each Tab and filtered to what is actually rendered.showDelaywidgets focused.pf-widget-okwhile the widget was stillvisibility: hidden, which silently did nothing. Focus now waits for theopenedclass, is scoped to the widget's own node (an unscoped lookup grabbed whichever widget came first in the document), and is skipped whenokShow: falseleaves no button to focus.opacitytransition on.success/.error. Nothing about the state swap changes opacity, and declaring a transition on the widget root overrode.slide-transition(), costing slideouts and bars their slide-out animation when the state delay closed them.Also included
entity.preload,pathfora.publish/preview), which was fetching visitor profiles on every example page load and could render real campaigns on top of the widget an example was meant to demonstrate.Testing
test/acceptance/accessibility.spec.js— 24 specs across widget naming, form state announcement, the dialog focus trap and delayed widget focus.create-and-dispatch-keydowntest helper takes ashiftKeyflag for reverse-tab coverage..pf-widget[id*="..."]now that the generated ids share the widget id prefix.🤖 Generated with Claude Code