@ulu/frontend
Version:
A versatile SCSS and JavaScript component library offering configurable, accessible components and flexible integration into any project, with SCSS modules suitable for modern JS frameworks.
202 lines (198 loc) • 6.27 kB
JavaScript
/**
* @module ui/collapsible
*/
import { getName as getEventName } from "../events/index.js";
import { log, logError } from "../utils/class-logger.js";
import { ensureId } from "../utils/id.js";
/**
* Class for accessible hide/show components
*/
export class Collapsible {
static defaults = {
clickOutsideCloses: false,
// oneOpenPerContext: false, // This should be another module that manages instances within a context (accordions)
// clickWithinCloses: false, // Not sure how this was used but seems like it should be separate
focusoutCloses: false,
escapeCloses: false,
/**
* The module won't attach the handlers (you need to do it yourself)
*/
selfManaged: false,
/**
* This collapsible starts in open state
*/
startOpen: false,
/**
* Open/active state class
*/
openClass: "is-active",
/**
* Output debug info
*/
debug: true,
onChange(_ctx) {
// do something
}
};
/**
* @param {Object} elements Elements object
* @param {Node} elements.trigger Trigger button/element that opens/closes collapsible
* @param {Node} elements.content The content element that the trigger reveals
* @param {Object} config Configuration options (see defaults)
* @returns {Object} Collapsible instance
*/
constructor(elements, config) {
const { trigger, content } = elements;
if (!trigger || !content) {
logError(this, "missing required elements (trigger or content)");
return;
}
const options = Object.assign({}, Collapsible.defaults, config);
this.elements = elements;
this.options = options;
this.isOpen = false;
this.handlers = {}; // Spot to cache event handlers
ensureId(trigger);
ensureId(content);
this.debugLog(this, this);
if (!options.selfManaged) {
this.attachHandlers();
}
this.setup();
}
attachHandlers() {
const { trigger, content } = this.elements;
const { focusoutCloses } = this.options;
this.clickHandler = event => {
this.onClick(event);
}
this.focusoutHandler = (event) => {
if (focusoutCloses) {
// If closing on focus out we attach one-time event to
// see which element is focused next (in between focusout and focusin
// it's the body) so doing the logic in focusout won't work
document.addEventListener('focusin', () => {
if (!content.contains(document.activeElement)) {
this.close(event);
}
}, { once: true });
}
};
trigger.addEventListener("click", this.clickHandler);
content.addEventListener("focusout", this.focusoutHandler);
}
removeHandlers() {
const { trigger, content } = this.elements;
trigger.removeEventListener("click", this.clickHandler);
content.removeEventListener("focusout", this.focusoutHandler);
}
onClick(event) {
this.toggle(event);
}
destroy() {
this.removeHandlers();
this.destroyTemporaryHandlers();
}
debugLog(...msgs) {
if (this.options.debug) {
log(this, ...msgs);
}
}
setup() {
const { trigger, content } = this.elements;
const { startOpen } = this.options;
trigger.setAttribute("role", "button");
trigger.setAttribute("aria-controls", content.id);
content.setAttribute("aria-labelledby", trigger.id);
this.setState(startOpen);
}
createEvent(name, detail) {
return new CustomEvent(getEventName("collapsible:" + name), { detail });
}
setState(isOpen, event) {
const ctx = {
collapsible: this,
isOpen,
event
};
this.debugLog(this, "Set state", ctx);
const { trigger, content } = this.elements;
const { openClass } = this.options;
const setClass = el => el.classList[isOpen ? "add" : "remove"](openClass);
trigger.setAttribute("aria-expanded", isOpen ? "true" : "false");
setClass(trigger);
setClass(content);
this.isOpen = isOpen;
this.options.onChange(ctx);
trigger.dispatchEvent(this.createEvent("change", ctx));
if (isOpen) {
this.setupTemporaryHandlers();
} else {
this.destroyTemporaryHandlers();
}
}
/**
* Setup handlers needed for closing once open
*/
setupTemporaryHandlers() {
const { content, trigger } = this.elements;
const { clickOutsideCloses, escapeCloses } = this.options;
const onDocumentClick = (event) => {
const { target } = event;
const inTrigger = trigger.contains(target);
const inContent = content.contains(target);
if (clickOutsideCloses && !inTrigger && !inContent) {
this.close(event);
}
};
const onDocumentKeydown = (event) => {
if (escapeCloses && event.key === "Escape") {
this.close(event);
}
};
document.addEventListener("click", onDocumentClick);
document.addEventListener("keydown", onDocumentKeydown);
this.handlers.onDocumentClick = onDocumentClick;
this.handlers.onDocumentKeydown = onDocumentKeydown;
}
/**
* Destroy handlers attached for closing once open
*/
destroyTemporaryHandlers() {
const { onDocumentClick, onDocumentKeydown } = this.handlers;
if (onDocumentClick) {
document.removeEventListener("click", onDocumentClick);
}
if (onDocumentClick) {
document.removeEventListener("keydown", onDocumentKeydown);
}
}
open(event) {
this.setState(true, event);
}
close(event) {
this.setState(false, event);
}
toggle(event) {
this.setState(!this.isOpen, event);
}
// This is removed because I think it's not useful, users should keep references
// Static Methods for managing instances of this class
// static instances = [];
// /**
// * Get collapsible instance by trigger element
// * @param {Node|String} trigger Trigger node or trigger ID
// */
// static getInstance(trigger) {
// return Collapsible.instances.find(c => typeof trigger === "string" ?
// c.elements.trigger.id === trigger :
// c.elements.trigger === trigger
// );
// }
// static removeInstance(instance) {
// const index = Collapsible.instances.findIndex(c => c === instance);
// if (index > -1) {
// Collapsible.instances.splice(index, 1);
// }
// }
}