Skip to content

Commit 9b5fedb

Browse files
bsatarnejadHDingermyabc
authored
[62708] Implementing ARIA live regions to communicate contextual changes (opf#17920)
* check if there is a screen reader active on page load * hide the flat picker for screen readers * make the banner focusable * change the focus color in banner * change disabled to readonly input fields * change replace_via_turbo_stream to add a message to it * add live region of github to out project * add a method to send the aria message * make it possible to pass the attributes * pass type of aria live region to front-end based on the action * remove focused field from date picker * focus on tabs when there is no banner * Revert "remove focused field from date picker" This reverts commit ab5e306. * focus on the first element when opening the dialog * remove auto-focus * undo changes for adding a message * add a new input method for adding a date and select today as a date * remove label and name from form input * change readonly to disabled * undo changes for date-form * delete dateform file * add aria-live for fields in date pickr * add a turbo stream to the component to load the messages for aria live regions * undo changes for check f there is an active screen reader * use settimeout instead of add event listener, so turbo frame also can be used hare * undo changes for text with link form input * remove text with link form input * add documentation for aria-live region * add an example for using current method in relation creation * add aria live region in date-pickr dialog * add some delay for polite messages to make sure it is caught * send a message to be gotten by screen reader in date picker while changing inputs * remove aria-live form inputs in date pickr * show update message after any change in inputs of date picker * fix rubocup errors in relation controller * fix rubocup errors in datepicker controller * fix eslint error * update docs * add some details to the aria live doc * remove test for aria-live on inputs of date pickr * remove unnecessary live region polite for date picker * Update lookbook/docs/patterns/18-aria-live.md.erb Co-authored-by: Henriette Darge <h.darge@openproject.com> * Update lookbook/docs/patterns/18-aria-live.md.erb Co-authored-by: Henriette Darge <h.darge@openproject.com> * fix comment in preview controller * remove role: alert * set correct value for target * move aria turbo stream below live region * add missing spaces and a better headline for js handling turbo aria action * move the exception of modals and dialogs to the end of doc file * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update frontend/src/turbo/aria-stream-action.ts Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update app/controllers/work_packages/date_picker_controller.rb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update app/controllers/concerns/op_turbo/component_stream.rb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update app/controllers/work_packages/date_picker_controller.rb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update app/controllers/work_packages/date_picker_controller.rb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * change parameter in update_inputs_aria_live_message string * change type to politeness * Update lookbook/docs/patterns/accessibility/18-aria-live.md.erb Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com> * set delay for polite updates * use delay in relations tab update and date pickr update * remove the usage of aria live region update message in relations tab, since we should handle update message here in another way * use assistive technology instead of screen reader only * change the stream action to live region * change the aria action to live region * remove alert role * change the string in away that if there is no value for start date, finish date or duration, it shouldn't be part of the string * remove aria-live from input text fields * test for updated message in date pickr * undo changes for relations updates * check if message is null then return * add more delay for updating data in date picker * fix test for adding more delay for updating data in date picker --------- Co-authored-by: Henriette Darge <h.darge@openproject.com> Co-authored-by: Alexander Brandon Coles <a.coles@openproject.com>
1 parent ca0664e commit 9b5fedb

File tree

16 files changed

+241
-13
lines changed

16 files changed

+241
-13
lines changed

app/components/concerns/op_turbo/streamable.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def wrapper_key
4545
end
4646

4747
included do
48-
def render_as_turbo_stream(view_context:, action: :update, method: nil)
48+
def render_as_turbo_stream(view_context:, action: :update, method: nil, **attributes)
4949
case action
5050
when :update, *INLINE_ACTIONS
5151
@inner_html_only = true
@@ -73,7 +73,8 @@ def render_as_turbo_stream(view_context:, action: :update, method: nil)
7373
action:,
7474
method:,
7575
target: wrapper_key,
76-
template:
76+
template:,
77+
**attributes
7778
).render_in(view_context)
7879
end
7980

app/components/work_packages/date_picker/date_form_component.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,7 @@ def default_field_options(name)
211211
data[:focus] = "true"
212212
end
213213

214-
{
215-
data: data,
216-
aria: { live: :polite, atomic: true }
217-
}
214+
{ data: data }
218215
end
219216

220217
def single_date_field_button_link(focused_field)

app/components/work_packages/date_picker/dialog_content_component.html.erb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
<%=
22
content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do
3+
%>
4+
<live-region></live-region>
5+
<% if @live_region_message %>
6+
<%= render OpTurbo::StreamComponent.new(action: :liveRegion, message: @live_region_message, politeness: "polite", delay: "500", target: nil) %>
7+
<% end %>
8+
<%=
39
component_wrapper(
410
data: { "application-target": "dynamic",
511
controller: "work-packages--date-picker--preview",
@@ -99,5 +105,5 @@
99105
end
100106
end
101107
end
102-
end
103-
%>
108+
%>
109+
<% end %>

app/components/work_packages/date_picker/dialog_content_component.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def initialize(work_package:,
4646
focused_field: :start_date,
4747
triggering_field: nil,
4848
touched_field_map: {},
49+
live_region_message: "",
4950
date_mode: nil)
5051
super
5152

@@ -55,6 +56,7 @@ def initialize(work_package:,
5556
@triggering_field = triggering_field
5657
@touched_field_map = touched_field_map
5758
@date_mode = date_mode
59+
@live_region_message = live_region_message
5860
end
5961

6062
private

app/controllers/concerns/op_turbo/component_stream.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ def render_error_flash_message_via_turbo_stream(**)
8383
render_flash_message_via_turbo_stream(**, scheme: :danger, icon: :stop)
8484
end
8585

86+
def render_live_region_update_message(message:, politeness: "polite", delay: nil)
87+
turbo_streams << OpTurbo::StreamComponent
88+
.new(action: :liveRegion, message:, politeness:, delay:, target: nil)
89+
.render_in(view_context)
90+
end
91+
8692
def render_flash_message_via_turbo_stream(message:, component: OpPrimer::FlashComponent, **)
8793
instance = component.new(**).with_content(message)
8894
turbo_streams << instance.render_as_turbo_stream(view_context:, action: :flash)

app/controllers/work_packages/date_picker_controller.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,33 @@ def datepicker_modal_component
157157
focused_field:,
158158
triggering_field: params[:triggering_field],
159159
touched_field_map:,
160-
date_mode:)
160+
date_mode:,
161+
live_region_message:)
162+
end
163+
164+
def live_region_message
165+
message_parts = [
166+
"Scheduling mode: #{scheduling_label}",
167+
working_days_label
168+
]
169+
170+
message_parts << "Start date: #{@work_package.start_date}" if @work_package.start_date.present?
171+
message_parts << "Finish date: #{@work_package.due_date}" if @work_package.due_date.present?
172+
message_parts << "Duration: #{@work_package.duration} days" if @work_package.duration.present?
173+
174+
I18n.t(
175+
"work_packages.datepicker_modal.update_inputs_aria_live_message",
176+
message: message_parts.join(", ")
177+
)
178+
end
179+
180+
def working_days_label
181+
I18n.t("activerecord.attributes.work_package.include_non_working_days.#{@work_package.ignore_non_working_days}")
182+
end
183+
184+
def scheduling_label
185+
mode = @work_package.schedule_manually ? "manual" : "automatic"
186+
I18n.t("work_packages.datepicker_modal.mode.#{mode}")
161187
end
162188

163189
def focused_field

app/views/layouts/base.html.erb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ See COPYRIGHT and LICENSE files for more details.
5959
</noscript>
6060
<opce-toasts-container></opce-toasts-container>
6161
<opce-modal-overlay></opce-modal-overlay>
62+
<live-region>
63+
<div id="polite" aria-live="polite" aria-atomic="true" class="hidden-for-sighted"></div>
64+
<div id="assertive" aria-live="assertive" aria-atomic="true" class="hidden-for-sighted"></div>
65+
</live-region>
6266
<opce-spot-drop-modal-portal></opce-spot-drop-modal-portal>
6367
<% main_menu = render_main_menu(local_assigns.fetch(:menu_name, nil), @project) %>
6468
<% side_displayed = content_for?(:sidebar) || content_for?(:main_menu) || !main_menu.blank? %>

config/locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,7 @@ en:
752752
automatic: "Automatic"
753753
manual: "Manual"
754754
show_relations: "Show relations"
755+
update_inputs_aria_live_message: "Date picker updated. %{message}"
755756
tabs:
756757
aria_label: "Datepicker tabs"
757758
children: "Children"

frontend/package-lock.json

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"@openproject/primer-view-components": "^0.66.2",
106106
"@openproject/reactivestates": "^3.0.1",
107107
"@primer/css": "^21.5.0",
108+
"@primer/live-region-element": "^0.8.0",
108109
"@primer/primitives": "^9.1.2",
109110
"@primer/view-components": "npm:@openproject/primer-view-components@^0.66.2",
110111
"@types/hotwired__turbo": "^8.0.1",

frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export default class PreviewController extends DialogPreviewController {
353353
}
354354
}
355355

356-
// called from inputs defined in the date_picker/date_form.rb
356+
// called from inputs defined in the date_picker/date_form_component.rb
357357
onHighlightField(e:Event) {
358358
const fieldToHighlight = e.target as HTMLInputElement;
359359
if (fieldToHighlight) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { StreamActions, StreamElement } from '@hotwired/turbo';
2+
import { announce } from '@primer/live-region-element';
3+
4+
export function registerLiveRegionStreamAction() {
5+
StreamActions.liveRegion = function liveRegionStreamAction(this:StreamElement) {
6+
const message = this.getAttribute('message');
7+
if (!message) return;
8+
const politeness = this.getAttribute('politeness') || 'polite';
9+
const delay = parseInt(this.getAttribute('delay') ?? '0', 10);
10+
if (politeness === 'assertive') {
11+
void announce(message, {
12+
politeness: 'assertive',
13+
});
14+
} else {
15+
void announce(message, {
16+
politeness: 'polite',
17+
delayMs: delay,
18+
});
19+
}
20+
};
21+
}

frontend/src/turbo/setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import TurboPower from 'turbo_power';
44
import { registerDialogStreamAction } from './dialog-stream-action';
55
import { addTurboEventListeners } from './turbo-event-listeners';
66
import { registerFlashStreamAction } from './flash-stream-action';
7+
import { registerLiveRegionStreamAction } from './live-region-stream-action';
78
import { registerInputCaptionStreamAction } from './input-caption-stream-action';
89
import { addTurboGlobalListeners } from './turbo-global-listeners';
910
import { applyTurboNavigationPatch } from './turbo-navigation-patch';
@@ -30,6 +31,7 @@ addTurboEventListeners();
3031
addTurboGlobalListeners();
3132
registerDialogStreamAction();
3233
registerFlashStreamAction();
34+
registerLiveRegionStreamAction();
3335
registerInputCaptionStreamAction();
3436

3537
// Apply navigational patch
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
## What are ARIA Live Regions?
2+
ARIA Live Regions are a crucial accessibility feature that allow assistive technologies to announce dynamic content updates without requiring user focus changes.
3+
4+
### ARIA Live Attributes
5+
ARIA Live Regions rely on specific attributes to control how updates are announced:
6+
- `aria-live="polite"`: Announces updates when the user is idle.
7+
- `aria-live="assertive"`: Announces updates immediately, interrupting the user.
8+
- `aria-atomic="true"`: Ensures the entire content of the region is read when it changes.
9+
10+
## Implementing ARIA Live Regions in Primer
11+
The Primer project provides a custom element (Web Component) to ease working with ARIA Live Regions.
12+
We have decided to use a single global ARIA live region in `base.html` to ensure live regions are present in the DOM before updates occur. This approach is better than having multiple live regions because it ensures that announcements are delivered in a clear and consistent manner. By centralizing updates, we avoid potential conflicts between overlapping live regions and provide a more seamless experience for screen reader users.
13+
14+
### Live Region in `base.html`
15+
```html
16+
<live-region>
17+
<div id="polite" aria-live="polite" aria-atomic="true" class="hidden-for-sighted"></div>
18+
<div id="assertive" aria-live="assertive" aria-atomic="true" class="hidden-for-sighted"></div>
19+
</live-region>
20+
```
21+
22+
The `@primer/live-region-element` package provides methods to trigger announcements programmatically:
23+
24+
```ts
25+
import { announce } from '@primer/live-region-element';
26+
27+
announce('Example polite message', { politeness: 'polite' });
28+
```
29+
30+
### Using Turbo Streams to Trigger ARIA Live Updates
31+
To use it in a Rails Turbo response, and send updates to the assistive technology after any action, we created a new Turbo stream component that triggers an liveRegion action:
32+
33+
```ruby
34+
OpTurbo::StreamComponent.new(action: :liveRegion, message: "Form submission successful!", politeness: "polite", role: "status", target: nil)
35+
```
36+
37+
- This creates a **Turbo Stream update** with:
38+
- `action: :liveRegion` → Calls the `liveRegion` action defined in `live-region-stream-action.ts`
39+
- `message: "Form submission successful!"` → The text to be announced to user
40+
- `politeness: "polite"` → Ensures the message is read when the user is idle
41+
- `target: nil` → Ensures the update isn't inserted into the DOM but is handled as an announcement
42+
43+
### How to Trigger an ARIA Update from the Controller
44+
You can trigger a assistive technology announcement from your Rails controller by calling the provided `render_live_region_update_message` helper:
45+
46+
```ruby
47+
render_live_region_update_message(
48+
message: "A relation is added",
49+
politeness: "polite"
50+
)
51+
```
52+
53+
This helper internally renders a Turbo Stream using the liveRegion action to announce the message via ARIA live regions.
54+
For reference only — you do not need to define this yourself.
55+
Here is the internal implementation of `render_live_region_update_message`:
56+
57+
```ruby
58+
def render_live_region_update_message(message:, politeness:)
59+
turbo_streams << OpTurbo::StreamComponent.new(action: :liveRegion, message:, politeness:, target: nil).render_in(view_context)
60+
end
61+
```
62+
63+
### How to Trigger an ARIA Update from a Turbo Frame
64+
If you're using a Turbo Frame and want to announce dynamic content changes to assistive technologies, you can embed a Turbo Stream using the `liveRegion` action directly inside the frame.
65+
66+
```erb
67+
<%= content_tag("turbo-frame", id: "frame-id") do %>
68+
....
69+
<%= render OpTurbo::StreamComponent.new(
70+
action: :liveRegion,
71+
message: "Update!!",
72+
politeness: "polite",
73+
target: nil
74+
) %>
75+
<% end %>
76+
77+
```
78+
79+
### How ARIA Live Messages Are Delivered via Turbo Streams
80+
The `registerLiveRegionStreamAction` method defines a custom Turbo Stream action called `liveRegion`. When triggered, it announces a message (using ARIA live regions) to assistive technologies, with a tone of either `"polite"` or `"assertive"`, depending on the `politeness` attribute in the stream element.
81+
82+
```ts
83+
<%= render file: "frontend/src/turbo/live-region-stream-action.ts" %>
84+
```
85+
86+
## Live Regions in Dialogs and Modals
87+
There is an important exception to our use of the global ARIA Live Region in `base.html`.
88+
89+
When a modal or dialog is open, screen readers often restrict focus and reading context to the content inside the dialog. In this case, live regions outside the dialog (such as those defined in `base.html`) become inaccessible to the screen reader.
90+
91+
To ensure that announcements are still read aloud in this situation, we should include a live-region component within the dialog itself, like what we have implemented for primerized date picker.
92+
93+
```erb
94+
<live-region>
95+
</live-region>
96+
<%= render OpTurbo::StreamComponent.new(action: :liveRegion, message: "Date picker is updated.", politeness: "polite", target: nil) %>
97+
```

spec/components/work_packages/date_picker/dialog_content_component_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@
5050
let(:schedule_manually) { true }
5151

5252
it "shows the date form" do
53-
expect(dialog_content).to have_field(I18n.t("attributes.start_date"), aria: { live: :polite })
54-
expect(dialog_content).to have_field(I18n.t("attributes.due_date"), aria: { live: :polite })
55-
expect(dialog_content).to have_field(WorkPackage.human_attribute_name("duration"), aria: { live: :polite })
53+
expect(dialog_content).to have_field(I18n.t("attributes.start_date"))
54+
expect(dialog_content).to have_field(I18n.t("attributes.due_date"))
55+
expect(dialog_content).to have_field(WorkPackage.human_attribute_name("duration"))
5656
end
5757

5858
it "has an enabled save button" do

0 commit comments

Comments
 (0)