Merge pull request #7289 from tchak/feat-stimulus-menu-button

Améliore l'accessibilité des boutons qui font apparaître un menu
This commit is contained in:
Paul Chavard 2022-05-11 16:27:47 +02:00 committed by GitHub
commit bc9a90bd08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 323 additions and 74 deletions

View file

@ -6,6 +6,7 @@ import { GeoAreaController } from './geo_area_controller';
import { TurboInputController } from './turbo_input_controller';
import { AutosaveController } from './autosave_controller';
import { AutosaveStatusController } from './autosave_status_controller';
import { MenuButtonController } from './menu_button_controller';
const Stimulus = Application.start();
Stimulus.register('react', ReactController);
@ -14,3 +15,4 @@ Stimulus.register('geo-area', GeoAreaController);
Stimulus.register('turbo-input', TurboInputController);
Stimulus.register('autosave', AutosaveController);
Stimulus.register('autosave-status', AutosaveStatusController);
Stimulus.register('menu-button', MenuButtonController);

View file

@ -0,0 +1,257 @@
import { ApplicationController } from './application_controller';
export class MenuButtonController extends ApplicationController {
static targets = ['button', 'menu'];
declare readonly buttonTarget: HTMLButtonElement;
declare readonly menuTarget: HTMLElement;
#isOpen = false;
#teardown?: () => void;
connect() {
this.setup();
}
disconnect(): void {
this.#teardown?.();
}
private get isMenu() {
return !(this.element as HTMLElement).dataset.popover;
}
private setup() {
this.buttonTarget.setAttribute(
'aria-haspopup',
this.isMenu ? 'menu' : 'true'
);
this.buttonTarget.setAttribute('aria-controls', this.menuTarget.id);
if (!this.buttonTarget.id) {
this.buttonTarget.id = `${this.menuTarget.id}_button`;
}
this.menuTarget.setAttribute('aria-labelledby', this.buttonTarget.id);
this.menuTarget.setAttribute('role', this.isMenu ? 'menu' : 'region');
this.menuTarget.classList.add('fade-in-down');
this.menuTarget.setAttribute('tab-index', '-1');
if (this.isMenu) {
for (const menuItem of this.menuTarget.querySelectorAll('a')) {
menuItem.setAttribute('role', 'menuitem');
}
}
this.on('click', (event) => {
const target = event.target as HTMLElement;
if (this.buttonTarget == target || this.buttonTarget.contains(target)) {
event.preventDefault();
if (this.#isOpen) {
this.close();
} else {
this.open();
}
}
});
this.on('keydown', (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (this.buttonTarget == target) {
this.onButtonKeydown(event);
} else if (
this.isMenu &&
(this.menuTarget == target || this.menuTarget.contains(target))
) {
this.onMenuKeydown(event);
}
});
}
private open(focusMenuItem: 'first' | 'last' = 'first') {
this.buttonTarget.setAttribute('aria-expanded', '');
this.menuTarget.parentElement?.classList.add('open');
this.menuTarget.focus();
const onClickBody = (event: Event) => {
const target = event.target as HTMLElement;
if (this.isClickOutside(target)) {
this.menuTarget.classList.remove('fade-in-down');
this.close();
}
};
requestAnimationFrame(() => {
if (focusMenuItem == 'first') {
this.setFocusToFirstMenuitem();
} else {
this.setFocusToLastMenuitem();
}
document.body.addEventListener('click', onClickBody);
});
this.#isOpen = true;
this.#teardown = () =>
document.body.removeEventListener('click', onClickBody);
}
private close() {
this.buttonTarget.removeAttribute('aria-expanded');
this.menuTarget.parentElement?.classList.remove('open');
this.#teardown?.();
this.setFocusToMenuitem(null);
this.#isOpen = false;
}
private isClickOutside(target: HTMLElement) {
return (
target.isConnected &&
!this.element.contains(target) &&
!target.closest('reach-portal') &&
this.#isOpen
);
}
private get currentMenuItem() {
return this.menuTarget.querySelector<HTMLElement>(
'[role="menuitem"]:focus'
);
}
private get menuItems() {
return [
...this.menuTarget.querySelectorAll<HTMLElement>('[role="menuitem"]')
];
}
private setFocusToMenuitem(menuItem: HTMLElement | null) {
if (menuItem) {
menuItem.focus();
} else {
this.buttonTarget.focus();
}
}
private setFocusToFirstMenuitem() {
this.setFocusToMenuitem(this.menuItems[0]);
}
private setFocusToLastMenuitem() {
const length = this.menuItems.length;
this.setFocusToMenuitem(this.menuItems[length - 1]);
}
setFocusToPreviousMenuitem() {
const { currentMenuItem, menuItems } = this;
if (currentMenuItem) {
const index = menuItems.indexOf(currentMenuItem);
if (index == 0) {
this.setFocusToLastMenuitem();
} else {
this.setFocusToMenuitem(menuItems[index - 1]);
}
}
}
setFocusToNextMenuitem() {
const { currentMenuItem, menuItems } = this;
if (currentMenuItem) {
const index = menuItems.indexOf(currentMenuItem);
if (index == menuItems.length - 1) {
this.setFocusToFirstMenuitem();
} else {
this.setFocusToMenuitem(menuItems[index + 1]);
}
}
}
performMenuAction(target: EventTarget | null) {
target?.dispatchEvent(new Event('click'));
}
private onButtonKeydown(event: KeyboardEvent) {
let stopPropagation = false;
switch (event.key) {
case ' ':
case 'Enter':
case 'ArrowDown':
case 'Down':
this.open();
stopPropagation = true;
break;
case 'Esc':
case 'Escape':
this.close();
stopPropagation = true;
break;
case 'Up':
case 'ArrowUp':
this.open('last');
stopPropagation = true;
break;
default:
break;
}
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
}
onMenuKeydown(event: KeyboardEvent) {
let stopPropagation = false;
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
if (event.shiftKey) {
if (event.key == 'Tab') {
this.close();
stopPropagation = true;
}
} else {
switch (event.key) {
case ' ':
this.performMenuAction(event.target);
stopPropagation = true;
break;
case 'Esc':
case 'Escape':
this.close();
stopPropagation = true;
break;
case 'Up':
case 'ArrowUp':
this.setFocusToPreviousMenuitem();
stopPropagation = true;
break;
case 'ArrowDown':
case 'Down':
this.setFocusToNextMenuitem();
stopPropagation = true;
break;
case 'Home':
case 'PageUp':
this.setFocusToFirstMenuitem();
stopPropagation = true;
break;
case 'End':
case 'PageDown':
this.setFocusToLastMenuitem();
stopPropagation = true;
break;
case 'Tab':
this.close();
break;
default:
break;
}
}
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
}
}

View file

@ -1,22 +0,0 @@
import { delegate } from '@utils';
delegate('click', 'body', (event) => {
if (!event.target.closest('.dropdown, [data-reach-combobox-popover]')) {
[...document.querySelectorAll('.dropdown')].forEach((element) => {
const button = element.querySelector('.dropdown-button');
button.setAttribute('aria-expanded', false);
element.classList.remove('open', 'fade-in-down');
});
}
});
delegate('click', '.dropdown-button', (event) => {
event.stopPropagation();
const button = event.target.closest('.dropdown-button');
const parent = button.parentElement;
if (parent.classList.contains('dropdown')) {
parent.classList.toggle('open');
var buttonExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !buttonExpanded);
}
});

View file

@ -13,7 +13,6 @@ import '../shared/ujs-error-handling';
import { registerComponents } from '../controllers/react_controller';
import '../controllers';
import '../new_design/dropdown';
import '../new_design/form-validation';
import '../new_design/procedure-context';
import '../new_design/procedure-form';

View file

@ -40,3 +40,16 @@ const fragment = document.createDocumentFragment();
if (fragment.children == undefined) {
polyfillChildren(DocumentFragment.prototype);
}
// IE 11 has no isConnected on Node
function polyfillIsConnected(proto) {
Object.defineProperty(proto, 'isConnected', {
get: function () {
return document.documentElement.contains(this);
}
});
}
if (!('isConnected' in Node.prototype)) {
polyfillIsConnected(Node.prototype);
}

View file

@ -33,10 +33,10 @@
= link_to admin_procedure_path(procedure), class: 'button mr-1 edit-procedure' do
%span.icon.edit
Modifier
.dropdown
.button.dropdown-button.procedures-actions-btn{ 'aria-expanded' => 'false', 'aria-controls' => 'actions-menu' }
.dropdown{ data: { controller: 'menu-button' } }
.button.dropdown-button.procedures-actions-btn{ data: { menu_button_target: 'button' } }
Actions
#actions-menu.dropdown-content.fade-in-down
#actions-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
%ul.dropdown-items.pl-0
- if !procedure.close? && !procedure.discarded?
%li

View file

@ -7,10 +7,10 @@
%li= link_to("Dossier nº #{dossier.id}", expert_avis_path(avis.procedure, avis))
.header-actions
%span.dropdown.print-menu-opener
%button.button.dropdown-button.icon-only{ 'aria-expanded' => 'false', 'aria-controls' => 'print-pj-menu' }
%span.dropdown.print-menu-opener{ data: { controller: 'menu-button' } }
%button.button.dropdown-button.icon-only{ data: { menu_button_target: 'button' } }
%span.icon.attached
%ul.print-menu.dropdown-content
%ul.print-menu.dropdown-content#print-pj-menu{ data: { menu_button_target: 'menu' } }
%li= link_to "Télécharger le dossier et toutes ses pièces jointes", telecharger_pjs_expert_avis_path(avis.procedure, avis), target: "_blank", rel: "noopener", class: "menu-item menu-link"
%nav.tabs

View file

@ -1,7 +1,7 @@
%span.dropdown.print-menu-opener
%button.button.dropdown-button.icon-only{ title: 'imprimer', 'aria-label': 'imprimer', 'aria-expanded' => 'false', 'aria-controls' => 'print-menu' }
%span.dropdown.print-menu-opener{ data: { controller: 'menu-button' } }
%button.button.dropdown-button.icon-only{ title: 'imprimer', 'aria-label': 'imprimer', data: { menu_button_target: 'button' } }
%span.icon.printer
%ul#print-menu.print-menu.dropdown-content
%ul#print-menu.print-menu.dropdown-content{ data: { menu_button_target: 'menu' } }
%li
= link_to "Tout le dossier", print_instructeur_dossier_path(dossier.procedure, dossier), target: "_blank", rel: "noopener", class: "menu-item menu-link"
%li
@ -12,10 +12,10 @@
%li
= link_to "Export GeoJSON", geo_data_instructeur_dossier_path(dossier.procedure, dossier), target: "_blank", rel: "noopener", class: "menu-item menu-link"
%span.dropdown.print-menu-opener
%button.button.dropdown-button.icon-only{ 'aria-expanded' => 'false', 'aria-controls' => 'print-pj-menu' }
%span.dropdown.print-menu-opener{ data: { controller: 'menu-button' } }
%button.button.dropdown-button.icon-only{ data: { menu_button_target: 'button' } }
%span.icon.attached
%ul#print-pj-menu.print-menu.dropdown-content
%ul#print-pj-menu.print-menu.dropdown-content{ data: { menu_button_target: 'menu' } }
%li= link_to "Télécharger le dossier et toutes ses pièces jointes", telecharger_pjs_instructeur_dossier_path(dossier.procedure, dossier), target: "_blank", rel: "noopener", class: "menu-item menu-link"
= render partial: "instructeurs/procedures/dossier_actions",

View file

@ -1,10 +1,10 @@
.dropdown
.dropdown{ data: { controller: 'menu-button', popover: 'true' } }
-# Dropdown button title
%button.button.primary.dropdown-button{ class: button_or_label_class(dossier), 'aria-expanded' => 'false', 'aria-controls' => 'state-menu' }
%button.button.primary.dropdown-button{ class: button_or_label_class(dossier), data: { menu_button_target: 'button' } }
= dossier_display_state dossier
-# Dropdown content
#state-menu.dropdown-content.fade-in-down
#state-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
- if dossier.en_construction?
-# ------------------------------------------------------

View file

@ -2,10 +2,10 @@
= link_to restore_instructeur_dossier_path(procedure_id, dossier_id), method: :patch, class: "button" do
= t('views.instructeurs.dossiers.restore')
- elsif close_to_expiration || Dossier::TERMINE.include?(state)
.dropdown.user-dossier-actions
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'actions-menu' }
.dropdown.user-dossier-actions{ data: { controller: 'menu-button' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
Actions
#actions-menu.dropdown-content.fade-in-down
#actions-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
%ul.dropdown-items
- if close_to_expiration
%li

View file

@ -1,7 +1,7 @@
%span.dropdown
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'download-menu' }
%span.dropdown{ data: { controller: 'menu-button' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
= t(".download", count: count)
#download-menu.dropdown-content.fade-in-down{ style: 'width: 450px' }
#download-menu.dropdown-content.fade-in-down{ style: 'width: 450px', data: { menu_button_target: 'menu' } }
%ul.dropdown-items
- exports_list(exports, statut).each do |item|
- format = item[:format]

View file

@ -1,7 +1,7 @@
%span.dropdown
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'filter-menu' }
%span.dropdown{ data: { controller: 'menu-button', popover: 'true' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
= t('views.instructeurs.dossiers.filters.title')
#filter-menu.dropdown-content.left-aligned.fade-in-down
#filter-menu.dropdown-content.left-aligned.fade-in-down{ data: { menu_button_target: 'menu' } }
= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large' do
= label_tag :field, t('views.instructeurs.dossiers.filters.column')
= select_tag :field, options_for_select(displayed_fields_options)

View file

@ -77,10 +77,10 @@
= render partial: "header_field", locals: { field: field, classname: field['classname'] }
%th.action-col.follow-col
%span.dropdown
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'custom-menu' }
%span.dropdown{ data: { controller: 'menu-button', popover: 'true' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
= t('views.instructeurs.dossiers.personalize')
#custom-menu.dropdown-content.fade-in-down
#custom-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
= form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do
= hidden_field_tag :values, nil
= react_component("ComboMultiple",

View file

@ -1,5 +1,5 @@
.dropdown.invite-user-action
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'invite-content' }
.dropdown.invite-user-action{ data: { controller: 'menu-button', popover: 'true' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
%span.icon.person
- if dossier.invites.count > 0
= t('views.invites.dropdown.view_invited_people')
@ -10,5 +10,5 @@
- else
= t('views.invites.dropdown.invite_to_edit')
#invite-content.dropdown-content.fade-in-down
#invite-content.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
= render partial: "invites/form", locals: { dossier: dossier }

View file

@ -1,8 +1,8 @@
.dropdown.header-menu-opener
%button.button.dropdown-button.icon-only.header-menu-button{ title: "Mon compte", 'aria-expanded' => 'false', 'aria-controls' => 'mon_compte_menu' }
.dropdown.header-menu-opener{ data: { controller: 'menu-button' } }
%button.button.dropdown-button.icon-only.header-menu-button{ title: "Mon compte", data: { menu_button_target: 'button' } }
.hidden Mon compte
= image_tag "icons/account-circle.svg", alt: 'Mon compte', width: 24, height: 24, loading: 'lazy'
%ul.header-menu.dropdown-content#mon_compte_menu
%ul.header-menu.dropdown-content#mon_compte_menu{ data: { menu_button_target: 'menu' } }
%li
.menu-item{ title: current_email }
= current_email

View file

@ -1,8 +1,8 @@
.dropdown.locale-dropdown.header-menu-opener
%button.button.dropdown-button.icon-only.header-menu-button{ title: t('.languages'), aria: { expanded: 'false', controls: 'locale_menu' } }
.dropdown.locale-dropdown.header-menu-opener{ data: { controller: 'menu-button' } }
%button.button.dropdown-button.icon-only.header-menu-button{ title: t('.languages'), data: { menu_button_target: 'button' } }
.hidden t('.languages')
= image_tag "icons/translate-icon.svg", alt: t('.languages'), width: 24, height: 24, lazy: true, aria: { hidden: true }
%ul.header-menu.dropdown-content
%ul.header-menu.dropdown-content{ data: { menu_button_target: 'menu' } }
%li
= active_link_to save_locale_path(locale: :fr), method: :post, class: "menu-item menu-link", active: I18n.locale == :fr do
Français

View file

@ -1,7 +1,7 @@
.dropdown.help-dropdown
%button.button.primary.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'help-menu' }
.dropdown.help-dropdown{ data: { controller: 'menu-button' } }
%button.button.primary.dropdown-button{ data: { menu_button_target: 'button' } }
= t('help')
#help-menu.dropdown-content.fade-in-down
#help-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
%ul.dropdown-items
- title = dossier.brouillon? ? "Besoin daide pour remplir votre dossier ?" : "Une question sur votre dossier ?"

View file

@ -1,7 +1,7 @@
.dropdown.help-dropdown
%button.button.primary.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'help-menu' }
.dropdown.help-dropdown{ data: { controller: 'menu-button' } }
%button.button.primary.dropdown-button{ data: { menu_button_target: 'button' } }
= t('help')
#help-menu.dropdown-content.fade-in-down
#help-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
%ul.dropdown-items
= render partial: 'shared/help/dropdown_items/faq_item'
= render partial: 'shared/help/dropdown_items/email_item'

View file

@ -1,7 +1,7 @@
.dropdown.help-dropdown
%button.button.primary.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'help-menu' }
.dropdown.help-dropdown{ data: { controller: 'menu-button' } }
%button.button.primary.dropdown-button{ data: { menu_button_target: 'button' } }
= t('help')
#help-menu.dropdown-content.fade-in-down
#help-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
%ul.dropdown-items
- if procedure.service.present?
= render partial: 'shared/help/dropdown_items/service_item',

View file

@ -5,10 +5,10 @@
- has_actions = has_edit_action || has_delete_action || has_new_dossier_action || has_transfer_action
- if has_actions
.dropdown.user-dossier-actions
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'actions-menu' }
.dropdown.user-dossier-actions{ data: { controller: 'menu-button' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
= t('views.users.dossiers.dossier_action.actions')
#actions-menu.dropdown-content.fade-in-down
#actions-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
%ul.dropdown-items
- if has_edit_action
- if dossier.brouillon?

View file

@ -1,6 +1,6 @@
%span.dropdown.print-menu-opener
%button.button.dropdown-button.icon-only{ title: t('views.users.dossiers.show.header.print'), 'aria-label': 'imprimer', 'aria-expanded' => 'false', 'aria-controls' => 'print-menu' }
%span.dropdown.print-menu-opener{ data: { controller: 'menu-button' } }
%button.button.dropdown-button.icon-only{ title: t('views.users.dossiers.show.header.print'), 'aria-label': 'imprimer', data: { menu_button_target: 'button' } }
%span.icon.printer
%ul#print-menu.print-menu.dropdown-content
%ul#print-menu.print-menu.dropdown-content{ data: { menu_button_target: 'menu' } }
%li
= link_to t('views.users.dossiers.show.header.print_dossier'), dossier_path(dossier, format: :pdf), target: "_blank", rel: "noopener", class: "menu-item menu-link"