mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 12:09:26 +00:00
301 lines
8.0 KiB
TypeScript
301 lines
8.0 KiB
TypeScript
import { Component, Element, Event, EventEmitter, Method, Prop, Watch, h } from '@stencil/core';
|
|
import Popover from '../../utilities/popover';
|
|
|
|
let id = 0;
|
|
|
|
/**
|
|
* @since 2.0
|
|
* @status stable
|
|
*
|
|
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
|
|
* @slot - The dropdown's content.
|
|
*
|
|
* @part base - The component's base wrapper.
|
|
* @part trigger - The container that wraps the trigger.
|
|
* @part panel - The panel that gets shown when the dropdown is open.
|
|
*/
|
|
|
|
@Component({
|
|
tag: 'sl-dropdown',
|
|
styleUrl: 'dropdown.scss',
|
|
shadow: true
|
|
})
|
|
export class Dropdown {
|
|
componentId = `dropdown-${++id}`;
|
|
ignoreMouseEvents = false;
|
|
ignoreMouseTimeout: any;
|
|
ignoreOpenWatcher = false;
|
|
panel: HTMLElement;
|
|
popover: Popover;
|
|
trigger: HTMLElement;
|
|
|
|
@Element() host: HTMLSlDropdownElement;
|
|
|
|
/** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */
|
|
@Prop({ mutable: true, reflect: true }) open = false;
|
|
|
|
/**
|
|
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
|
|
* inside of the viewport.
|
|
*/
|
|
@Prop() placement:
|
|
| 'top'
|
|
| 'top-start'
|
|
| 'top-end'
|
|
| 'bottom'
|
|
| 'bottom-start'
|
|
| 'bottom-end'
|
|
| 'right'
|
|
| 'right-start'
|
|
| 'right-end'
|
|
| 'left'
|
|
| 'left-start'
|
|
| 'left-end' = 'bottom-start';
|
|
|
|
/** Determines whether the dropdown should hide when a menu item is selected. */
|
|
@Prop() closeOnSelect = true;
|
|
|
|
/** The dropdown will close when the user interacts outside of this element (e.g. clicking). */
|
|
@Prop() containingElement: HTMLElement = this.host;
|
|
|
|
/** The distance in pixels from which to offset the panel away from its trigger. */
|
|
@Prop() distance = 2;
|
|
|
|
/** The distance in pixels from which to offset the panel along its trigger. */
|
|
@Prop() skidding = 0;
|
|
|
|
/** Emitted when the dropdown opens. Calling `event.preventDefault()` will prevent it from being opened. */
|
|
@Event() slShow: EventEmitter;
|
|
|
|
/** Emitted after the dropdown opens and all transitions are complete. */
|
|
@Event() slAfterShow: EventEmitter;
|
|
|
|
/** Emitted when the dropdown closes. Calling `event.preventDefault()` will prevent it from being closed. */
|
|
@Event() slHide: EventEmitter;
|
|
|
|
/** Emitted after the dropdown closes and all transitions are complete. */
|
|
@Event() slAfterHide: EventEmitter;
|
|
|
|
@Watch('open')
|
|
handleOpenChange() {
|
|
if (!this.ignoreOpenWatcher) {
|
|
this.open ? this.show() : this.hide();
|
|
}
|
|
}
|
|
|
|
@Watch('placement')
|
|
@Watch('distance')
|
|
@Watch('skidding')
|
|
handlePopoverOptionsChange() {
|
|
this.popover.setOptions({ placement: this.placement });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
|
|
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
|
|
this.handlePanelSelect = this.handlePanelSelect.bind(this);
|
|
this.handleTriggerKeyDown = this.handleTriggerKeyDown.bind(this);
|
|
this.togglePanel = this.togglePanel.bind(this);
|
|
}
|
|
|
|
componentDidLoad() {
|
|
this.popover = new Popover(this.trigger, this.panel, {
|
|
placement: this.placement,
|
|
skidding: this.skidding,
|
|
distance: this.distance,
|
|
onAfterHide: () => this.slAfterHide.emit(),
|
|
onAfterShow: () => this.slAfterShow.emit(),
|
|
onTransitionEnd: () => {
|
|
if (!this.open) {
|
|
this.panel.scrollTop = 0;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Show on init if open
|
|
if (this.open) {
|
|
this.show();
|
|
}
|
|
}
|
|
|
|
componentDidUnload() {
|
|
this.hide();
|
|
this.popover.destroy();
|
|
}
|
|
|
|
/** Shows the dropdown panel */
|
|
@Method()
|
|
async show() {
|
|
this.ignoreOpenWatcher = true;
|
|
this.open = true;
|
|
|
|
const slShow = this.slShow.emit();
|
|
|
|
if (slShow.defaultPrevented) {
|
|
this.open = false;
|
|
this.ignoreOpenWatcher = false;
|
|
return;
|
|
}
|
|
|
|
this.popover.show();
|
|
this.ignoreOpenWatcher = false;
|
|
|
|
this.panel.addEventListener('slSelect', this.handlePanelSelect);
|
|
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
|
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
|
}
|
|
|
|
/** Hides the dropdown panel */
|
|
@Method()
|
|
async hide() {
|
|
this.ignoreOpenWatcher = true;
|
|
this.open = false;
|
|
|
|
const slHide = this.slHide.emit();
|
|
|
|
if (slHide.defaultPrevented) {
|
|
this.open = true;
|
|
this.ignoreOpenWatcher = false;
|
|
return;
|
|
}
|
|
|
|
this.popover.hide();
|
|
this.ignoreOpenWatcher = false;
|
|
|
|
this.panel.removeEventListener('slSelect', this.handlePanelSelect);
|
|
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
|
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
|
}
|
|
|
|
focusOnTrigger() {
|
|
const slot = this.trigger.querySelector('slot');
|
|
const trigger = slot.assignedElements({ flatten: true })[0] as any;
|
|
if (trigger) {
|
|
if (typeof trigger.setFocus === 'function') {
|
|
trigger.setFocus();
|
|
} else if (typeof trigger.focus === 'function') {
|
|
trigger.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
getMenu() {
|
|
return this.panel
|
|
.querySelector('slot')
|
|
.assignedElements({ flatten: true })
|
|
.filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as HTMLSlMenuElement;
|
|
}
|
|
|
|
handleDocumentKeyDown(event: KeyboardEvent) {
|
|
// Close when escape is pressed
|
|
if (event.key === 'Escape') {
|
|
this.hide();
|
|
this.focusOnTrigger();
|
|
return;
|
|
}
|
|
|
|
// Close when tabbing results in the focus leaving the containing element
|
|
if (event.key === 'Tab') {
|
|
setTimeout(() => {
|
|
if (
|
|
document.activeElement &&
|
|
document.activeElement.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
|
|
) {
|
|
this.hide();
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Prevent scrolling when certain keys are pressed
|
|
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
// If a menu is present, focus on it when certain keys are pressed
|
|
const menu = this.getMenu();
|
|
if (menu && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
|
|
event.preventDefault();
|
|
menu.setFocus();
|
|
return;
|
|
}
|
|
}
|
|
|
|
handleDocumentMouseDown(event: MouseEvent) {
|
|
// Close when clicking outside of the close element
|
|
const path = event.composedPath() as Array<EventTarget>;
|
|
if (!path.includes(this.containingElement)) {
|
|
this.hide();
|
|
return;
|
|
}
|
|
}
|
|
|
|
handlePanelSelect(event: CustomEvent) {
|
|
const target = event.target as HTMLElement;
|
|
|
|
// Hide the dropdown when a menu item is selected
|
|
if (this.closeOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
|
|
this.hide();
|
|
this.focusOnTrigger();
|
|
}
|
|
}
|
|
|
|
handleTriggerKeyDown(event: KeyboardEvent) {
|
|
// Open the panel when pressing down or up while focused on the trigger
|
|
if (!this.open && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
|
|
this.show();
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
// All other keys focus the menu and initiate type-to-select
|
|
const menu = this.getMenu();
|
|
if (menu && event.target !== menu) {
|
|
menu.setFocus();
|
|
menu.typeToSelect(event.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
togglePanel() {
|
|
this.open ? this.hide() : this.show();
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<div
|
|
part="base"
|
|
id={this.componentId}
|
|
class={{
|
|
dropdown: true,
|
|
'dropdown--open': this.open
|
|
}}
|
|
aria-expanded={this.open}
|
|
aria-haspopup="true"
|
|
>
|
|
<span
|
|
part="trigger"
|
|
class="dropdown__trigger"
|
|
ref={el => (this.trigger = el)}
|
|
onKeyDown={this.handleTriggerKeyDown}
|
|
onClick={this.togglePanel}
|
|
>
|
|
<slot name="trigger" />
|
|
</span>
|
|
|
|
<div
|
|
ref={el => (this.panel = el)}
|
|
part="panel"
|
|
class="dropdown__panel"
|
|
role="menu"
|
|
aria-hidden={!this.open}
|
|
aria-labeledby={this.componentId}
|
|
hidden
|
|
>
|
|
<slot />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|