Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Make the header toolbar dropdown menu accessible
- 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
Showing
28 changed files
with
1,098 additions
and
127 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
}); | ||
}); | ||
} | ||
} | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
54
indico/web/client/js/custom_elements/ind_with_toggletip.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}); | ||
}); | ||
} | ||
} | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
} | ||
} | ||
); |
Oops, something went wrong.