@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
825 lines (824 loc) • 25.5 kB
JavaScript
/*!
* All material copyright ESRI, All Rights Reserved, unless otherwise specified.
* See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details.
* v1.5.0-next.4
*/
import { h, Host } from "@stencil/core";
import { focusElement, focusElementInGroup, isPrimaryPointerButton, toAriaBoolean } from "../../utils/dom";
import { connectFloatingUI, defaultMenuPlacement, disconnectFloatingUI, filterComputedPlacements, FloatingCSS, reposition } from "../../utils/floating-ui";
import { guid } from "../../utils/guid";
import { connectInteractive, disconnectInteractive, updateHostInteraction } from "../../utils/interactive";
import { isActivationKey } from "../../utils/key";
import { componentLoaded, setComponentLoaded, setUpLoadableComponent } from "../../utils/loadable";
import { createObserver } from "../../utils/observers";
import { connectOpenCloseComponent, disconnectOpenCloseComponent } from "../../utils/openCloseComponent";
import { SLOTS } from "./resources";
/**
* @slot - A slot for adding `calcite-dropdown-group` elements. Every `calcite-dropdown-item` must have a parent `calcite-dropdown-group`, even if the `groupTitle` property is not set.
* @slot trigger - A slot for the element that triggers the `calcite-dropdown`.
*/
export class Dropdown {
constructor() {
this.items = [];
this.groups = [];
this.mutationObserver = createObserver("mutation", () => this.updateItems());
this.resizeObserver = createObserver("resize", (entries) => this.resizeObserverCallback(entries));
this.openTransitionProp = "opacity";
this.guid = `calcite-dropdown-${guid()}`;
this.defaultAssignedElements = [];
//--------------------------------------------------------------------------
//
// Private Methods
//
//--------------------------------------------------------------------------
this.slotChangeHandler = (event) => {
this.defaultAssignedElements = event.target.assignedElements({
flatten: true
});
this.updateItems();
};
this.setFilteredPlacements = () => {
const { el, flipPlacements } = this;
this.filteredFlipPlacements = flipPlacements
? filterComputedPlacements(flipPlacements, el)
: null;
};
this.updateTriggers = (event) => {
this.triggers = event.target.assignedElements({
flatten: true
});
this.reposition(true);
};
this.updateItems = () => {
this.items = this.groups
.map((group) => Array.from(group?.querySelectorAll("calcite-dropdown-item")))
.reduce((previousValue, currentValue) => [...previousValue, ...currentValue], []);
this.updateSelectedItems();
this.reposition(true);
};
this.updateGroups = (event) => {
const groups = event.target
.assignedElements({ flatten: true })
.filter((el) => el?.matches("calcite-dropdown-group"));
this.groups = groups;
this.updateItems();
};
this.resizeObserverCallback = (entries) => {
entries.forEach((entry) => {
const { target } = entry;
if (target === this.referenceEl) {
this.setDropdownWidth();
}
else if (target === this.scrollerEl) {
this.setMaxScrollerHeight();
}
});
};
this.setDropdownWidth = () => {
const { referenceEl, scrollerEl } = this;
const referenceElWidth = referenceEl?.clientWidth;
if (!referenceElWidth || !scrollerEl) {
return;
}
scrollerEl.style.minWidth = `${referenceElWidth}px`;
};
this.setMaxScrollerHeight = () => {
const { scrollerEl } = this;
if (!scrollerEl) {
return;
}
this.reposition(true);
const maxScrollerHeight = this.getMaxScrollerHeight();
scrollerEl.style.maxHeight = maxScrollerHeight > 0 ? `${maxScrollerHeight}px` : "";
this.reposition(true);
};
this.setScrollerAndTransitionEl = (el) => {
this.resizeObserver.observe(el);
this.scrollerEl = el;
this.transitionEl = el;
connectOpenCloseComponent(this);
};
this.setReferenceEl = (el) => {
this.referenceEl = el;
connectFloatingUI(this, this.referenceEl, this.floatingEl);
this.resizeObserver.observe(el);
};
this.setFloatingEl = (el) => {
this.floatingEl = el;
connectFloatingUI(this, this.referenceEl, this.floatingEl);
};
this.keyDownHandler = (event) => {
const target = event.target;
if (target !== this.referenceEl) {
return;
}
const { defaultPrevented, key } = event;
if (defaultPrevented) {
return;
}
if (this.open) {
if (key === "Escape") {
this.closeCalciteDropdown();
event.preventDefault();
return;
}
else if (event.shiftKey && key === "Tab") {
this.closeCalciteDropdown();
event.preventDefault();
return;
}
}
if (isActivationKey(key)) {
this.openCalciteDropdown();
event.preventDefault();
}
else if (key === "Escape") {
this.closeCalciteDropdown();
event.preventDefault();
}
};
this.focusOnFirstActiveOrFirstItem = () => {
this.getFocusableElement(this.items.find((item) => item.selected) || this.items[0]);
};
this.toggleOpenEnd = () => {
this.focusOnFirstActiveOrFirstItem();
this.el.removeEventListener("calciteDropdownOpen", this.toggleOpenEnd);
};
this.openCalciteDropdown = () => {
this.open = !this.open;
if (this.open) {
this.el.addEventListener("calciteDropdownOpen", this.toggleOpenEnd);
}
};
this.open = false;
this.closeOnSelectDisabled = false;
this.disabled = false;
this.flipPlacements = undefined;
this.maxItems = 0;
this.overlayPositioning = "absolute";
this.placement = defaultMenuPlacement;
this.scale = "m";
this.selectedItems = [];
this.type = "click";
this.width = undefined;
}
openHandler(value) {
if (!this.disabled) {
if (value) {
this.reposition(true);
}
return;
}
this.open = false;
}
handleDisabledChange(value) {
if (!value) {
this.open = false;
}
}
flipPlacementsHandler() {
this.setFilteredPlacements();
this.reposition(true);
}
maxItemsHandler() {
this.setMaxScrollerHeight();
}
overlayPositioningHandler() {
this.reposition(true);
}
placementHandler() {
this.reposition(true);
}
//--------------------------------------------------------------------------
//
// Public Methods
//
//--------------------------------------------------------------------------
/** Sets focus on the component's first focusable element. */
async setFocus() {
await componentLoaded(this);
this.el.focus();
}
//--------------------------------------------------------------------------
//
// Lifecycle
//
//--------------------------------------------------------------------------
connectedCallback() {
this.mutationObserver?.observe(this.el, { childList: true, subtree: true });
this.setFilteredPlacements();
this.reposition(true);
if (this.open) {
this.openHandler(this.open);
}
connectInteractive(this);
connectOpenCloseComponent(this);
}
componentWillLoad() {
setUpLoadableComponent(this);
}
componentDidLoad() {
setComponentLoaded(this);
this.reposition(true);
}
componentDidRender() {
updateHostInteraction(this);
}
disconnectedCallback() {
this.mutationObserver?.disconnect();
this.resizeObserver?.disconnect();
disconnectInteractive(this);
disconnectFloatingUI(this, this.referenceEl, this.floatingEl);
disconnectOpenCloseComponent(this);
}
render() {
const { open, guid } = this;
return (h(Host, null, h("div", { class: "calcite-trigger-container", id: `${guid}-menubutton`, onClick: this.openCalciteDropdown, onKeyDown: this.keyDownHandler,
// eslint-disable-next-line react/jsx-sort-props
ref: this.setReferenceEl }, h("slot", { "aria-controls": `${guid}-menu`, "aria-expanded": toAriaBoolean(open), "aria-haspopup": "menu", name: SLOTS.dropdownTrigger, onSlotchange: this.updateTriggers })), h("div", { "aria-hidden": toAriaBoolean(!open), class: "calcite-dropdown-wrapper",
// eslint-disable-next-line react/jsx-sort-props
ref: this.setFloatingEl }, h("div", { "aria-labelledby": `${guid}-menubutton`, class: {
["calcite-dropdown-content"]: true,
[FloatingCSS.animation]: true,
[FloatingCSS.animationActive]: open
}, id: `${guid}-menu`, role: "menu",
// eslint-disable-next-line react/jsx-sort-props
ref: this.setScrollerAndTransitionEl }, h("slot", { onSlotchange: this.updateGroups })))));
}
//--------------------------------------------------------------------------
//
// Public Methods
//
//--------------------------------------------------------------------------
/**
* Updates the position of the component.
*
* @param delayed
*/
async reposition(delayed = false) {
const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this;
return reposition(this, {
floatingEl,
referenceEl,
overlayPositioning,
placement,
flipPlacements: filteredFlipPlacements,
type: "menu"
}, delayed);
}
closeCalciteDropdownOnClick(event) {
if (this.disabled ||
!isPrimaryPointerButton(event) ||
!this.open ||
event.composedPath().includes(this.el)) {
return;
}
this.closeCalciteDropdown(false);
}
closeCalciteDropdownOnEvent(event) {
this.closeCalciteDropdown();
event.stopPropagation();
}
closeCalciteDropdownOnOpenEvent(event) {
if (event.composedPath().includes(this.el)) {
return;
}
this.open = false;
}
pointerEnterHandler() {
if (this.disabled || this.type !== "hover") {
return;
}
this.openCalciteDropdown();
}
pointerLeaveHandler() {
if (this.disabled || this.type !== "hover") {
return;
}
this.closeCalciteDropdown();
}
calciteInternalDropdownItemKeyEvent(event) {
const { keyboardEvent } = event.detail;
const target = keyboardEvent.target;
switch (keyboardEvent.key) {
case "Tab":
if (this.items.indexOf(target) === this.items.length - 1 && !keyboardEvent.shiftKey) {
this.closeCalciteDropdown();
}
else if (this.items.indexOf(target) === 0 && keyboardEvent.shiftKey) {
this.closeCalciteDropdown();
}
break;
case "ArrowDown":
focusElementInGroup(this.items, target, "next");
break;
case "ArrowUp":
focusElementInGroup(this.items, target, "previous");
break;
case "Home":
focusElementInGroup(this.items, target, "first");
break;
case "End":
focusElementInGroup(this.items, target, "last");
break;
}
event.stopPropagation();
}
handleItemSelect(event) {
this.updateSelectedItems();
event.stopPropagation();
this.calciteDropdownSelect.emit();
if (!this.closeOnSelectDisabled ||
event.detail.requestedDropdownGroup.selectionMode === "none") {
this.closeCalciteDropdown();
}
event.stopPropagation();
}
onBeforeOpen() {
this.calciteDropdownBeforeOpen.emit();
}
onOpen() {
this.calciteDropdownOpen.emit();
}
onBeforeClose() {
this.calciteDropdownBeforeClose.emit();
}
onClose() {
this.calciteDropdownClose.emit();
}
updateSelectedItems() {
this.selectedItems = this.items.filter((item) => item.selected);
}
getMaxScrollerHeight() {
const { maxItems, items } = this;
let itemsToProcess = 0;
let maxScrollerHeight = 0;
let groupHeaderHeight;
this.groups.forEach((group) => {
if (maxItems > 0 && itemsToProcess < maxItems) {
Array.from(group.children).forEach((item, index) => {
if (index === 0) {
if (isNaN(groupHeaderHeight)) {
groupHeaderHeight = item.offsetTop;
}
maxScrollerHeight += groupHeaderHeight;
}
if (itemsToProcess < maxItems) {
maxScrollerHeight += item.offsetHeight;
itemsToProcess += 1;
}
});
}
});
return items.length > maxItems ? maxScrollerHeight : 0;
}
closeCalciteDropdown(focusTrigger = true) {
this.open = false;
if (focusTrigger) {
focusElement(this.triggers[0]);
}
}
getFocusableElement(item) {
if (!item) {
return;
}
const target = item.attributes.isLink
? item.shadowRoot.querySelector("a")
: item;
focusElement(target);
}
static get is() { return "calcite-dropdown"; }
static get encapsulation() { return "shadow"; }
static get delegatesFocus() { return true; }
static get originalStyleUrls() {
return {
"$": ["dropdown.scss"]
};
}
static get styleUrls() {
return {
"$": ["dropdown.css"]
};
}
static get properties() {
return {
"open": {
"type": "boolean",
"mutable": true,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, displays and positions the component."
},
"attribute": "open",
"reflect": true,
"defaultValue": "false"
},
"closeOnSelectDisabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, the component will remain open after a selection is made.\n\nIf the `selectionMode` of the selected `calcite-dropdown-item`'s containing `calcite-dropdown-group` is `\"none\"`, the component will always close."
},
"attribute": "close-on-select-disabled",
"reflect": true,
"defaultValue": "false"
},
"disabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, interaction is prevented and the component is displayed with lower opacity."
},
"attribute": "disabled",
"reflect": true,
"defaultValue": "false"
},
"flipPlacements": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "EffectivePlacement[]",
"resolved": "Placement[]",
"references": {
"EffectivePlacement": {
"location": "import",
"path": "../../utils/floating-ui"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Defines the available placements that can be used when a flip occurs."
}
},
"maxItems": {
"type": "number",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the maximum number of `calcite-dropdown-item`s to display before showing a scroller.\nValue must be greater than `0`, and does not include `groupTitle`'s from `calcite-dropdown-group`."
},
"attribute": "max-items",
"reflect": true,
"defaultValue": "0"
},
"overlayPositioning": {
"type": "string",
"mutable": false,
"complexType": {
"original": "OverlayPositioning",
"resolved": "\"absolute\" | \"fixed\"",
"references": {
"OverlayPositioning": {
"location": "import",
"path": "../../utils/floating-ui"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Determines the type of positioning to use for the overlaid content.\n\nUsing `\"absolute\"` will work for most cases. The component will be positioned inside of overflowing parent containers and will affect the container's layout.\n\n`\"fixed\"` should be used to escape an overflowing parent container, or when the reference element's `position` CSS property is `\"fixed\"`."
},
"attribute": "overlay-positioning",
"reflect": true,
"defaultValue": "\"absolute\""
},
"placement": {
"type": "string",
"mutable": false,
"complexType": {
"original": "MenuPlacement",
"resolved": "\"bottom\" | \"bottom-end\" | \"bottom-start\" | \"top\" | \"top-end\" | \"top-start\"",
"references": {
"MenuPlacement": {
"location": "import",
"path": "../../utils/floating-ui"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "default",
"text": "\"bottom-start\""
}],
"text": "Determines where the component will be positioned relative to the container element."
},
"attribute": "placement",
"reflect": true,
"defaultValue": "defaultMenuPlacement"
},
"scale": {
"type": "string",
"mutable": false,
"complexType": {
"original": "Scale",
"resolved": "\"l\" | \"m\" | \"s\"",
"references": {
"Scale": {
"location": "import",
"path": "../interfaces"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the size of the component."
},
"attribute": "scale",
"reflect": true,
"defaultValue": "\"m\""
},
"selectedItems": {
"type": "unknown",
"mutable": true,
"complexType": {
"original": "HTMLCalciteDropdownItemElement[]",
"resolved": "HTMLCalciteDropdownItemElement[]",
"references": {
"HTMLCalciteDropdownItemElement": {
"location": "global"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "readonly",
"text": undefined
}],
"text": "Specifies the component's selected items."
},
"defaultValue": "[]"
},
"type": {
"type": "string",
"mutable": false,
"complexType": {
"original": "\"hover\" | \"click\"",
"resolved": "\"click\" | \"hover\"",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the action to open the component from the container element."
},
"attribute": "type",
"reflect": true,
"defaultValue": "\"click\""
},
"width": {
"type": "string",
"mutable": false,
"complexType": {
"original": "Scale",
"resolved": "\"l\" | \"m\" | \"s\"",
"references": {
"Scale": {
"location": "import",
"path": "../interfaces"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the width of the component."
},
"attribute": "width",
"reflect": true
}
};
}
static get events() {
return [{
"method": "calciteDropdownSelect",
"name": "calciteDropdownSelect",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when a `calcite-dropdown-item`'s selection changes."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "calciteDropdownBeforeClose",
"name": "calciteDropdownBeforeClose",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is requested to be closed and before the closing transition begins."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "calciteDropdownClose",
"name": "calciteDropdownClose",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is closed and animation is complete."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "calciteDropdownBeforeOpen",
"name": "calciteDropdownBeforeOpen",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is added to the DOM but not rendered, and before the opening transition begins."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "calciteDropdownOpen",
"name": "calciteDropdownOpen",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is open and animation is complete."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}];
}
static get methods() {
return {
"setFocus": {
"complexType": {
"signature": "() => Promise<void>",
"parameters": [],
"references": {
"Promise": {
"location": "global"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "Sets focus on the component's first focusable element.",
"tags": []
}
},
"reposition": {
"complexType": {
"signature": "(delayed?: boolean) => Promise<void>",
"parameters": [{
"tags": [{
"name": "param",
"text": "delayed"
}],
"text": ""
}],
"references": {
"Promise": {
"location": "global"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "Updates the position of the component.",
"tags": [{
"name": "param",
"text": "delayed"
}]
}
}
};
}
static get elementRef() { return "el"; }
static get watchers() {
return [{
"propName": "open",
"methodName": "openHandler"
}, {
"propName": "disabled",
"methodName": "handleDisabledChange"
}, {
"propName": "flipPlacements",
"methodName": "flipPlacementsHandler"
}, {
"propName": "maxItems",
"methodName": "maxItemsHandler"
}, {
"propName": "overlayPositioning",
"methodName": "overlayPositioningHandler"
}, {
"propName": "placement",
"methodName": "placementHandler"
}];
}
static get listeners() {
return [{
"name": "pointerdown",
"method": "closeCalciteDropdownOnClick",
"target": "window",
"capture": false,
"passive": true
}, {
"name": "calciteInternalDropdownCloseRequest",
"method": "closeCalciteDropdownOnEvent",
"target": undefined,
"capture": false,
"passive": false
}, {
"name": "calciteDropdownOpen",
"method": "closeCalciteDropdownOnOpenEvent",
"target": "window",
"capture": false,
"passive": false
}, {
"name": "pointerenter",
"method": "pointerEnterHandler",
"target": undefined,
"capture": false,
"passive": true
}, {
"name": "pointerleave",
"method": "pointerLeaveHandler",
"target": undefined,
"capture": false,
"passive": true
}, {
"name": "calciteInternalDropdownItemKeyEvent",
"method": "calciteInternalDropdownItemKeyEvent",
"target": undefined,
"capture": false,
"passive": false
}, {
"name": "calciteInternalDropdownItemSelect",
"method": "handleItemSelect",
"target": undefined,
"capture": false,
"passive": false
}];
}
}