shoelace/src/components/dropdown/dropdown.ts

459 wiersze
14 KiB
TypeScript
Czysty Zwykły widok Historia

import type { Instance as PopperInstance } from '@popperjs/core/dist/esm';
import { createPopper } from '@popperjs/core/dist/esm';
2021-07-10 00:45:44 +00:00
import { LitElement, html } from 'lit';
2021-05-27 21:00:43 +00:00
import { customElement, property, query } from 'lit/decorators.js';
2021-09-29 12:40:26 +00:00
import { classMap } from 'lit/directives/class-map.js';
2021-07-10 00:45:44 +00:00
import styles from './dropdown.styles';
import type SlMenuItem from '~/components/menu-item/menu-item';
import type SlMenu from '~/components/menu/menu';
import { animateTo, stopAnimations } from '~/internal/animate';
import { emit, waitForEvent } from '~/internal/event';
import { scrollIntoView } from '~/internal/scroll';
import { getTabbableBoundary } from '~/internal/tabbable';
import { watch } from '~/internal/watch';
import { setDefaultAnimation, getAnimation } from '~/utilities/animation-registry';
2020-07-15 21:30:37 +00:00
/**
2020-07-17 10:09:10 +00:00
* @since 2.0
2020-07-15 21:30:37 +00:00
* @status stable
*
2021-06-25 20:25:46 +00:00
* @slot - The dropdown's content.
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
2020-07-15 21:30:37 +00:00
*
2021-06-25 20:25:46 +00:00
* @event sl-show - Emitted when the dropdown opens.
* @event sl-after-show - Emitted after the dropdown opens and all animations are complete.
* @event sl-hide - Emitted when the dropdown closes.
2021-07-01 21:23:16 +00:00
* @event sl-after-hide - Emitted after the dropdown closes and all animations are complete.
2021-05-26 12:42:33 +00:00
*
2021-06-25 20:25:46 +00:00
* @csspart base - The component's base wrapper.
* @csspart trigger - The container that wraps the trigger.
* @csspart panel - The panel that gets shown when the dropdown is open.
*
2021-06-25 20:25:46 +00:00
* @animation dropdown.show - The animation to use when showing the dropdown.
* @animation dropdown.hide - The animation to use when hiding the dropdown.
2020-07-15 21:30:37 +00:00
*/
2021-03-18 13:04:23 +00:00
@customElement('sl-dropdown')
2021-03-09 00:14:32 +00:00
export default class SlDropdown extends LitElement {
2021-07-10 00:45:44 +00:00
static styles = styles;
2021-03-06 17:01:39 +00:00
@query('.dropdown__trigger') trigger: HTMLElement;
@query('.dropdown__panel') panel: HTMLElement;
@query('.dropdown__positioner') positioner: HTMLElement;
2021-02-26 14:09:13 +00:00
private popover?: PopperInstance;
2020-07-15 21:30:37 +00:00
/** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */
2021-07-01 00:04:46 +00:00
@property({ type: Boolean, reflect: true }) open = false;
2020-07-15 21:30:37 +00:00
/**
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
* inside of the viewport.
*/
2021-03-06 17:01:39 +00:00
@property() placement:
2020-07-15 21:30:37 +00:00
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'right'
| 'right-start'
| 'right-end'
| 'left'
| 'left-start'
| 'left-end' = 'bottom-start';
2021-05-27 20:29:10 +00:00
/** Disables the dropdown so the panel will not open. */
2021-07-01 00:04:46 +00:00
@property({ type: Boolean }) disabled = false;
2021-05-27 20:29:10 +00:00
2021-07-01 21:23:16 +00:00
/**
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
* controls that allow multiple selections.
*/
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
2020-07-15 21:30:37 +00:00
/** The dropdown will close when the user interacts outside of this element (e.g. clicking). */
@property({ attribute: false }) containingElement?: HTMLElement;
2020-07-15 21:30:37 +00:00
/** The distance in pixels from which to offset the panel away from its trigger. */
2021-09-24 12:31:54 +00:00
@property({ type: Number }) distance = 0;
2020-07-15 21:30:37 +00:00
/** The distance in pixels from which to offset the panel along its trigger. */
2021-07-01 00:04:46 +00:00
@property({ type: Number }) skidding = 0;
2020-07-15 21:30:37 +00:00
2020-08-25 21:07:28 +00:00
/**
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
* `overflow: auto|scroll`.
*/
2021-07-01 00:04:46 +00:00
@property({ type: Boolean }) hoist = false;
2021-03-06 17:01:39 +00:00
connectedCallback() {
super.connectedCallback();
this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this);
this.handlePanelSelect = this.handlePanelSelect.bind(this);
2021-02-26 14:09:13 +00:00
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
if (typeof this.containingElement === 'undefined') {
2021-02-26 14:09:13 +00:00
this.containingElement = this;
}
2021-05-26 11:51:57 +00:00
// Create the popover after render
void this.updateComplete.then(() => {
2021-05-26 12:42:33 +00:00
this.popover = createPopper(this.trigger, this.positioner, {
2021-05-26 11:51:57 +00:00
placement: this.placement,
2021-05-26 12:42:33 +00:00
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
2021-05-26 11:51:57 +00:00
}
2021-05-26 12:42:33 +00:00
]
2021-05-26 11:51:57 +00:00
});
2020-07-15 21:30:37 +00:00
});
2021-05-26 11:51:57 +00:00
}
2020-07-15 21:30:37 +00:00
2021-06-02 12:47:55 +00:00
firstUpdated() {
2021-05-26 12:42:33 +00:00
this.panel.hidden = !this.open;
2020-07-15 21:30:37 +00:00
}
2021-03-06 17:01:39 +00:00
disconnectedCallback() {
super.disconnectedCallback();
void void this.hide();
2021-11-29 14:55:31 +00:00
this.popover?.destroy();
2020-07-15 21:30:37 +00:00
}
focusOnTrigger() {
2021-02-26 14:09:13 +00:00
const slot = this.trigger.querySelector('slot')!;
const trigger = slot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (typeof trigger?.focus === 'function') {
trigger.focus();
}
}
2020-07-15 21:30:37 +00:00
getMenu() {
2021-02-26 14:09:13 +00:00
const slot = this.panel.querySelector('slot')!;
return slot.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
| SlMenu
| undefined;
2020-07-15 21:30:37 +00:00
}
handleDocumentKeyDown(event: KeyboardEvent) {
// Close when escape is pressed
if (event.key === 'Escape') {
void this.hide();
this.focusOnTrigger();
2020-07-15 21:30:37 +00:00
return;
}
2020-09-03 21:44:52 +00:00
// Handle tabbing
2020-07-15 21:30:37 +00:00
if (event.key === 'Tab') {
2020-10-22 17:42:52 +00:00
// Tabbing within an open menu should close the dropdown and refocus the trigger
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
event.preventDefault();
void this.hide();
2020-10-22 17:42:52 +00:00
this.focusOnTrigger();
return;
}
// Tabbing outside of the containing element closes the panel
//
// If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
2020-07-15 21:30:37 +00:00
setTimeout(() => {
const activeElement =
this.containingElement?.getRootNode() instanceof ShadowRoot
2021-02-26 14:09:13 +00:00
? document.activeElement?.shadowRoot?.activeElement
: document.activeElement;
if (
typeof this.containingElement === 'undefined' ||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
) {
void this.hide();
2020-07-15 21:30:37 +00:00
}
});
}
}
handleDocumentMouseDown(event: MouseEvent) {
2020-09-02 20:59:20 +00:00
// Close when clicking outside of the containing element
const path = event.composedPath();
if (typeof this.containingElement !== 'undefined' && !path.includes(this.containingElement)) {
void this.hide();
2020-07-15 21:30:37 +00:00
}
}
handleMenuItemActivate(event: CustomEvent) {
2021-02-26 14:09:13 +00:00
const item = event.target as SlMenuItem;
scrollIntoView(item, this.panel);
}
2020-07-15 21:30:37 +00:00
handlePanelSelect(event: CustomEvent) {
const target = event.target as HTMLElement;
// Hide the dropdown when a menu item is selected
2021-07-01 21:23:16 +00:00
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
void this.hide();
this.focusOnTrigger();
2020-07-15 21:30:37 +00:00
}
}
2021-03-06 20:09:12 +00:00
@watch('distance')
@watch('hoist')
@watch('placement')
@watch('skidding')
handlePopoverOptionsChange() {
void this.popover?.setOptions({
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
2021-05-26 12:42:33 +00:00
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
}
]
});
2021-03-06 20:09:12 +00:00
}
2020-09-03 21:44:52 +00:00
handleTriggerClick() {
if (this.open) {
void this.hide();
} else {
void this.show();
}
2020-09-03 21:44:52 +00:00
}
2020-07-15 21:30:37 +00:00
handleTriggerKeyDown(event: KeyboardEvent) {
2020-09-03 21:44:52 +00:00
const menu = this.getMenu();
const menuItems = typeof menu !== 'undefined' ? ([...menu.querySelectorAll('sl-menu-item')] as SlMenuItem[]) : [];
2020-10-22 17:42:52 +00:00
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
2020-09-03 21:44:52 +00:00
// Close when escape or tab is pressed
if (event.key === 'Escape') {
this.focusOnTrigger();
void this.hide();
2020-09-03 21:44:52 +00:00
return;
}
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
// key again to hide the menu in case they don't want to make a selection.
if ([' ', 'Enter'].includes(event.key)) {
2020-07-15 21:30:37 +00:00
event.preventDefault();
this.handleTriggerClick();
2020-09-03 21:44:52 +00:00
return;
2020-07-15 21:30:37 +00:00
}
2020-09-03 21:44:52 +00:00
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
// faster navigation.
if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
// Show the menu if it's not already open
if (!this.open) {
void this.show();
2020-09-03 21:44:52 +00:00
}
2020-10-22 17:42:52 +00:00
// Focus on a menu item
if (event.key === 'ArrowDown' && typeof firstMenuItem !== 'undefined') {
menu!.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
2020-10-22 17:42:52 +00:00
return;
}
if (event.key === 'ArrowUp' && typeof lastMenuItem !== 'undefined') {
menu!.setCurrentItem(lastMenuItem);
lastMenuItem.focus();
2020-09-03 21:44:52 +00:00
return;
}
}
// Other keys bring focus to the menu and initiate type-to-select behavior
const ignoredKeys = ['Tab', 'Shift', 'Meta', 'Ctrl', 'Alt'];
if (this.open && !ignoredKeys.includes(event.key)) {
menu?.typeToSelect(event.key);
}
2020-07-15 21:30:37 +00:00
}
2020-09-11 14:55:24 +00:00
handleTriggerKeyUp(event: KeyboardEvent) {
// Prevent space from triggering a click event in Firefox
if (event.key === ' ') {
event.preventDefault();
}
}
2020-10-16 21:04:35 +00:00
handleTriggerSlotChange() {
this.updateAccessibleTrigger();
}
//
// Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
// `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
// that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
// a child of the slotted element, or an element in the slotted element's shadow root.
//
// For example, the accessible trigger of an <sl-button> is a <button> located inside its shadow root.
//
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
//
updateAccessibleTrigger() {
const slot = this.trigger.querySelector('slot')!;
const assignedElements = slot.assignedElements({ flatten: true }) as HTMLElement[];
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
if (typeof accessibleTrigger !== 'undefined') {
accessibleTrigger.setAttribute('aria-haspopup', 'true');
accessibleTrigger.setAttribute('aria-expanded', this.open ? 'true' : 'false');
2020-10-16 21:04:35 +00:00
}
}
2021-05-27 20:29:10 +00:00
/** Shows the dropdown panel. */
2021-05-26 12:42:33 +00:00
async show() {
2021-05-27 20:29:10 +00:00
if (this.open) {
return undefined;
2021-02-26 14:09:13 +00:00
}
2021-05-26 12:42:33 +00:00
this.open = true;
2021-05-27 20:29:10 +00:00
return waitForEvent(this, 'sl-after-show');
2021-02-26 14:09:13 +00:00
}
/** Hides the dropdown panel */
2021-05-26 12:42:33 +00:00
async hide() {
2021-05-27 20:29:10 +00:00
if (!this.open) {
return undefined;
2021-02-26 14:09:13 +00:00
}
2021-05-26 12:42:33 +00:00
this.open = false;
2021-05-27 20:29:10 +00:00
return waitForEvent(this, 'sl-after-hide');
2021-02-26 14:09:13 +00:00
}
/**
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
* is activated.
*/
reposition() {
if (!this.open) {
return;
}
void this.popover?.update();
2021-02-26 14:09:13 +00:00
}
2021-06-15 13:26:35 +00:00
@watch('open', { waitUntilFirstUpdate: true })
2021-05-27 20:29:10 +00:00
async handleOpenChange() {
2021-06-15 13:26:35 +00:00
if (this.disabled) {
2021-05-27 20:29:10 +00:00
return;
}
2021-02-26 14:09:13 +00:00
2021-03-06 20:09:12 +00:00
this.updateAccessibleTrigger();
2021-05-27 20:29:10 +00:00
if (this.open) {
// Show
emit(this, 'sl-show');
2021-05-27 20:29:10 +00:00
this.panel.addEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.addEventListener('sl-select', this.handlePanelSelect);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
void this.popover?.update();
2021-05-27 20:29:10 +00:00
this.panel.hidden = false;
const { keyframes, options } = getAnimation(this, 'dropdown.show');
await animateTo(this.panel, keyframes, options);
emit(this, 'sl-after-show');
2021-05-27 20:29:10 +00:00
} else {
// Hide
emit(this, 'sl-hide');
2021-05-27 20:29:10 +00:00
this.panel.removeEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
2021-06-05 16:28:35 +00:00
document.removeEventListener('keydown', this.handleDocumentKeyDown);
2021-05-27 20:29:10 +00:00
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'dropdown.hide');
await animateTo(this.panel, keyframes, options);
this.panel.hidden = true;
emit(this, 'sl-after-hide');
2021-05-27 20:29:10 +00:00
}
2021-02-26 14:09:13 +00:00
}
2020-07-15 21:30:37 +00:00
render() {
2021-02-26 14:09:13 +00:00
return html`
2020-07-15 21:30:37 +00:00
<div
part="base"
2021-12-30 17:14:39 +00:00
id="dropdown"
2021-02-26 14:09:13 +00:00
class=${classMap({
2020-07-15 21:30:37 +00:00
dropdown: true,
'dropdown--open': this.open
2021-02-26 14:09:13 +00:00
})}
2020-07-15 21:30:37 +00:00
>
<span
part="trigger"
class="dropdown__trigger"
2021-03-06 17:01:39 +00:00
@click=${this.handleTriggerClick}
@keydown=${this.handleTriggerKeyDown}
@keyup=${this.handleTriggerKeyUp}
2020-07-15 21:30:37 +00:00
>
2021-03-06 17:39:13 +00:00
<slot name="trigger" @slotchange=${this.handleTriggerSlotChange}></slot>
2020-07-15 21:30:37 +00:00
</span>
2021-05-26 12:42:33 +00:00
<!-- Position the panel with a wrapper since the popover makes use of translate. This let's us add animations
2021-02-26 14:09:13 +00:00
on the panel without interfering with the position. -->
2021-03-06 17:01:39 +00:00
<div class="dropdown__positioner">
2020-08-25 21:51:52 +00:00
<div
part="panel"
class="dropdown__panel"
2021-02-26 14:09:13 +00:00
aria-hidden=${this.open ? 'false' : 'true'}
2021-12-30 17:14:39 +00:00
aria-labelledby="dropdown"
2020-08-25 21:51:52 +00:00
>
2021-03-06 17:39:13 +00:00
<slot></slot>
2020-08-25 21:51:52 +00:00
</div>
2020-07-15 21:30:37 +00:00
</div>
</div>
2021-02-26 14:09:13 +00:00
`;
2020-07-15 21:30:37 +00:00
}
}
2021-05-26 12:42:33 +00:00
setDefaultAnimation('dropdown.show', {
keyframes: [
{ opacity: 0, transform: 'scale(0.9)' },
{ opacity: 1, transform: 'scale(1)' }
],
2021-11-12 14:43:02 +00:00
options: { duration: 100, easing: 'ease' }
2021-05-26 12:42:33 +00:00
});
setDefaultAnimation('dropdown.hide', {
keyframes: [
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.9)' }
],
2021-11-12 14:43:02 +00:00
options: { duration: 100, easing: 'ease' }
2021-05-26 12:42:33 +00:00
});
2021-03-12 14:09:08 +00:00
declare global {
interface HTMLElementTagNameMap {
'sl-dropdown': SlDropdown;
}
}