kopia lustrzana https://github.com/shoelace-style/shoelace
292 wiersze
9.5 KiB
TypeScript
292 wiersze
9.5 KiB
TypeScript
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
|
import { type HasSlotController } from '../../internal/slot.js';
|
|
import { html } from 'lit';
|
|
import { type LocalizeController } from '../../utilities/localize.js';
|
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
import type SlMenuItem from './menu-item.js';
|
|
import type SlPopup from '../popup/popup.js';
|
|
|
|
/** A reactive controller to manage the registration of event listeners for submenus. */
|
|
export class SubmenuController implements ReactiveController {
|
|
private host: ReactiveControllerHost & SlMenuItem;
|
|
private popupRef: Ref<SlPopup> = createRef();
|
|
private enableSubmenuTimer = -1;
|
|
private isConnected = false;
|
|
private isPopupConnected = false;
|
|
private skidding = 0;
|
|
private readonly hasSlotController: HasSlotController;
|
|
private readonly localize: LocalizeController;
|
|
private readonly submenuOpenDelay = 100;
|
|
|
|
constructor(
|
|
host: ReactiveControllerHost & SlMenuItem,
|
|
hasSlotController: HasSlotController,
|
|
localize: LocalizeController
|
|
) {
|
|
(this.host = host).addController(this);
|
|
this.hasSlotController = hasSlotController;
|
|
this.localize = localize;
|
|
}
|
|
|
|
hostConnected() {
|
|
if (this.hasSlotController.test('submenu') && !this.host.disabled) {
|
|
this.addListeners();
|
|
}
|
|
}
|
|
|
|
hostDisconnected() {
|
|
this.removeListeners();
|
|
}
|
|
|
|
hostUpdated() {
|
|
if (this.hasSlotController.test('submenu') && !this.host.disabled) {
|
|
this.addListeners();
|
|
this.updateSkidding();
|
|
} else {
|
|
this.removeListeners();
|
|
}
|
|
}
|
|
|
|
private addListeners() {
|
|
if (!this.isConnected) {
|
|
this.host.addEventListener('mousemove', this.handleMouseMove);
|
|
this.host.addEventListener('mouseover', this.handleMouseOver);
|
|
this.host.addEventListener('keydown', this.handleKeyDown);
|
|
this.host.addEventListener('click', this.handleClick);
|
|
this.host.addEventListener('focusout', this.handleFocusOut);
|
|
this.isConnected = true;
|
|
}
|
|
|
|
// The popup does not seem to get wired when the host is
|
|
// connected, so manage its listeners separately.
|
|
if (!this.isPopupConnected) {
|
|
if (this.popupRef.value) {
|
|
this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover);
|
|
this.popupRef.value.addEventListener('sl-reposition', this.handlePopupReposition);
|
|
this.isPopupConnected = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private removeListeners() {
|
|
if (this.isConnected) {
|
|
this.host.removeEventListener('mousemove', this.handleMouseMove);
|
|
this.host.removeEventListener('mouseover', this.handleMouseOver);
|
|
this.host.removeEventListener('keydown', this.handleKeyDown);
|
|
this.host.removeEventListener('click', this.handleClick);
|
|
this.host.removeEventListener('focusout', this.handleFocusOut);
|
|
this.isConnected = false;
|
|
}
|
|
if (this.isPopupConnected) {
|
|
if (this.popupRef.value) {
|
|
this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover);
|
|
this.popupRef.value.removeEventListener('sl-reposition', this.handlePopupReposition);
|
|
this.isPopupConnected = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set the safe triangle cursor position
|
|
private handleMouseMove = (event: MouseEvent) => {
|
|
this.host.style.setProperty('--safe-triangle-cursor-x', `${event.clientX}px`);
|
|
this.host.style.setProperty('--safe-triangle-cursor-y', `${event.clientY}px`);
|
|
};
|
|
|
|
private handleMouseOver = () => {
|
|
if (this.hasSlotController.test('submenu')) {
|
|
this.enableSubmenu();
|
|
}
|
|
};
|
|
|
|
private handleSubmenuEntry(event: KeyboardEvent) {
|
|
// Pass focus to the first menu-item in the submenu.
|
|
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
|
|
|
|
// Missing slot
|
|
if (!submenuSlot) {
|
|
console.error('Cannot activate a submenu if no corresponding menuitem can be found.', this);
|
|
return;
|
|
}
|
|
|
|
// Menus
|
|
let menuItems: NodeListOf<Element> | null = null;
|
|
for (const elt of submenuSlot.assignedElements()) {
|
|
menuItems = elt.querySelectorAll("sl-menu-item, [role^='menuitem']");
|
|
if (menuItems.length !== 0) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!menuItems || menuItems.length === 0) {
|
|
return;
|
|
}
|
|
|
|
menuItems[0].setAttribute('tabindex', '0');
|
|
for (let i = 1; i !== menuItems.length; ++i) {
|
|
menuItems[i].setAttribute('tabindex', '-1');
|
|
}
|
|
|
|
// Open the submenu (if not open), and set focus to first menuitem.
|
|
if (this.popupRef.value) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (this.popupRef.value.active) {
|
|
if (menuItems[0] instanceof HTMLElement) {
|
|
menuItems[0].focus();
|
|
}
|
|
} else {
|
|
this.enableSubmenu(false);
|
|
this.host.updateComplete.then(() => {
|
|
if (menuItems![0] instanceof HTMLElement) {
|
|
menuItems![0].focus();
|
|
}
|
|
});
|
|
this.host.requestUpdate();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Focus on the first menu-item of a submenu.
|
|
private handleKeyDown = (event: KeyboardEvent) => {
|
|
switch (event.key) {
|
|
case 'Escape':
|
|
case 'Tab':
|
|
this.disableSubmenu();
|
|
break;
|
|
case 'ArrowLeft':
|
|
// Either focus is currently on the host element or a child
|
|
if (event.target !== this.host) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.host.focus();
|
|
this.disableSubmenu();
|
|
}
|
|
break;
|
|
case 'ArrowRight':
|
|
case 'Enter':
|
|
case ' ':
|
|
this.handleSubmenuEntry(event);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
private handleClick = (event: MouseEvent) => {
|
|
// Clicking on the item which heads the menu does nothing, otherwise hide submenu and propagate
|
|
if (event.target === this.host) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
} else if (
|
|
event.target instanceof Element &&
|
|
(event.target.tagName === 'sl-menu-item' || event.target.role?.startsWith('menuitem'))
|
|
) {
|
|
this.disableSubmenu();
|
|
}
|
|
};
|
|
|
|
// Close this submenu on focus outside of the parent or any descendants.
|
|
private handleFocusOut = (event: FocusEvent) => {
|
|
if (event.relatedTarget && event.relatedTarget instanceof Element && this.host.contains(event.relatedTarget)) {
|
|
return;
|
|
}
|
|
this.disableSubmenu();
|
|
};
|
|
|
|
// Prevent the parent menu-item from getting focus on mouse movement on the submenu
|
|
private handlePopupMouseover = (event: MouseEvent) => {
|
|
event.stopPropagation();
|
|
};
|
|
|
|
// Set the safe triangle values for the submenu when the position changes
|
|
private handlePopupReposition = () => {
|
|
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
|
|
const menu = submenuSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'sl-menu')[0];
|
|
const isRtl = this.localize.dir() === 'rtl';
|
|
|
|
if (!menu) {
|
|
return;
|
|
}
|
|
|
|
const { left, top, width, height } = menu.getBoundingClientRect();
|
|
|
|
this.host.style.setProperty('--safe-triangle-submenu-start-x', `${isRtl ? left + width : left}px`);
|
|
this.host.style.setProperty('--safe-triangle-submenu-start-y', `${top}px`);
|
|
this.host.style.setProperty('--safe-triangle-submenu-end-x', `${isRtl ? left + width : left}px`);
|
|
this.host.style.setProperty('--safe-triangle-submenu-end-y', `${top + height}px`);
|
|
};
|
|
|
|
private setSubmenuState(state: boolean) {
|
|
if (this.popupRef.value) {
|
|
if (this.popupRef.value.active !== state) {
|
|
this.popupRef.value.active = state;
|
|
this.host.requestUpdate();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shows the submenu. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the
|
|
// newly opened menu.
|
|
private enableSubmenu(delay = true) {
|
|
if (delay) {
|
|
window.clearTimeout(this.enableSubmenuTimer);
|
|
this.enableSubmenuTimer = window.setTimeout(() => {
|
|
this.setSubmenuState(true);
|
|
}, this.submenuOpenDelay);
|
|
} else {
|
|
this.setSubmenuState(true);
|
|
}
|
|
}
|
|
|
|
private disableSubmenu() {
|
|
window.clearTimeout(this.enableSubmenuTimer);
|
|
this.setSubmenuState(false);
|
|
}
|
|
|
|
// Calculate the space the top of a menu takes-up, for aligning the popup menu-item with the activating element.
|
|
private updateSkidding(): void {
|
|
// .computedStyleMap() not always available.
|
|
if (!this.host.parentElement?.computedStyleMap) {
|
|
return;
|
|
}
|
|
const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap();
|
|
const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top'];
|
|
|
|
const skidding = attrs.reduce((accumulator, attr) => {
|
|
const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px');
|
|
const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px');
|
|
const pxValue = unitValue.to('px');
|
|
return accumulator - pxValue.value;
|
|
}, 0);
|
|
|
|
this.skidding = skidding;
|
|
}
|
|
|
|
isExpanded(): boolean {
|
|
return this.popupRef.value ? this.popupRef.value.active : false;
|
|
}
|
|
|
|
renderSubmenu() {
|
|
const isLtr = this.localize.dir() === 'ltr';
|
|
|
|
// Always render the slot, but conditionally render the outer <sl-popup>
|
|
if (!this.isConnected) {
|
|
return html` <slot name="submenu" hidden></slot> `;
|
|
}
|
|
|
|
return html`
|
|
<sl-popup
|
|
${ref(this.popupRef)}
|
|
placement=${isLtr ? 'right-start' : 'left-start'}
|
|
anchor="anchor"
|
|
flip
|
|
flip-fallback-strategy="best-fit"
|
|
skidding="${this.skidding}"
|
|
strategy="fixed"
|
|
>
|
|
<slot name="submenu"></slot>
|
|
</sl-popup>
|
|
`;
|
|
}
|
|
}
|