Skip to content

LYT-1120: give widget dialogs resolving accessible names and announce form states - #676

Merged
ashyablok-cs merged 3 commits into
developfrom
LYT-1120-widget-dialog-accessible-names
Sep 17, 2026
Merged

ashyablok-cs merged 3 commits into
developfrom
LYT-1120-widget-dialog-accessible-names

Conversation

@ashyablok-cs

@ashyablok-cs ashyablok-cs commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes all three defects in #675 (LYT-1120 / ZD 22915, Clorox). Three commits — 545bb25
covers defects 1 and 2, 440e4f0 covers defect 3, and 2e6ed83 is the review round.

#673 closed #670 by adding dialog semantics to the message slideout and bar. It did
not deliver a working accessible name on any layout it touched, and it did not reach the
form and subscription layouts at all — which is why Clorox retested v1.2.21, saw
their "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.html and
subscription/bar.html rendered with no pf-widget-container, no role and no
accessible name. Each is now wrapped the way the message equivalents 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.html and
message/bar.html already had a pf-widget-container on develop, so their diff here is
just the removal of the dangling static aria attributes, not a new wrapper.)
Checks done before making it: .pf-widget-container is only styled under
.pf-widget-modal / .pf-widget-gate, no LESS rule in the slideout or bar paths uses a
child selector, and nothing in src/rollup/** traverses the widget root's direct
children. All three were then rendered in Chrome against the built bundle — layout
unchanged.

Defect 2 — the aria references did not resolve

message/slideout.html and message/bar.html carried
aria-labelledby="pf-widget-headline", but no element had that id — the string
existed only as a class name. Both references dangled. bar additionally pointed at a
headline 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 from
setup-widget-aria.js — and both the static ids and the template-level
aria-labelledby/aria-describedby are gone from all 15 templates. The helper:

  • mints ids namespaced under the widget id, so widgets open at the same time cannot
    collide. The old static ids were already a duplicate-id bug on any page showing two
    dialogs.
  • only sets a reference when the target will actually hold text, so an empty headline
    (the default) no longer names a dialog with an empty string.
  • names a bar by 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. Any
customer 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.success hides the headline, message
and form, then re-shows the ones inside .success-state), which is silent. Three things
were wrong at once:

  1. nothing marked the swap — no live region, no focus change;
  2. the dialog's aria references stayed pinned to the original headline and message, which
    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!";
  3. 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 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 .success class took it from
display: none to rendered — so the region entered the accessibility tree with its text
already inside, which is the case NVDA and JAWS skip. It now works the way both of you
described: construct-state-live-region.js builds a rendered, visually-hidden, empty
role="status" element alongside the state divs at widget-construction time, and
announceFormState writes the state's text into it a tick after the reveal. It is clipped
rather 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 implicit aria-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 — with okShow: true set, so a regression that re-included
the 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 the
comment 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-visible heuristics, 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 this
rule Chrome draws outline: auto around the full-viewport gate or modal container. The
comment now says "load-bearing, not cosmetic. Do not remove."

Focus trap

show-widget.js captured its set of focusable elements once, at open time. After a state
swap that set still pointed into the hidden form, so Tab called .focus() on
display: none elements and focus went nowhere — a keyboard user was stranded. It now
recomputes on every Tab, filtered to elements that are actually rendered
(getClientRects().length > 0, which is correct for the position: fixed modal content
where offsetParent is not). The handler also read the global event instead of its own
ev; 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 !== 9 and never checked shiftKey, and
its 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

  • bar layouts are role="region", not role="dialog" (@cthorn-cs). A persistent
    promo 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 swap
    depends on the rename-and-focus path. Both bars are still named by their message, since
    bar has no headline element.
  • transition: opacity dropped from the state classes. Nothing about the state swap
    changes opacity, but declaring a transition on the widget root overrode
    .slide-transition() — costing slideouts and bars their slide-out animation on the
    auto-close that fires a few seconds later, undoing part of LYT-1004: restore slideout and bar open/close animations #672.
  • showDelay focus call scoped to its own widget, and moved to when the widget is
    actually 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 for
    turned up a second bug in the same two lines: the focus ran as soon as the widget was
    appended, while it is still visibility: hidden for another 50ms — and nothing inside a
    hidden subtree can take focus, so the call had never once worked. It now runs from
    the same callback that adds the opened class. Two tests: a delayed okShow: false
    widget 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.js is new: 28 tests covering all 12 named-container
type/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 (develop baseline is 278). Every new test was confirmed to
fail 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=dialog with
resolving 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 a
gate's success state lands on the visible button; and a slideout showing a success state
keeps its full translate, opacity, visibility transition.

Two caveats worth knowing:

  • Announcement behaviour in a real screen reader is untested. The tests prove the
    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.
  • Slideouts and bars are still not perceived when they appear. Nothing moves focus
    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.
  • Local CSS changes cannot be verified on a page that lets the library load its own
    stylesheet.
    pathfora async-loads https://c.lytics.io/static/pathfora.min.css at
    runtime, 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 with NODE_ENV=production gulp build, consistent with how #672 and
#673 shipped.

Not in scope

Filed separately rather than folded in here:

  • Validation failures are silent. widgetFormValidate adds invalid /
    bad-validation classes and returns — no announcement, no aria-invalid, and the
    focus() calls are guarded on i === 0 so they focus the first field in the list
    rather than the first failing one. A spoken message needs new default user-facing copy,
    which is a product decision. Worth noting the error formState only ever fires for
    confirmAction.waitForAsyncResponse, so a server error is the only error a form can
    currently report
    — the far more common validation error reports nothing.
  • bar layouts never get state divs. construct-widget-layout.js builds them for
    modal/slideout/gate/inline only, so a bar with formStates hides its content
    and shows nothing for 3s.
  • button-action.js:38,55 are no-op statements (callbackTypes.MODAL_CANCEL; with no
    assignment), so every confirm and cancel callback — form states or not — receives
    undefined as its first argument.
  • A dialog configured with neither headline nor msg ends up with role="dialog"
    and no accessible name (@cthorn-cs). describe-widget-container.js clears the reference
    rather 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.
  • A widget id containing whitespace splits the aria reference (@teijas). Deliberately
    not fixed here, so this PR stays one concern; aria-labelledby is a space-separated
    IDREF list, so config.id = "spring promo" yields two dangling refs while the root's
    getElementById keeps 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

ashyablok-cs and others added 2 commits September 10, 2026 11:57
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>
@ashyablok-cs
ashyablok-cs requested a review from a team September 15, 2026 21:18
@teijas teijas added the claude-review: in progress A Claude PR review is running label Sep 16, 2026

@teijas teijas left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

  1. The inline live region is created at the moment it's revealed. announceFormState puts role="status" on the state div in the same tick the .success class takes it from display: none to 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-hidden role="status" element built alongside the state divs, into which announceFormState copies 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.

  2. A widget id containing whitespace would split the aria reference. aria-labelledby is a space-separated IDREF list, so config.id = "spring promo" yields aria-labelledby="spring promo-pf-widget-headline", which parses as two dangling refs. getElementById on 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+/g in the namespace, or fall back to a counter when the id has whitespace. If Experience ids can never contain spaces, ignore this.

  3. 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-visible heuristics, 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 draw outline: auto around 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.

Comment thread src/rollup/form/announce-form-state.js Outdated
Comment thread src/rollup/widgets/describe-widget-container.js
Comment thread src/less/widgets/widgets-general.less
Comment thread src/rollup/widgets/show-widget.js Outdated
@teijas teijas added claude-review: done A Claude review exists; human review still required and removed claude-review: in progress A Claude PR review is running labels Sep 16, 2026

@cthorn-cs cthorn-cs left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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-modal bar and slideout layouts. 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/slideout and message/bar also gain a pf-widget-container (same risk class — looks fine, just for completeness). And display-conditions.spec.js is 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-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

Copy link
Copy Markdown
Contributor Author

Thanks both — pushed as 2e6ed83. Everything below is addressed except the whitespace-in-ids one, which is deliberately deferred (noted at the bottom).

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: construct-state-live-region.js builds a rendered, visually-hidden, empty role="status" element alongside the state divs at widget-construction time, and announceFormState writes into it a tick after the reveal. Clipped rather than display: none, so the announcement stays in the accessibility tree.

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 okShow: true and asserts the region holds exactly "Thanks!. We got it.", so a regression that re-includes "Done" fails.

@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.

role="dialog" on bar and slideout — @cthorn-cs

Good catch, and I have split them. bar is now role="region" — a persistent promo bar is not something you open, act on and dismiss, and as a named region it lands in the landmarks list, which is more useful. Slideouts keep dialog: they are dismissible, they can carry a form, and their state swap depends on the rename-and-focus path. Both bars are still named by their message.

The focus-ring rule — @teijas (3)

Kept, comment rewritten. My original comment was straightforwardly wrong about the :focus-visible heuristics and would have invited someone to delete the rule later. It now spells out that script moving focus from an element matching :focus-visible propagates the match, so Chrome would otherwise draw outline: auto around the whole full-viewport container — ending with "load-bearing, not cosmetic. Do not remove."

showDelay regression pin — @teijas

Added, and it earned its keep immediately: writing it turned up a second bug in those two lines. The focus ran as soon as the widget was appended, while it is still visibility: hidden for another 50ms — and nothing inside a hidden subtree can take focus, so that call had never once worked, on this branch or on develop. It now runs from the same callback that adds the opened class. Two tests: a delayed okShow: false widget opens without throwing and steals no focus, and a delayed widget opened behind an existing one focuses its own confirm button.

Two corrections on the scope nit — @cthorn-cs

Checked both and I think they are off:

  • message/slideout.html and message/bar.html already had a pf-widget-container on develop (git show develop:src/templates/message/slideout.html). Their diff here is only the removal of the dangling static aria attributes. The newly-wrapped set really is just the three listed: form/slideout, subscription/slideout, subscription/bar.
  • display-conditions.spec.js is not in the diff — git diff develop --name-only shows ab-testing.spec.js and accessibility.spec.js as the only touched specs. Possibly you were looking at Screen readers still cannot read Experience content after #673 (form, subscription, and form-state cases) #675?

Happy to be shown wrong on either if you are seeing something I am not.

Deliberately not fixed

  • Whitespace in widget ids (@teijas) — real, and left for its own PR so this one stays one concern. The only validation on ids today is truthiness (prepare-widget.js:23), so the fix belongs at validation rather than at the reference site. Added to "Not in scope" in the description.
  • A dialog with neither headline nor msg (@cthorn-cs) — flagged in the description. Naming it would mean inventing user-facing copy, which is a product call.

Description

Updated for all of the above, plus the two notes @teijas asked for: the showDelay fix now has tests, and slideouts/bars are still not perceived on open since nothing moves focus or announces — this PR makes them correctly named once reached, which is what ZD 22915 reported, but it does not make them interrupt. Worth setting the Clorox expectation on that explicitly.

Suite is at 306 (from 300; develop baseline 278). All six new tests were confirmed to fail first — reverting each of the four fixes individually fails exactly the tests that pin it. dist/ rebuilt.

@ashyablok-cs
ashyablok-cs merged commit 3fad473 into develop Sep 17, 2026
5 checks passed
@ashyablok-cs ashyablok-cs mentioned this pull request Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-review: done A Claude review exists; human review still required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Experience popups are not accessible to screen readers (WCAG/ADA compliance gap)

3 participants