preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
1,054 lines (861 loc) • 27.4 kB
text/typescript
/*
* HSDropdown
* @version: 3.2.2
* @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 {
afterTransition,
dispatch,
getClassProperty,
getClassPropertyAlt,
isIOS,
isIpadOS,
stringToBoolean,
} from "../../utils";
import {
autoUpdate,
computePosition,
flip,
offset,
type Placement,
type Strategy,
VirtualElement,
} from "@floating-ui/dom";
import { IDropdown, IHTMLElementFloatingUI } from "../dropdown/interfaces";
import HSBasePlugin from "../base-plugin";
import HSAccessibilityObserver from "../accessibility-manager";
import { ICollectionItem } from "../../interfaces";
import { IAccessibilityComponent } from "../accessibility-manager/interfaces";
import { POSITIONS } from "../../constants";
class HSDropdown extends HSBasePlugin<{}, IHTMLElementFloatingUI>
implements IDropdown {
private accessibilityComponent: IAccessibilityComponent;
private readonly toggle: HTMLElement | null;
private readonly closers: HTMLElement[] | null;
public menu: HTMLElement | null;
private eventMode: string;
private closeMode: string;
private hasAutofocus: boolean;
private autofocusOnKeyboardOnly: boolean;
private animationInProcess: boolean;
private longPressTimer: number | null = null;
private openedViaKeyboard: boolean = false;
private onElementMouseEnterListener: () => void | null;
private onElementMouseLeaveListener: () => void | null;
private onToggleClickListener: (evt: Event) => void | null;
private onToggleContextMenuListener: (evt: Event) => void | null;
private onTouchStartListener: ((evt: TouchEvent) => void) | null = null;
private onTouchEndListener: ((evt: TouchEvent) => void) | null = null;
private onCloserClickListener:
| {
el: HTMLButtonElement;
fn: () => void;
}[]
| null;
constructor(el: IHTMLElementFloatingUI, 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.hasAutofocus = stringToBoolean(
getClassProperty(this.el, "--has-autofocus", "true") || "true",
);
this.autofocusOnKeyboardOnly = stringToBoolean(
getClassProperty(this.el, "--autofocus-on-keyboard-only", "true") ||
"true",
);
this.animationInProcess = false;
this.onCloserClickListener = [];
if (this.toggle && this.menu) this.init();
}
private elementMouseEnter() {
this.onMouseEnterHandler();
}
private elementMouseLeave() {
this.onMouseLeaveHandler();
}
private toggleClick(evt: Event) {
this.onClickHandler(evt);
}
private toggleContextMenu(evt: MouseEvent) {
evt.preventDefault();
this.onContextMenuHandler(evt);
}
private handleTouchStart(evt: TouchEvent): void {
this.longPressTimer = window.setTimeout(() => {
evt.preventDefault();
const touch = evt.touches[0];
const contextMenuEvent = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
view: window,
clientX: touch.clientX,
clientY: touch.clientY,
});
if (this.toggle) this.toggle.dispatchEvent(contextMenuEvent);
}, 400);
}
private handleTouchEnd(evt: TouchEvent): void {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
private closerClick() {
this.close();
}
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.onElementMouseEnterListener = () => this.elementMouseEnter();
this.onElementMouseLeaveListener = () => this.elementMouseLeave();
this.el.addEventListener("mouseenter", this.onElementMouseEnterListener);
this.el.addEventListener("mouseleave", this.onElementMouseLeaveListener);
}
if (typeof window !== "undefined") {
if (!window.HSAccessibilityObserver) {
window.HSAccessibilityObserver = new HSAccessibilityObserver();
}
this.setupAccessibility();
}
}
resizeHandler() {
this.eventMode = getClassProperty(this.el, "--trigger", "click");
this.closeMode = getClassProperty(this.el, "--auto-close", "true");
this.hasAutofocus = stringToBoolean(
getClassProperty(this.el, "--has-autofocus", "true") || "true",
);
this.autofocusOnKeyboardOnly = stringToBoolean(
getClassProperty(this.el, "--autofocus-on-keyboard-only", "true") ||
"true",
);
}
private isOpen(): boolean {
return this.el.classList.contains("open") &&
!this.menu.classList.contains("hidden");
}
private buildToggle() {
if (this?.toggle?.ariaExpanded) {
if (this.el.classList.contains("open")) this.toggle.ariaExpanded = "true";
else this.toggle.ariaExpanded = "false";
}
if (this.eventMode === "contextmenu") {
this.onToggleContextMenuListener = (evt: MouseEvent) =>
this.toggleContextMenu(evt);
this.onTouchStartListener = this.handleTouchStart.bind(this);
this.onTouchEndListener = this.handleTouchEnd.bind(this);
this.toggle.addEventListener(
"contextmenu",
this.onToggleContextMenuListener,
);
this.toggle.addEventListener("touchstart", this.onTouchStartListener, {
passive: false,
});
this.toggle.addEventListener("touchend", this.onTouchEndListener);
this.toggle.addEventListener("touchmove", this.onTouchEndListener);
} else {
this.onToggleClickListener = (evt) => this.toggleClick(evt);
this.toggle.addEventListener("click", this.onToggleClickListener);
}
}
private buildMenu() {
this.menu.role = this.menu.getAttribute("role") || "menu";
this.menu.tabIndex = -1;
const checkboxes = this.menu.querySelectorAll('[role="menuitemcheckbox"]');
const radiobuttons = this.menu.querySelectorAll('[role="menuitemradio"]');
checkboxes.forEach((el: HTMLElement) =>
el.addEventListener("click", () => this.selectCheckbox(el))
);
radiobuttons.forEach((el: HTMLElement) =>
el.addEventListener("click", () => this.selectRadio(el))
);
this.menu.addEventListener("click", () => {
this.menu.focus();
});
}
private buildClosers() {
this.closers.forEach((el: HTMLButtonElement) => {
this.onCloserClickListener.push({
el,
fn: () => this.closerClick(),
});
el.addEventListener(
"click",
this.onCloserClickListener.find((closer) => closer.el === el).fn,
);
});
}
private getScrollbarSize() {
let div = document.createElement("div");
div.style.overflow = "scroll";
div.style.width = "100px";
div.style.height = "100px";
document.body.appendChild(div);
let scrollbarSize = div.offsetWidth - div.clientWidth;
document.body.removeChild(div);
return scrollbarSize;
}
private onContextMenuHandler(evt: MouseEvent) {
const virtualElement: VirtualElement = {
getBoundingClientRect: () => new DOMRect(),
};
virtualElement.getBoundingClientRect = () =>
new DOMRect(evt.clientX, evt.clientY, 0, 0);
HSDropdown.closeCurrentlyOpened();
if (
this.el.classList.contains("open") &&
!this.menu.classList.contains("hidden")
) {
this.close();
document.body.style.overflow = "";
document.body.style.paddingRight = "";
} else {
document.body.style.overflow = "hidden";
document.body.style.paddingRight = `${this.getScrollbarSize()}px`;
this.open(virtualElement);
}
}
private onClickHandler(evt: Event) {
const isMouseHoverTrigger = this.eventMode === "hover" &&
window.matchMedia("(hover: hover)").matches &&
(evt as PointerEvent).pointerType === "mouse";
if (isMouseHoverTrigger) {
const el = evt.currentTarget as HTMLElement;
const isAnchor = el.tagName === "A";
const isNavLink = isAnchor && el.hasAttribute("href") &&
el.getAttribute("href") !== "#";
if (!isNavLink) {
evt.preventDefault();
evt.stopPropagation();
evt.stopImmediatePropagation?.();
}
return false;
}
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._floatingUI ||
(
this.el._floatingUI &&
!this.el.classList.contains("open")
)
) 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 destroyFloatingUI() {
const scope =
(window.getComputedStyle(this.el).getPropertyValue("--scope") || "")
.trim();
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._floatingUI) {
this.el._floatingUI.destroy();
this.el._floatingUI = null;
}
if (scope === "window") this.el.appendChild(this.menu);
this.animationInProcess = false;
}
private focusElement() {
const input: HTMLInputElement = this.menu.querySelector("[autofocus]");
if (input) {
input.focus();
return true;
}
const menuItems = this.menu.querySelectorAll(
'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])',
);
if (menuItems.length > 0) {
const firstItem = menuItems[0] as HTMLElement;
firstItem.focus();
return true;
}
return false;
}
private setupFloatingUI(target?: VirtualElement | HTMLElement) {
const _target = target || this.el;
const computedStyle = window.getComputedStyle(this.el);
const placementCss = (computedStyle.getPropertyValue("--placement") || "")
.trim();
const flipCss = (computedStyle.getPropertyValue("--flip") || "true").trim();
const strategyCss =
(computedStyle.getPropertyValue("--strategy") || "fixed").trim();
const offsetCss = (computedStyle.getPropertyValue("--offset") || "10")
.trim();
const gpuAccelerationCss =
(computedStyle.getPropertyValue("--gpu-acceleration") || "true").trim();
const adaptive =
(window.getComputedStyle(this.el).getPropertyValue("--adaptive") ||
"adaptive").replace(" ", "");
const strategy = strategyCss as Strategy;
const offsetValue = parseInt(offsetCss, 10);
const placement: Placement = POSITIONS[placementCss] || "bottom-start";
const middleware = [
...(flipCss === "true" ? [flip()] : []),
offset(offsetValue),
];
const options = {
placement,
strategy,
middleware,
};
const checkSpaceAndAdjust = (x: number) => {
const menuRect = this.menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const scrollbarWidth = window.innerWidth -
document.documentElement.clientWidth;
const availableWidth = viewportWidth - scrollbarWidth;
if (x + menuRect.width > availableWidth) {
x = availableWidth - menuRect.width;
}
if (x < 0) x = 0;
return x;
};
const update = () => {
computePosition(_target, this.menu, options).then(
({ x, y, placement: computedPlacement }) => {
const adjustedX = checkSpaceAndAdjust(x);
if (strategy === "absolute" && adaptive === "none") {
Object.assign(this.menu.style, {
position: strategy,
margin: "0",
});
} else if (strategy === "absolute") {
Object.assign(this.menu.style, {
position: strategy,
transform: `translate3d(${x}px, ${y}px, 0px)`,
margin: "0",
});
} else {
if (gpuAccelerationCss === "true") {
Object.assign(this.menu.style, {
position: strategy,
left: "",
top: "",
inset: "0px auto auto 0px",
margin: "0",
transform: `translate3d(${
adaptive === "adaptive" ? adjustedX : 0
}px, ${y}px, 0)`,
});
} else {
Object.assign(this.menu.style, {
position: strategy,
left: `${x}px`,
top: `${y}px`,
transform: "",
});
}
}
this.menu.setAttribute("data-placement", computedPlacement);
},
);
};
update();
const cleanup = autoUpdate(_target, this.menu, update);
return {
update,
destroy: cleanup,
};
}
private selectCheckbox(target: HTMLElement) {
target.ariaChecked = target.ariaChecked === "true" ? "false" : "true";
}
private selectRadio(target: HTMLElement) {
if (target.ariaChecked === "true") return false;
const group = target.closest(".group");
const items = group.querySelectorAll('[role="menuitemradio"]');
const otherItems = Array.from(items).filter((el) => el !== target);
otherItems.forEach((el) => {
el.ariaChecked = "false";
});
target.ariaChecked = "true";
}
// Public methods
// TODO:: rename "Popper" to "FLoatingUI"
public calculatePopperPosition(target?: VirtualElement | HTMLElement) {
const floatingUIInstance = this.setupFloatingUI(target);
const floatingUIPosition = this.menu.getAttribute("data-placement");
floatingUIInstance.update();
floatingUIInstance.destroy();
return floatingUIPosition;
}
public open(
target?: VirtualElement | HTMLElement,
openedViaKeyboard: boolean = false,
) {
if (this.el.classList.contains("open") || this.animationInProcess) {
return false;
}
this.openedViaKeyboard = openedViaKeyboard;
this.animationInProcess = true;
this.menu.style.cssText = "";
const _target = target || this.el;
const computedStyle = window.getComputedStyle(this.el);
const scope = (computedStyle.getPropertyValue("--scope") || "").trim();
const strategyCss =
(computedStyle.getPropertyValue("--strategy") || "fixed").trim();
const strategy = strategyCss as Strategy;
if (scope === "window") document.body.appendChild(this.menu);
if (strategy !== ("static" as Strategy)) {
this.el._floatingUI = this.setupFloatingUI(_target);
}
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");
if (window.HSAccessibilityObserver && this.accessibilityComponent) {
window.HSAccessibilityObserver.updateComponentState(
this.accessibilityComponent,
true,
);
}
if (scope === "window") this.menu.classList.add("open");
this.animationInProcess = false;
if (
this.hasAutofocus &&
(!this.autofocusOnKeyboardOnly || this.openedViaKeyboard)
) this.focusElement();
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 scope =
(window.getComputedStyle(this.el).getPropertyValue("--scope") || "")
.trim();
const clearAfterClose = () => {
this.menu.style.margin = null;
if (this?.toggle?.ariaExpanded) this.toggle.ariaExpanded = "false";
this.el.classList.remove("open");
this.openedViaKeyboard = false;
this.fireEvent("close", this.el);
dispatch("close.hs.dropdown", this.el, this.el);
};
this.animationInProcess = true;
if (scope === "window") this.menu.classList.remove("open");
if (window.HSAccessibilityObserver && this.accessibilityComponent) {
window.HSAccessibilityObserver.updateComponentState(
this.accessibilityComponent,
false,
);
}
if (isAnimated) {
const el: HTMLElement =
this.el.querySelector("[data-hs-dropdown-transition]") || this.menu;
let hasCompleted = false;
const completeClose = () => {
if (hasCompleted) return;
hasCompleted = true;
this.destroyFloatingUI();
};
afterTransition(el, completeClose);
const computedStyle = window.getComputedStyle(el);
const transitionDuration = computedStyle.getPropertyValue(
"transition-duration",
);
const duration = parseFloat(transitionDuration) * 1000 || 150;
setTimeout(completeClose, duration + 50);
} else {
this.destroyFloatingUI();
}
clearAfterClose();
}
public forceClearState() {
this.destroyFloatingUI();
this.menu.style.margin = null;
this.el.classList.remove("open");
this.menu.classList.add("hidden");
this.openedViaKeyboard = false;
}
public destroy() {
// Remove listeners
if (!isIOS() && !isIpadOS()) {
this.el.removeEventListener(
"mouseenter",
this.onElementMouseEnterListener,
);
this.el.removeEventListener(
"mouseleave",
() => this.onElementMouseLeaveListener,
);
this.onElementMouseEnterListener = null;
this.onElementMouseLeaveListener = null;
}
if (this.eventMode === "contextmenu") {
if (this.toggle) {
this.toggle.removeEventListener(
"contextmenu",
this.onToggleContextMenuListener,
);
this.toggle.removeEventListener(
"touchstart",
this.onTouchStartListener,
);
this.toggle.removeEventListener("touchend", this.onTouchEndListener);
this.toggle.removeEventListener("touchmove", this.onTouchEndListener);
}
this.onToggleContextMenuListener = null;
this.onTouchStartListener = null;
this.onTouchEndListener = null;
} else {
if (this.toggle) {
this.toggle.removeEventListener("click", this.onToggleClickListener);
}
this.onToggleClickListener = null;
}
if (this.closers.length) {
this.closers.forEach((el: HTMLButtonElement) => {
el.removeEventListener(
"click",
this.onCloserClickListener.find((closer) => closer.el === el).fn,
);
});
this.onCloserClickListener = null;
}
// Remove classes
this.el.classList.remove("open");
this.destroyFloatingUI();
window.$hsDropdownCollection = window.$hsDropdownCollection.filter((
{ element },
) => element.el !== this.el);
// Unregister accessibility
// if (typeof window !== "undefined" && window.HSAccessibilityObserver) {
// window.HSAccessibilityObserver.unregisterPlugin(this);
// }
}
// Static methods
private static findInCollection(
target: HSDropdown | HTMLElement | string,
): ICollectionItem<HSDropdown> | null {
return window.$hsDropdownCollection.find((el) => {
if (target instanceof HSDropdown) return el.element.el === target.el;
else if (typeof target === "string") {
return el.element.el === document.querySelector(target);
} else return el.element.el === target;
}) || null;
}
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
: null;
}
static autoInit() {
if (!window.$hsDropdownCollection) {
window.$hsDropdownCollection = [];
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);
}
});
}
if (window.$hsDropdownCollection) {
window.$hsDropdownCollection = window.$hsDropdownCollection.filter(
({ element }) => document.contains(element.el),
);
}
document
.querySelectorAll(".hs-dropdown:not(.--prevent-on-load-init)")
.forEach((el: IHTMLElementFloatingUI) => {
if (
!window.$hsDropdownCollection.find(
(elC) => (elC?.element?.el as HTMLElement) === el,
)
) {
new HSDropdown(el);
}
});
}
static open(
target: HSDropdown | HTMLElement | string,
openedViaKeyboard: boolean = false,
) {
const instance = HSDropdown.findInCollection(target);
if (
instance &&
instance.element.menu.classList.contains("hidden")
) instance.element.open(undefined, openedViaKeyboard);
}
static close(target: HSDropdown | HTMLElement | string) {
const instance = HSDropdown.findInCollection(target);
if (
instance &&
!instance.element.menu.classList.contains("hidden")
) instance.element.close();
}
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) {
const dropdownElement = evtTarget.closest(".hs-dropdown") as HTMLElement;
if (dropdownElement) {
if (getClassPropertyAlt(dropdownElement, "--auto-close") === "inside") {
currentlyOpened = currentlyOpened.filter(
(el) => el.element.el !== dropdownElement,
);
}
} else {
const dropdownMenu = evtTarget.closest(".hs-dropdown-menu");
if (dropdownMenu) {
const originalDropdown = window.$hsDropdownCollection.find(
(item) => item.element.menu === dropdownMenu,
);
if (
originalDropdown &&
getClassPropertyAlt(originalDropdown.element.el, "--auto-close") ===
"inside"
) {
currentlyOpened = currentlyOpened.filter(
(el) => el.element.el !== originalDropdown.element.el,
);
}
}
}
}
if (currentlyOpened) {
currentlyOpened.forEach((el) => {
if (
el.element.closeMode === "false" ||
el.element.closeMode === "outside"
) {
return false;
}
el.element.close(isAnimated);
});
}
if (currentlyOpened) {
currentlyOpened.forEach((el) => {
if (getClassPropertyAlt(el.element.el, "--trigger") !== "contextmenu") {
return false;
}
document.body.style.overflow = "";
document.body.style.paddingRight = "";
});
}
}
// Accessibility methods
private setupAccessibility(): void {
this.accessibilityComponent = window.HSAccessibilityObserver
.registerComponent(
this.el,
{
onEnter: () => {
if (!this.isOpened()) this.open(undefined, true);
},
onSpace: () => {
if (!this.isOpened()) this.open(undefined, true);
},
onEsc: () => {
if (this.isOpened()) {
this.close();
if (this.toggle) this.toggle.focus();
}
},
onArrow: (evt: KeyboardEvent) => {
if (evt.metaKey) return;
switch (evt.key) {
case "ArrowDown":
if (!this.isOpened()) this.open(undefined, true);
else this.focusMenuItem("next");
break;
case "ArrowUp":
if (this.isOpened()) this.focusMenuItem("prev");
break;
case "ArrowRight":
this.onArrowX(evt, "right");
break;
case "ArrowLeft":
this.onArrowX(evt, "left");
break;
}
},
onHome: () => {
if (this.isOpened()) this.onStartEnd(true);
},
onEnd: () => {
if (this.isOpened()) this.onStartEnd(false);
},
onTab: () => {
if (this.isOpened()) this.close();
},
onFirstLetter: (key: string) => {
if (this.isOpened()) this.onFirstLetter(key);
},
},
this.isOpened(),
"Dropdown",
".hs-dropdown",
this.menu,
);
}
private onFirstLetter(key: string): void {
if (!this.isOpened() || !this.menu) return;
const menuItems = this.menu.querySelectorAll(
'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])',
);
if (menuItems.length === 0) return;
const currentIndex = Array.from(menuItems).indexOf(
document.activeElement as HTMLElement,
);
for (let i = 1; i <= menuItems.length; i++) {
const index = (currentIndex + i) % menuItems.length;
const text =
(menuItems[index] as HTMLElement).textContent?.trim().toLowerCase() ||
"";
if (text.startsWith(key.toLowerCase())) {
(menuItems[index] as HTMLElement).focus();
return;
}
}
(menuItems[0] as HTMLElement).focus();
}
private onArrowX(evt: KeyboardEvent, direction: "left" | "right"): void {
if (!this.isOpened()) return;
evt.preventDefault();
evt.stopImmediatePropagation();
const menuItems = this.menu.querySelectorAll(
'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])',
);
if (!menuItems.length) return;
const currentIndex = Array.from(menuItems).indexOf(
document.activeElement as HTMLElement,
);
let nextIndex = -1;
if (direction === "right") {
nextIndex = (currentIndex + 1) % menuItems.length;
} else {
nextIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
}
(menuItems[nextIndex] as HTMLElement).focus();
}
private onStartEnd(toStart: boolean = true): void {
if (!this.isOpened()) return;
const menuItems = this.menu.querySelectorAll(
'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])',
);
if (!menuItems.length) return;
const index = toStart ? 0 : menuItems.length - 1;
(menuItems[index] as HTMLElement).focus();
}
private focusMenuItem(direction: "next" | "prev"): void {
const menuItems = this.menu.querySelectorAll(
'a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden])',
);
if (!menuItems.length) return;
const currentIndex = Array.from(menuItems).indexOf(
document.activeElement as HTMLElement,
);
const nextIndex = direction === "next"
? (currentIndex + 1) % menuItems.length
: (currentIndex - 1 + menuItems.length) % menuItems.length;
(menuItems[nextIndex] as HTMLElement).focus();
}
// Backward compatibility
static on(
evt: string,
target: HSDropdown | HTMLElement | string,
cb: Function,
) {
const instance = HSDropdown.findInCollection(target);
if (instance) instance.element.events[evt] = cb;
}
public isOpened(): boolean {
return this.isOpen();
}
public containsElement(element: HTMLElement): boolean {
return this.el.contains(element);
}
}
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;