Skip to content

fix(a11y): Make recurrence modal fully keyboard-accessible#1533

Merged
jensens merged 3 commits intomasterfrom
fix/recurrence-keyboard-accessibility
Mar 5, 2026
Merged

fix(a11y): Make recurrence modal fully keyboard-accessible#1533
jensens merged 3 commits intomasterfrom
fix/recurrence-keyboard-accessibility

Conversation

@jensens
Copy link
Member

@jensens jensens commented Feb 12, 2026

Summary

Fixes plone/Products.CMFPlone#4272

The recurrence modal had two keyboard accessibility issues:

  1. "End recurrence" only accessible when tabbing backwards — the modal's focus trap built a static list of focusable elements at open time, missing dynamically shown/hidden fields
  2. "Selected dates" and "exclude" buttons unreachable without mouse — occurrence action links loaded via AJAX were never in the focus trap, and used <a href="#"> instead of proper <button> elements

Changes

  • Dynamic focus trap (pat/modal/modal.js): activateFocusTrap() now re-queries visible focusable elements on each Tab keypress instead of using a static list captured at init. This benefits all modals across Plone, not just recurrence.
  • Semantic buttons (occurrence.xml, display.xml, recurrence.js): Changed occurrence action links and display widget "Add"/"Delete" links from <a href="#"> to <button type="button"> for proper semantics and native keyboard support (Enter + Space).
  • CSS updates (recurrence.scss): Updated selector from div.ridisplay .rimain a to div.ridisplay .rimain button. Added :focus-visible outline for occurrence action buttons.
  • Tests (recurrence.test.js): Added tests verifying <button> elements are used for display controls and dynamically added date actions.

Test plan

  • All 194 existing tests pass (28 suites)
  • 2 new tests added and passing
  • Manual testing: open an Event edit form, click "Edit" on recurrence, verify Tab reaches all controls including "End recurrence" range options and occurrence exclude/include buttons
  • Verify Shift+Tab wraps correctly through the modal
  • Verify the "Add"/"Delete rules" buttons in the display widget are keyboard-accessible

@jensens jensens marked this pull request as draft February 12, 2026 11:28
@jensens jensens force-pushed the fix/recurrence-keyboard-accessibility branch from a2acead to d039306 Compare February 13, 2026 23:36
@jensens jensens marked this pull request as ready for review February 13, 2026 23:38
@jensens jensens requested a review from thet February 13, 2026 23:38
jensens added 2 commits March 4, 2026 17:22
Make modal focus trap dynamic: re-query visible focusable elements on
each Tab press so that AJAX-loaded content (occurrence lists) and
dynamically shown/hidden fields are always reachable via keyboard.

Ref: plone/Products.CMFPlone#4272
- Change occurrence action links (<a href="#">) to <button> elements
for proper semantics and native keyboard support (Enter + Space).
- Change display widget "Add"/"Delete" links to <button> elements.
- Update all matching JS selectors and CSS rules.
- Add focus-visible outline styles for occurrence action buttons.
- Add tests verifying button elements are used.

Fixes: plone/Products.CMFPlone#4272
@thet thet force-pushed the fix/recurrence-keyboard-accessibility branch from d039306 to d9b3ff3 Compare March 4, 2026 16:24
Copy link
Member

@thet thet left a comment

Choose a reason for hiding this comment

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

LGTM

Especially the button change is great.

The dynamic re-evaluation of tab presses sounds a bit expensive, but might be OK too. I'll paste a conversation with an AI on this issue and got a convincing answer, so this improvement LGTM.

@thet
Copy link
Member

thet commented Mar 4, 2026

Btw. I changed the single commit combining two different changes into two commits, as it is the convention in Mockup and Patternslib.

@thet
Copy link
Member

thet commented Mar 4, 2026

thet

Context:

  •  modal.js
  • agent

@{agent} In the referenced context buffer we have a focusTrapHandler, which is run for each <tab> key press.

  1. Does this cover all input focus change possibilities? I mean, are there also other ways of switching the focus from one input to another than pressing the <tab> key, which are not covered by the event handler?

  2. Is this the best way to handle dynamic inserted content into a modal, is it performant enought? Or would a MutationObserver be better, which only re-runs the focustraphandler when something has changed?

CodeCompanion (Copilot)

Here are answers to your questions based on the provided code:

  1. Does this cover all input focus change possibilities?
    No, the current focus trap handler only intercepts <Tab> key presses within the modal to cycle focus among visible inputs. Other ways users can change focus include:
  • Clicking with the mouse (not intercepted by the handler).
  • Using <Shift+Tab> (which is covered).
  • Using browser/system shortcuts (e.g., accessibility tools, screen readers).
  • Programmatic focus changes (e.g., via JavaScript).
  • <F6> or <Ctrl+Tab> in some browsers for panel navigation.

So, while the handler covers keyboard navigation via <Tab>, it does not prevent mouse clicks or programmatic focus changes from moving focus outside the modal. For a stricter focus trap, you would need to intercept focus events and redirect them if the target is outside the modal.

  1. Is this the best way to handle dynamically inserted content into a modal?
    The current approach re-queries visible inputs on every <Tab> press, ensuring that newly added elements are included in the focus cycle. This is effective and performant for most use cases, as the query is only run when the user presses <Tab>, not on every DOM mutation.

Using a MutationObserver could allow you to update a cached list of focusable elements only when the modal's content changes, potentially improving performance if the modal contains a very large number of elements or if <Tab> is pressed extremely frequently. However, for typical modal content sizes and user behaviour, the difference is negligible, and the current approach is simpler and less error-prone.

Summary:

  • The handler is good for keyboard navigation but does not cover all focus change methods.
  • For most cases, re-querying on <Tab> is performant and robust. MutationObserver is only needed for very complex/dynamic modals or if you want to cache focusable elements for other reasons.

If you want stricter focus trapping or to experiment with MutationObserver-based caching, let me know and I can implement an example for you.

thet

Context:

  •  modal.js
  • agent

@thet thet requested a review from petschki March 4, 2026 16:27
@jensens
Copy link
Member Author

jensens commented Mar 5, 2026

OK, the AI is right, it covers only tab-key, but that was the original issue: keyboard accessibility. @thet do you merge or shall I?

@jensens jensens merged commit 35a7a53 into master Mar 5, 2026
3 checks passed
@jensens jensens deleted the fix/recurrence-keyboard-accessibility branch March 5, 2026 12:18
@petschki
Copy link
Member

@jensens
Copy link
Member Author

jensens commented Mar 11, 2026

The PR changed <a name="riedit"> to <button type="button" name="riedit">, but the robot test in plone.app.event still looks for //a[@name="riedit"].

jensens added a commit to plone/plone.app.event that referenced this pull request Mar 11, 2026
The recurrence widget's 'Add rules' button was changed from <a> to
<button> in plone/mockup#1533 for accessibility. Use //*[@name="riedit"]
instead of //a[@name="riedit"] so the test works with both old and new
mockup versions.
mister-roboto pushed a commit to plone/buildout.coredev that referenced this pull request Mar 12, 2026
Branch: refs/heads/master
Date: 2026-03-11T22:37:12+01:00
Author: Jens W. Klein (jensens) <jk@kleinundpartner.at>
Commit: plone/plone.app.event@403d120

fix(robot): Use element-agnostic XPath for riedit selector

The recurrence widget's 'Add rules' button was changed from &lt;a&gt; to
&lt;button&gt; in plone/mockup#1533 for accessibility. Use //*[@name="riedit"]
instead of //a[@name="riedit"] so the test works with both old and new
mockup versions.

Files changed:
M src/plone/app/event/tests/robot/test_event_roundtrip.robot
Repository: plone.app.event

Branch: refs/heads/master
Date: 2026-03-11T22:38:31+01:00
Author: Jens W. Klein (jensens) <jk@kleinundpartner.at>
Commit: plone/plone.app.event@3db6648

Add towncrier news entry for #432

Files changed:
A news/432.tests
Repository: plone.app.event

Branch: refs/heads/master
Date: 2026-03-11T22:47:37+01:00
Author: Jens W. Klein (jensens) <jk@kleinundpartner.at>
Commit: plone/plone.app.event@d60dc96

Update configuration via plone/meta 2.x

Adds setuptools&lt;82.0.0 to dependencies tox env to fix
ModuleNotFoundError: No module named 'pkg_resources'.
Also updates isort, black, pyupgrade targets and setuptools
version constraint.

Files changed:
A news/+meta.internal
M .github/workflows/test-matrix.yml
M .meta.toml
M .pre-commit-config.yaml
M pyproject.toml
M tox.ini
Repository: plone.app.event

Branch: refs/heads/master
Date: 2026-03-11T22:50:04+01:00
Author: Jens W. Klein (jensens) <jk@kleinundpartner.at>
Commit: plone/plone.app.event@17abd26

Apply isort 8 import ordering fixes

Files changed:
M docs/conf.py
M setup.py
M src/plone/app/event/__init__.py
M src/plone/app/event/base.py
M src/plone/app/event/dx/behaviors.py
M src/plone/app/event/ical/exporter.py
M src/plone/app/event/interfaces.py
M src/plone/app/event/portlets/portlet_calendar.py
M src/plone/app/event/setuphandlers.py
M src/plone/app/event/tests/base_setup.py
M src/plone/app/event/tests/robot/variables.py
M src/plone/app/event/tests/test_dx_behaviors.py
M src/plone/app/event/tests/test_icalendar.py
M src/plone/app/event/tests/test_portlet_calendar.py
M src/plone/app/event/tests/test_portlet_events.py
M src/plone/app/event/tests/test_recurrence.py
M src/plone/app/event/upgrades/upgrades.py
Repository: plone.app.event

Branch: refs/heads/master
Date: 2026-03-12T07:19:32+01:00
Author: Peter Mathis (petschki) <petschki@users.noreply.github.com>
Commit: plone/plone.app.event@ffc29bb

Merge pull request #433 from plone/config-with-default-template-2.5.1

Update configuration via plone/meta 2.x

Files changed:
A news/+meta.internal
A news/432.tests
M .github/workflows/test-matrix.yml
M .meta.toml
M .pre-commit-config.yaml
M docs/conf.py
M pyproject.toml
M setup.py
M src/plone/app/event/__init__.py
M src/plone/app/event/base.py
M src/plone/app/event/dx/behaviors.py
M src/plone/app/event/ical/exporter.py
M src/plone/app/event/interfaces.py
M src/plone/app/event/portlets/portlet_calendar.py
M src/plone/app/event/setuphandlers.py
M src/plone/app/event/tests/base_setup.py
M src/plone/app/event/tests/robot/test_event_roundtrip.robot
M src/plone/app/event/tests/robot/variables.py
M src/plone/app/event/tests/test_dx_behaviors.py
M src/plone/app/event/tests/test_icalendar.py
M src/plone/app/event/tests/test_portlet_calendar.py
M src/plone/app/event/tests/test_portlet_events.py
M src/plone/app/event/tests/test_recurrence.py
M src/plone/app/event/upgrades/upgrades.py
M tox.ini
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.

Accessibility issue: Recurrence in events is not configurable via keyboard

3 participants