Skip to content

v1.3.0 - #678

Merged
ashyablok-cs merged 6 commits into
masterfrom
develop
Sep 17, 2026
Merged

v1.3.0#678
ashyablok-cs merged 6 commits into
masterfrom
develop

Conversation

@ashyablok-cs

Copy link
Copy Markdown
Contributor

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:

  1. .pf-widget-container now wraps layouts that never had one. form/slideout, subscription/slideout and subscription/bar previously rendered their close button, body and content as direct children of .pf-widget. They now sit inside a .pf-widget-container that carries role="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.

  2. 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 mean aria-labelledby resolves 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.

  3. Inline form widgets render a new .pf-widget-announcement element. Inline widgets sit in the page's own flow and must not steal focus, so they announce their form state through a visually hidden role="status" live region instead. It is built with the state elements and stays in the document, clipped rather than display: 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

  • Every widget dialog resolves an accessible name. aria-labelledby/aria-describedby are 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 from role="dialog" to role="region"; they are not dialogs.
  • Form success and error states are announced. Previously the state swap was pure CSS, which is silent, and the same rules hide the button the user just pressed — a screen reader user got no feedback and a keyboard user was dropped back to the top of the page. Dialogs are now renamed after their new contents and handed focus; inline widgets write into the live region above.
  • The modal/gate focus trap handles Shift+Tab and re-renders. The old trap only corrected forward Tab, so the first Shift+Tab leaked out the top of the dialog. It also captured the focusable set once at open time, which went stale as soon as a form widget swapped in its success state — focus went to display: none elements. The set is now recomputed on each Tab and filtered to what is actually rendered.
  • Delayed widgets now actually receive focus. showDelay widgets focused .pf-widget-ok while the widget was still visibility: hidden, which silently did nothing. Focus now waits for the opened class, is scoped to the widget's own node (an unscoped lookup grabbed whichever widget came first in the document), and is skipped when okShow: false leaves no button to focus.
  • Removed the opacity transition 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

  • Docs examples opt out of the demo account's server-side personalization (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

  • New test/acceptance/accessibility.spec.js — 24 specs across widget naming, form state announcement, the dialog focus trap and delayed widget focus.
  • create-and-dispatch-keydown test helper takes a shiftKey flag for reverse-tab coverage.
  • AB testing specs tightened to .pf-widget[id*="..."] now that the generated ids share the widget id prefix.

🤖 Generated with Claude Code

ashyablok-cs and others added 6 commits August 26, 2026 14:03
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-io

snyk-io Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@ashyablok-cs
ashyablok-cs merged commit 6f9a731 into master Sep 17, 2026
5 checks passed
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.

1 participant