Files
squoosh/src/components/compress/custom-els/MultiPanel/index.ts
Jake Archibald f351712130 Building on #275 (#289)
* Upgrade devDependcies. Replace UglifyJS ⚰ with TerserJS 👶 Fix TypeScript compiler errors

* Remove babel and associated plugins

* Re-enable strictNullChecks and noImplicitAny

* Use surma's better ga type definition.
`ts-ignore` document.activeElement potential null warnings

* Avoiding ignores
2018-11-14 14:04:01 -08:00

309 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as style from './styles.css';
import { transitionHeight } from '../../../../lib/util';
interface CloseAllOptions {
exceptFirst?: boolean;
}
const openOneOnlyAttr = 'open-one-only';
function getClosestHeading(el: Element): HTMLElement | undefined {
// Look for the child of multi-panel, but stop at interactive elements like links & buttons
const closestEl = el.closest('multi-panel > *, a, button');
if (closestEl && closestEl.classList.contains(style.panelHeading)) {
return closestEl as HTMLElement;
}
return undefined;
}
async function close(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.removeAttribute('content-expanded');
content.setAttribute('aria-expanded', 'false');
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from,
to: 0,
duration: 300,
});
content.style.height = '';
}
async function open(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.setAttribute('content-expanded', '');
content.setAttribute('aria-expanded', 'true');
const to = content.getBoundingClientRect().height;
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from, to,
duration: 300,
});
content.style.height = '';
}
/**
* A multi-panel view that the user can add any number of 'panels'.
* 'a panel' consists of two elements. Even index element becomes heading,
* and odd index element becomes the expandable content.
*/
export default class MultiPanel extends HTMLElement {
static get observedAttributes() { return [openOneOnlyAttr]; }
constructor() {
super();
// add EventListeners
this.addEventListener('click', this._onClick);
this.addEventListener('keydown', this._onKeyDown);
// Watch for children changes.
new MutationObserver(() => this._childrenChange())
.observe(this, { childList: true });
}
connectedCallback() {
this._childrenChange();
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (name === openOneOnlyAttr && newValue === null) {
this._closeAll({ exceptFirst: true });
}
}
// Click event handler
private _onClick(event: MouseEvent) {
const el = event.target as HTMLElement;
const heading = getClosestHeading(el);
if (!heading) return;
this._toggle(heading);
}
// KeyDown event handler
private _onKeyDown(event: KeyboardEvent) {
const selectedEl = document.activeElement!;
const heading = getClosestHeading(selectedEl);
// if keydown event is not on heading element, ignore
if (!heading) return;
// if something inside of heading has focus, ignore
if (selectedEl !== heading) return;
// dont handle modifier shortcuts used by assistive technology.
if (event.altKey) return;
let newHeading: HTMLElement | undefined;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
newHeading = this._prevHeading();
break;
case 'ArrowRight':
case 'ArrowDown':
newHeading = this._nextHeading();
break;
case 'Home':
newHeading = this._firstHeading();
break;
case 'End':
newHeading = this._lastHeading();
break;
// this has 3 cases listed to support IEs and FF before 37
case 'Enter':
case ' ':
case 'Spacebar':
this._toggle(heading);
break;
// Any other key press is ignored and passed back to the browser.
default:
return;
}
event.preventDefault();
if (newHeading) {
selectedEl.setAttribute('tabindex', '-1');
newHeading.setAttribute('tabindex', '0');
newHeading.focus();
}
}
private _toggle(heading: HTMLElement) {
if (!heading) return;
// toggle expanded and aria-expanded attributes
if (heading.hasAttribute('content-expanded')) {
close(heading);
} else {
if (this.openOneOnly) this._closeAll();
open(heading);
}
}
private _closeAll(options: CloseAllOptions = {}): void {
const { exceptFirst = false } = options;
let els = [...this.children].filter(el => el.matches('[content-expanded]')) as HTMLElement[];
if (exceptFirst) {
els = els.slice(1);
}
for (const el of els) close(el);
}
// children of multi-panel should always be even number (heading/content pair)
private _childrenChange() {
let preserveTabIndex = false;
let heading = this.firstElementChild;
while (heading) {
const content = heading.nextElementSibling;
const randomId = Math.random().toString(36).substr(2, 9);
// if at the end of this loop, runout of element for content,
// it means it has odd number of elements. log error and set heading to end the loop.
if (!content) {
console.error('<multi-panel> requires an even number of element children.');
break;
}
// When odd number of elements were inserted in the middle,
// what was heading before may become content after the insertion.
// Remove classes and attributes to prepare for this change.
heading.classList.remove(style.panelContent);
content.classList.remove(style.panelHeading);
heading.removeAttribute('aria-expanded');
heading.removeAttribute('content-expanded');
// If appreciable, remove tabindex from content which used to be header.
content.removeAttribute('tabindex');
// Assign heading and content classes
heading.classList.add(style.panelHeading);
content.classList.add(style.panelContent);
// Assign ids and aria-X for heading/content pair.
heading.id = `panel-heading-${randomId}`;
heading.setAttribute('aria-controls', `panel-content-${randomId}`);
content.id = `panel-content-${randomId}`;
content.setAttribute('aria-labelledby', `panel-heading-${randomId}`);
// If tabindex 0 is assigned to a heading, flag to preserve tab index position.
// Otherwise, make sure tabindex -1 is set to heading elements.
if (heading.getAttribute('tabindex') === '0') {
preserveTabIndex = true;
} else {
heading.setAttribute('tabindex', '-1');
}
// It's possible that the heading & content expanded attributes are now out of sync. Resync
// them using the heading as the source of truth.
content.setAttribute(
'aria-expanded',
heading.hasAttribute('content-expanded') ? 'true' : 'false',
);
// next sibling of content = next heading
heading = content.nextElementSibling;
}
// if no flag, make 1st heading as tabindex 0 (otherwise keep previous tab index position).
if (!preserveTabIndex && this.firstElementChild) {
this.firstElementChild.setAttribute('tabindex', '0');
}
// In case we're openOneOnly, and an additional open item has been added:
if (this.openOneOnly) this._closeAll({ exceptFirst: true });
}
// returns heading that is before currently selected one.
private _prevHeading() {
// activeElement would be the currently selected heading
// 2 elements before that would be the previous heading unless it is the first element.
if (this.firstElementChild === document.activeElement) {
return this.firstElementChild as HTMLElement;
}
// previous Element of active Element is previous Content,
// previous Element of previous Content is previousHeading
const previousContent = document.activeElement!.previousElementSibling;
if (previousContent) {
return previousContent.previousElementSibling as HTMLElement;
}
}
// returns heading that is after currently selected one.
private _nextHeading() {
// activeElement would be the currently selected heading
// 2 elemements after that would be the next heading.
const nextContent = document.activeElement!.nextElementSibling;
if (nextContent) {
return nextContent.nextElementSibling as HTMLElement;
}
}
// returns first heading in multi-panel.
private _firstHeading() {
// first element is always first heading
return this.firstElementChild as HTMLElement;
}
// returns last heading in multi-panel.
private _lastHeading() {
// if the last element is heading, return last element
const lastEl = this.lastElementChild as HTMLElement;
if (lastEl && lastEl.classList.contains(style.panelHeading)) {
return lastEl;
}
// otherwise return 2nd from the last
const lastContent = this.lastElementChild;
if (lastContent) {
return lastContent.previousElementSibling as HTMLElement;
}
}
/**
* If true, only one panel can be open at once. When one opens, others close.
*/
get openOneOnly() {
return this.hasAttribute(openOneOnlyAttr);
}
set openOneOnly(val: boolean) {
if (val) {
this.setAttribute(openOneOnlyAttr, '');
} else {
this.removeAttribute(openOneOnlyAttr);
}
}
}
customElements.define('multi-panel', MultiPanel);