preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
620 lines (518 loc) • 15.8 kB
text/typescript
/*
* HSDropdown
* @version: 2.5.0
* @author: Preline Labs Ltd.
* @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html)
* Copyright 2024 Preline Labs Ltd.
*/
import {
getClassProperty,
getClassPropertyAlt,
isIOS,
isIpadOS,
dispatch,
afterTransition,
menuSearchHistory,
} from '../../utils';
import { IMenuSearchHistory } from '../../utils/interfaces';
import { createPopper, PositioningStrategy } from '@popperjs/core';
import { IDropdown, IHTMLElementPopper } from '../dropdown/interfaces';
import HSBasePlugin from '../base-plugin';
import { ICollectionItem } from '../../interfaces';
import { POSITIONS, DROPDOWN_ACCESSIBILITY_KEY_SET } from '../../constants';
class HSDropdown
extends HSBasePlugin<{}, IHTMLElementPopper>
implements IDropdown
{
private static history: IMenuSearchHistory;
private readonly toggle: HTMLElement | null;
private readonly closers: HTMLElement[] | null;
public menu: HTMLElement | null;
private eventMode: string;
private closeMode: string;
private animationInProcess: boolean;
constructor(el: IHTMLElementPopper, options?: {}, events?: {}) {
super(el, options, events);
this.toggle =
this.el.querySelector(':scope > .hs-dropdown-toggle') ||
this.el.querySelector(
':scope > .hs-dropdown-toggle-wrapper > .hs-dropdown-toggle',
) ||
(this.el.children[0] as HTMLElement);
this.closers =
Array.from(this.el.querySelectorAll(':scope .hs-dropdown-close')) || null;
this.menu = this.el.querySelector(':scope > .hs-dropdown-menu');
this.eventMode = getClassProperty(this.el, '--trigger', 'click');
this.closeMode = getClassProperty(this.el, '--auto-close', 'true');
this.animationInProcess = false;
if (this.toggle && this.menu) this.init();
}
private init() {
this.createCollection(window.$hsDropdownCollection, this);
if ((this.toggle as HTMLButtonElement).disabled) return false;
if (this.toggle) this.buildToggle();
if (this.menu) this.buildMenu();
if (this.closers) this.buildClosers();
if (!isIOS() && !isIpadOS()) {
this.el.addEventListener('mouseenter', () => this.onMouseEnterHandler());
this.el.addEventListener('mouseleave', () => this.onMouseLeaveHandler());
}
}
resizeHandler() {
this.eventMode = getClassProperty(this.el, '--trigger', 'click');
this.closeMode = getClassProperty(this.el, '--auto-close', 'true');
}
private buildToggle() {
if (this?.toggle?.ariaExpanded) {
if (this.el.classList.contains('open')) this.toggle.ariaExpanded = 'true';
else this.toggle.ariaExpanded = 'false';
}
this.toggle.addEventListener('click', (evt) => this.onClickHandler(evt));
}
private buildMenu() {
this.menu.role = 'menu';
}
private buildClosers() {
this.closers.forEach((el: HTMLButtonElement) => {
el.addEventListener('click', () => this.close());
});
}
private onClickHandler(evt: Event) {
if (
this.el.classList.contains('open') &&
!this.menu.classList.contains('hidden')
) {
this.close();
} else {
this.open();
}
}
private onMouseEnterHandler() {
if (this.eventMode !== 'hover') return false;
if (this.el._popper) this.forceClearState();
if (
!this.el.classList.contains('open') &&
this.menu.classList.contains('hidden')
) {
this.open();
}
}
private onMouseLeaveHandler() {
if (this.eventMode !== 'hover') return false;
if (
this.el.classList.contains('open') &&
!this.menu.classList.contains('hidden')
) {
this.close();
}
}
private destroyPopper() {
this.menu.classList.remove('block');
this.menu.classList.add('hidden');
this.menu.style.inset = null;
this.menu.style.position = null;
if (this.el && this.el._popper) this.el._popper.destroy();
this.animationInProcess = false;
}
private absoluteStrategyModifiers() {
return [
{
name: 'applyStyles',
fn: (data: any) => {
const strategy = (
window.getComputedStyle(this.el).getPropertyValue('--strategy') ||
'absolute'
).replace(' ', '');
const adaptive = (
window.getComputedStyle(this.el).getPropertyValue('--adaptive') ||
'adaptive'
).replace(' ', '');
data.state.elements.popper.style.position = strategy;
data.state.elements.popper.style.transform =
adaptive === 'adaptive' ? data.state.styles.popper.transform : null;
data.state.elements.popper.style.top = null;
data.state.elements.popper.style.bottom = null;
data.state.elements.popper.style.left = null;
data.state.elements.popper.style.right = null;
data.state.elements.popper.style.margin = 0;
},
},
];
}
// Public methods
public open() {
if (this.el.classList.contains('open')) return false;
if (this.animationInProcess) return false;
this.animationInProcess = true;
const placement = (
window.getComputedStyle(this.el).getPropertyValue('--placement') || ''
).replace(' ', '');
const flip = (
window.getComputedStyle(this.el).getPropertyValue('--flip') || 'true'
).replace(' ', '');
const strategy = (
window.getComputedStyle(this.el).getPropertyValue('--strategy') || 'fixed'
).replace(' ', '') as PositioningStrategy;
const offset = parseInt(
(
window.getComputedStyle(this.el).getPropertyValue('--offset') || '10'
).replace(' ', ''),
);
const gpuAcceleration = (
window.getComputedStyle(this.el).getPropertyValue('--gpu-acceleration') ||
'true'
).replace(' ', '');
if (strategy !== ('static' as PositioningStrategy)) {
this.el._popper = createPopper(this.el, this.menu, {
placement: POSITIONS[placement] || 'bottom-start',
strategy: strategy,
modifiers: [
...(strategy !== 'fixed' ? this.absoluteStrategyModifiers() : []),
{
name: 'flip',
enabled: flip === 'true',
},
{
name: 'offset',
options: {
offset: [0, offset],
},
},
{
name: 'computeStyles',
options: {
adaptive: strategy !== 'fixed' ? false : true,
gpuAcceleration: gpuAcceleration === 'true' ? true : false,
},
},
],
});
}
this.menu.style.margin = null;
this.menu.classList.remove('hidden');
this.menu.classList.add('block');
setTimeout(() => {
if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'true';
this.el.classList.add('open');
this.animationInProcess = false;
});
this.fireEvent('open', this.el);
dispatch('open.hs.dropdown', this.el, this.el);
}
public close(isAnimated = true) {
if (this.animationInProcess || !this.el.classList.contains('open'))
return false;
const clearAfterClose = () => {
this.menu.style.margin = null;
if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = 'false';
this.el.classList.remove('open');
this.fireEvent('close', this.el);
dispatch('close.hs.dropdown', this.el, this.el);
};
this.animationInProcess = true;
if (isAnimated) {
const el: HTMLElement =
this.el.querySelector('[data-hs-dropdown-transition]') || this.menu;
afterTransition(el, () => this.destroyPopper());
} else this.destroyPopper();
clearAfterClose();
}
public forceClearState() {
this.destroyPopper();
this.menu.style.margin = null;
this.el.classList.remove('open');
}
// Static methods
static getInstance(target: HTMLElement | string, isInstance?: boolean) {
const elInCollection = window.$hsDropdownCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string' ? document.querySelector(target) : target),
);
return elInCollection
? isInstance
? elInCollection
: elInCollection.element.el
: null;
}
static autoInit() {
if (!window.$hsDropdownCollection) window.$hsDropdownCollection = [];
document
.querySelectorAll('.hs-dropdown:not(.--prevent-on-load-init)')
.forEach((el: IHTMLElementPopper) => {
if (
!window.$hsDropdownCollection.find(
(elC) => (elC?.element?.el as HTMLElement) === el,
)
)
new HSDropdown(el);
});
if (window.$hsDropdownCollection) {
document.addEventListener('keydown', (evt) =>
HSDropdown.accessibility(evt),
);
window.addEventListener('click', (evt) => {
const evtTarget = evt.target;
HSDropdown.closeCurrentlyOpened(evtTarget as HTMLElement);
});
let prevWidth = window.innerWidth;
window.addEventListener('resize', () => {
if (window.innerWidth !== prevWidth) {
prevWidth = innerWidth;
HSDropdown.closeCurrentlyOpened(null, false);
}
});
}
}
static open(target: HTMLElement) {
const elInCollection = window.$hsDropdownCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string' ? document.querySelector(target) : target),
);
if (
elInCollection &&
elInCollection.element.menu.classList.contains('hidden')
)
elInCollection.element.open();
}
static close(target: HTMLElement) {
const elInCollection = window.$hsDropdownCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string' ? document.querySelector(target) : target),
);
if (
elInCollection &&
!elInCollection.element.menu.classList.contains('hidden')
) {
elInCollection.element.close();
}
}
// Accessibility methods
static accessibility(evt: KeyboardEvent) {
this.history = menuSearchHistory;
const target: ICollectionItem<HSDropdown> | null =
window.$hsDropdownCollection.find((el) =>
el.element.el.classList.contains('open'),
);
if (
target &&
(DROPDOWN_ACCESSIBILITY_KEY_SET.includes(evt.code) ||
(evt.code.length === 4 &&
evt.code[evt.code.length - 1].match(/^[A-Z]*$/))) &&
!evt.metaKey &&
!target.element.menu.querySelector('input:focus') &&
!target.element.menu.querySelector('textarea:focus')
) {
switch (evt.code) {
case 'Escape':
if (!target.element.menu.querySelector('.hs-select.active')) {
evt.preventDefault();
this.onEscape(evt);
}
break;
case 'Enter':
if (
!target.element.menu.querySelector('.hs-select button:focus') &&
!target.element.menu.querySelector('.hs-collapse-toggle:focus')
) {
this.onEnter(evt);
}
break;
case 'ArrowUp':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onArrow();
break;
case 'ArrowDown':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onArrow(false);
break;
case 'Home':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onStartEnd();
break;
case 'End':
evt.preventDefault();
evt.stopImmediatePropagation();
this.onStartEnd(false);
break;
default:
evt.preventDefault();
this.onFirstLetter(evt.key);
break;
}
}
}
static onEscape(evt: KeyboardEvent) {
const dropdown = (evt.target as HTMLElement).closest('.hs-dropdown.open');
if (window.$hsDropdownCollection.find((el) => el.element.el === dropdown)) {
const target = window.$hsDropdownCollection.find(
(el) => el.element.el === dropdown,
);
if (target) {
target.element.close();
target.element.toggle.focus();
}
} else {
this.closeCurrentlyOpened();
}
}
static onEnter(evt: KeyboardEvent) {
const dropdown = (evt.target as HTMLElement).parentElement;
if (window.$hsDropdownCollection.find((el) => el.element.el === dropdown)) {
evt.preventDefault();
const target = window.$hsDropdownCollection.find(
(el) => el.element.el === dropdown,
);
if (target) target.element.open();
}
}
static onArrow(isArrowUp = true) {
const target = window.$hsDropdownCollection.find((el) =>
el.element.el.classList.contains('open'),
);
if (target) {
const menu = target.element.menu;
if (!menu) return false;
const preparedLinks = isArrowUp
? Array.from(
menu.querySelectorAll(
'a:not([hidden]), .hs-dropdown > button:not([hidden])',
),
).reverse()
: Array.from(
menu.querySelectorAll(
'a:not([hidden]), .hs-dropdown > button:not([hidden])',
),
);
const links = preparedLinks.filter(
(el: any) => !el.classList.contains('disabled'),
);
const current = menu.querySelector('a:focus, button:focus');
let currentInd = links.findIndex((el: any) => el === current);
if (currentInd + 1 < links.length) {
currentInd++;
}
(links[currentInd] as HTMLButtonElement | HTMLAnchorElement).focus();
}
}
static onStartEnd(isStart = true) {
const target = window.$hsDropdownCollection.find((el) =>
el.element.el.classList.contains('open'),
);
if (target) {
const menu = target.element.menu;
if (!menu) return false;
const preparedLinks = isStart
? Array.from(menu.querySelectorAll('a'))
: Array.from(menu.querySelectorAll('a')).reverse();
const links = preparedLinks.filter(
(el: any) => !el.classList.contains('disabled'),
);
if (links.length) {
links[0].focus();
}
}
}
static onFirstLetter(code: string) {
const target = window.$hsDropdownCollection.find((el) =>
el.element.el.classList.contains('open'),
);
if (target) {
const menu = target.element.menu;
if (!menu) return false;
const links = Array.from(menu.querySelectorAll('a'));
const getCurrentInd = () =>
links.findIndex(
(el, i) =>
el.innerText.toLowerCase().charAt(0) === code.toLowerCase() &&
this.history.existsInHistory(i),
);
let currentInd = getCurrentInd();
if (currentInd === -1) {
this.history.clearHistory();
currentInd = getCurrentInd();
}
if (currentInd !== -1) {
links[currentInd].focus();
this.history.addHistory(currentInd);
}
}
}
static closeCurrentlyOpened(
evtTarget: HTMLElement | null = null,
isAnimated = true,
) {
const parent =
evtTarget &&
evtTarget.closest('.hs-dropdown') &&
evtTarget.closest('.hs-dropdown').parentElement.closest('.hs-dropdown')
? evtTarget
.closest('.hs-dropdown')
.parentElement.closest('.hs-dropdown')
: null;
let currentlyOpened = parent
? window.$hsDropdownCollection.filter(
(el) =>
el.element.el.classList.contains('open') &&
el.element.menu
.closest('.hs-dropdown')
.parentElement.closest('.hs-dropdown') === parent,
)
: window.$hsDropdownCollection.filter((el) =>
el.element.el.classList.contains('open'),
);
if (
evtTarget &&
evtTarget.closest('.hs-dropdown') &&
getClassPropertyAlt(evtTarget.closest('.hs-dropdown'), '--auto-close') ===
'inside'
) {
currentlyOpened = currentlyOpened.filter(
(el) => el.element.el !== evtTarget.closest('.hs-dropdown'),
);
}
if (currentlyOpened) {
currentlyOpened.forEach((el) => {
if (
el.element.closeMode === 'false' ||
el.element.closeMode === 'outside'
)
return false;
el.element.close(isAnimated);
});
}
}
// Backward compatibility
static on(evt: string, target: HTMLElement, cb: Function) {
const elInCollection = window.$hsDropdownCollection.find(
(el) =>
el.element.el ===
(typeof target === 'string' ? document.querySelector(target) : target),
);
if (elInCollection) elInCollection.element.events[evt] = cb;
}
}
declare global {
interface Window {
HSDropdown: Function;
$hsDropdownCollection: ICollectionItem<HSDropdown>[];
}
}
window.addEventListener('load', () => {
HSDropdown.autoInit();
// Uncomment for debug
// console.log('Dropdown collection:', window.$hsDropdownCollection);
});
window.addEventListener('resize', () => {
if (!window.$hsDropdownCollection) window.$hsDropdownCollection = [];
window.$hsDropdownCollection.forEach((el) => el.element.resizeHandler());
});
if (typeof window !== 'undefined') {
window.HSDropdown = HSDropdown;
}
export default HSDropdown;