Skip to content

Commit

Permalink
Make the header toolbar dropdown menu accessible
Browse files Browse the repository at this point in the history
- Correctly indicate the roles and states of the dropdown menu
- Use a vanilla JavaScript implementation of the dropdown
- Introduce new folders `client/{js,styles}/custom-elements`
- Improve accessibility of the tooltips for the #create-* buttons
  • Loading branch information
foxbunny committed Nov 8, 2023
1 parent 73813de commit 7997604
Show file tree
Hide file tree
Showing 28 changed files with 1,098 additions and 127 deletions.
4 changes: 4 additions & 0 deletions CHANGES.rst
Expand Up @@ -48,6 +48,8 @@ Improvements
:user:`kewisch`)
- Allow editors to edit their review comments on editables (:pr:`6008`)
- Auto-linking of patterns in minutes (e.g. issue trackers, Github repos...) (:pr:`5998`)
- Allow event managers to delete editables from contributions (:pr:`5778`)
- Support custom user-defined stylesheets with text metric overrides (:pr:`5895`)

Bugfixes
^^^^^^^^
Expand Down Expand Up @@ -75,6 +77,7 @@ Accessibility
thanks :user:`foxbunny`)
- Prevent icons from being announced to screen readers as random characters (:issue:`5985`,
:pr:`5986`, thanks :user:`foxbunny`)
- Make dropdown menu fully accessible (:issue:`5896`, :pr:`5897`, thanks :user:`foxbunny`)

Internal Changes
^^^^^^^^^^^^^^^^
Expand All @@ -90,6 +93,7 @@ Internal Changes
the removal of registration form fields (:pr:`5924`)
- Add a tool ``bin/managemnent/icons_generate.py`` to generate CSS for icomoon icons based
on ``selection.json`` (:pr:`5986`, thanks :user:`foxbunny`)
- Add ``<ind-menu>`` custom element for managing drop-down menus (:issue:`5896`, :pr:`5897`, thanks :user:`foxbunny`)


----
Expand Down
123 changes: 123 additions & 0 deletions indico/web/client/js/custom_elements/TipBase.js
@@ -0,0 +1,123 @@
// This file is part of Indico.
// Copyright (C) 2002 - 2023 CERN
//
// Indico is free software; you can redistribute it and/or
// modify it under the terms of the MIT License; see the
// LICENSE file for more details.

import {domReady} from 'indico/utils/domstate';
import './tips.scss';

let viewportWidth = document.documentElement.clientWidth;
let viewportHeight = document.documentElement.clientHeight;

window.addEventListener('DOMContentLoaded', () => requestIdleCallback(updateClientGeometry));
window.addEventListener('resize', updateClientGeometry);
window.addEventListener('orientationchange', updateClientGeometry);

function updateClientGeometry() {
viewportWidth = document.documentElement.clientWidth;
viewportHeight = document.documentElement.clientHeight;
}

export class TipBase extends HTMLElement {
constructor() {
super();
this.updatePosition = this.updatePosition.bind(this);
this.show = this.show.bind(this);
this.hide = this.hide.bind(this);
this.dismiss = this.dismiss.bind(this);
}

connectedCallback() {
domReady.then(() => {
this.$tip = this.querySelector('[data-tip-content]');
console.assert(!!this.$tip, 'Must contain a *[data-tip-content] element');
this.setup();
});
}

disconnectedCallback() {
window.removeEventListener('keydown', this.dismiss);
}

get shown() {
return this.hasAttribute('shown');
}

set shown(isShown) {
if (this.shown === isShown) {
return;
}
this.toggleAttribute('shown', isShown);
this.dispatchEvent(new Event('toggle'));
}

getTipCSS() {
const vw = viewportWidth;
const vh = viewportHeight;
const referenceRect = this.getBoundingClientRect();
const tooltipRect = this.$tip.getBoundingClientRect();

let top = 'auto';
let bottom = 'auto';
const refCenter = `${referenceRect.x + referenceRect.width / 2}px`;
let arrowBorder;

console.log(referenceRect);

// Place above or below?
if (tooltipRect.height < referenceRect.top) {
bottom = `${vh - referenceRect.top}px`;
arrowBorder = 'var(--tooltip-surface-color) transparent transparent';
} else {
top = `${referenceRect.bottom}px`;
arrowBorder = 'transparent transparent var(--tooltip-surface-color)';
}

// Ideal left coordinate (CSS will adjust as needed)
const idealLeft = referenceRect.left + (referenceRect.width - tooltipRect.width) / 2;
// NB: the clamp() formula assumes the tooltip content will always fit the viewport, which is ensured using CSS
const left = `clamp(0.5em, ${idealLeft}px, ${vw - tooltipRect.width}px - 0.5em)`;

return {
top,
bottom,
left,
'ref-center': refCenter,
'arrow-borders': arrowBorder,
};
}

updatePosition() {
this.style = '';
const css = this.getTipCSS();
for (const key in css) {
this.style.setProperty(`--${key}`, css[key]);
}
}

dismiss(evt) {
if (!this.shown || evt.code !== 'Escape') {
return;
}
evt.preventDefault();
this.shown = false;
}

setup() {
window.addEventListener('keydown', this.dismiss);
this.$tip.addEventListener('click', evt => {
evt.preventDefault();
});
this.addEventListener('toggle', () => {
if (this.shown) {
window.addEventListener('resize', this.updatePosition);
window.addEventListener('scroll', this.updatePosition, {passive: true});
} else {
window.removeEventListener('resize', this.updatePosition);
window.removeEventListener('scroll', this.updatePosition);
}
});
}
}
Expand Up @@ -5,13 +5,14 @@
// modify it under the terms of the MIT License; see the
// LICENSE file for more details.

import {domReady} from 'indico/utils/domstate';
import './ind_bypass_block_links.scss';

customElements.define(
'ind-bypass-block-links',
class extends HTMLElement {
connectedCallback() {
window.addEventListener('DOMContentLoaded', () => {
domReady.then(() => {
const bypassBlockTargets = document.querySelectorAll('[id][data-bypass-target]');
for (const target of bypassBlockTargets) {
this.append(
Expand Down
57 changes: 57 additions & 0 deletions indico/web/client/js/custom_elements/ind_menu.js
@@ -0,0 +1,57 @@
// This file is part of Indico.
// Copyright (C) 2002 - 2023 CERN
//
// Indico is free software; you can redistribute it and/or
// modify it under the terms of the MIT License; see the
// LICENSE file for more details.

import './ind_menu.scss';
import {domReady} from 'indico/utils/domstate';

let lastId = 0; // Track the assigned IDs to give each element a unique ID

customElements.define(
'ind-menu',
class extends HTMLElement {
connectedCallback() {
domReady.then(() => {
const trigger = this.querySelector('button');
const list = this.querySelector('menu');

console.assert(
trigger.nextElementSibling === list,
'The <menu> element must come after <button>'
);

trigger.setAttribute('aria-expanded', false);

list.id = list.id || `dropdown-list-${lastId++}`;
trigger.setAttribute('aria-controls', list.id);

trigger.addEventListener('click', function() {
trigger.setAttribute('aria-expanded', trigger.getAttribute('aria-expanded') !== 'true');
});

this.addEventListener('focusout', function() {
// Delay action as no element is focused immediately after focusout
requestAnimationFrame(() => {
if (this.contains(document.activeElement)) {
return;
}
trigger.removeAttribute('aria-expanded');
});
});

this.addEventListener('keydown', function(ev) {
if (!trigger.hasAttribute('aria-expanded')) {
return;
}
if (ev.code === 'Escape') {
trigger.removeAttribute('aria-expanded');
trigger.focus();
}
});
});
}
}
);
49 changes: 49 additions & 0 deletions indico/web/client/js/custom_elements/ind_menu.scss
@@ -0,0 +1,49 @@
// This file is part of Indico.
// Copyright (C) 2002 - 2023 CERN
//
// Indico is free software; you can redistribute it and/or
// modify it under the terms of the MIT License; see the
// LICENSE file for more details.

ind-menu menu {
--time-menu-animation: 0.2s;
--length-menu-travel: -0.4em;
position: absolute;
z-index: 1;
}

ind-menu [aria-expanded='true'] + menu {
animation: menu-slide-down var(--time-menu-animation);
}

ind-menu :not([aria-expanded='true']) + menu {
display: none; // must be set to none to hide it from a11y tools and tab navigation as well
animation: menu-slide-up var(--time-menu-animation);
}

@keyframes menu-slide-down {
from {
display: block;
opacity: 0;
transform: translateY(var(--length-menu-travel));
}

to {
opacity: 1;
transform: none;
}
}

@keyframes menu-slide-up {
from {
display: block;
opacity: 1;
transform: none;
}

to {
display: none;
opacity: 0;
transform: translateY(var(--length-menu-travel));
}
}
54 changes: 54 additions & 0 deletions indico/web/client/js/custom_elements/ind_with_toggletip.js
@@ -0,0 +1,54 @@
// This file is part of Indico.
// Copyright (C) 2002 - 2023 CERN
//
// Indico is free software; you can redistribute it and/or
// modify it under the terms of the MIT License; see the
// LICENSE file for more details.

import {TipBase} from 'indico/custom_elements/TipBase';

const liveRegionUpdateDelay = 100;

customElements.define(
'ind-with-toggletip',
class extends TipBase {
show() {
// XXX: We add a slight delay to ensure that the screen readers will be able to pick up
// the change to the live region.
setTimeout(() => {
this.$tip.innerHTML = this.tipContent;
this.shown = true;
this.updatePosition();
}, liveRegionUpdateDelay);
}

hide() {
this.shown = false;
this.$tip.hidden = true;
this.$tip.innerHTML = '';
}

setup() {
super.setup();

// NB: The tip is an aria-live region. Because of this, it is necessary to clear the content.
// Live regions are (usually) only announced when content changes. (See also the hide() method.)
this.tipContent = this.$tip.innerHTML;
this.$tip.innerHTML = '';

this.addEventListener('click', evt => {
// NB: The toggletip button can trigger the toggle tip even when it is still open,
// so we must clean up just in case.
this.removeEventListener('focusout', this.hide);
this.hide();

const $target = evt.target.closest('button');
if (!$target || !this.contains($target)) {
return;
}
this.show();
this.addEventListener('focusout', this.hide, {once: true});
});
}
}
);
50 changes: 50 additions & 0 deletions indico/web/client/js/custom_elements/ind_with_tooltip.js
@@ -0,0 +1,50 @@
// This file is part of Indico.
// Copyright (C) 2002 - 2023 CERN
//
// Indico is free software; you can redistribute it and/or
// modify it under the terms of the MIT License; see the
// LICENSE file for more details.

import {TipBase} from 'indico/custom_elements/TipBase';

const tipDelay = 1000; // ms

customElements.define(
'ind-with-tooltip',
class extends TipBase {
show() {
// XXX: Delay showing the tip to prevent tip blinking when moving the cursor over multiple items with tooltips
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.shown = true;
this.updatePosition();
}, tipDelay);
}

hide() {
clearTimeout(this.timer);
this.shown = false;
window.removeEventListener('resize', this.updatePosition);
}

setup() {
super.setup();

// XXX: When the tooltip is part of a button/link text, we don't want to trigger the default behavior
this.$tip.addEventListener('click', evt => {
evt.preventDefault();
});

this.addEventListener('pointerenter', () => {
this.show();
this.addEventListener('pointerleave', this.hide, {once: true});
});

this.addEventListener('focusin', () => {
this.removeEventListener('pointerleave', this.hide);
this.show();
this.addEventListener('focusout', this.hide);
});
}
}
);

0 comments on commit 7997604

Please sign in to comment.