I’m making a component and ran into an issue that I can’t think and find any working solutions. When anything is in the dropdown their events aren’t being listented too, but when they go outside of the dropdown and into the header then they work. I’m using the Calcite Design System component library for Calcite Actions but I tried using divs/buttons to make sure that wasn’t the issue. I tried playing with pointer-events too but it didn’t work too.
When you open the codepen and click the buttons, you’ll notice that text is being added in the console, however when you click the test button that sends the buttons to the dropdown by changing their slot attribute, the buttons will no longer send stuff to the console anymore.
https://codepen.io/Keeron1/pen/GgpJbEO
class Floater extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open"
});
// Wait for Calcite's <calcite-action> to be defined before injecting HTML
customElements.whenDefined("calcite-action").then(() => {
// Inject HTML template
const tpl = document.getElementById("floater-template");
const clone = tpl.content.cloneNode(true);
this.shadowRoot.appendChild(clone);
this._init();
});
}
// Public API
get position() {
return this.getAttribute("position") || "top-left";
}
set position(v) {
this.setAttribute("position", v);
}
get isDraggable() {
if (!this.hasAttribute("draggable")) return true
return this.getAttribute("draggable") === "true" ? true : false
}
set isDraggable(v) {
this.setAttribute("draggable", v);
}
get isResizable() {
return this.getAttribute("resizable") || "none"
}
set isResizable(v) {
this.setAttribute("resizable", v);
}
get isHandleEnabled() {
if (!this.hasAttribute("handle-enabled")) return false
return this.getAttribute("handle-enabled") === "true" ? true : false
}
set isHandleEnabled(v) {
this.setAttribute("handle-enabled", v);
}
_init() {
// Elements
this.floaterCont = this.shadowRoot.querySelector(".floater-container")
this.floater = this.shadowRoot.querySelector(".floater");
// Heading
this.heading = this.shadowRoot.querySelector(".floater-heading")
this.hTitle = this.shadowRoot.querySelector(".floater-heading-title");
this.originalhTitle = this.hTitle?.outerHTML || null;
this.headingEnd = this.shadowRoot.querySelector(".floater-heading-end")
this.headingEndSlot = this.shadowRoot.querySelector('slot[name="heading-end"]');
this.closeButton = this.shadowRoot.querySelector(".floater-close");
this.originalCloseButton = this.closeButton?.outerHTML || null
// Dropdown
this.headingDropdown = this.shadowRoot.querySelector(".floater-heading-dropdown")
this.originalHeadingDropdown = this.headingDropdown?.outerHTML || null
this.headingDropdownAction = this.shadowRoot.querySelector(".floater-heading-dropdown-action")
this.headingDropdownItems = this.shadowRoot.querySelector(".floater-heading-dropdown-items")
this.headingEndDropdownSlot = this.shadowRoot.querySelector('slot[name="dropdown-heading-end"]')
// Content
this.floaterContent = this.shadowRoot.querySelector(".floater-content");
this.contentSlot = this.shadowRoot.querySelector('slot:not([name])');
this.test = this.shadowRoot.querySelector(".floater-test")
// Attributes
this.isDragging = false;
this.dragOffsetX = 0; // Distance between cursor and left of the component
this.dragOffsetY = 0; // Distance between cursor and top of the component
this.lastWindowWH = {
width: null,
height: null
}; // Window width and height
requestAnimationFrame(() => {
this._updateTitle();
this._updateClose();
this._setScale();
this._updateDraggable();
this._updateResizable();
window.addEventListener("resize", this._onResize);
this.closeButton?.addEventListener("click", this._close);
this.test?.addEventListener("click", this._testClick)
this._setStartingPosition();
this.lastWindowWH.width = window.innerWidth;
this.lastWindowWH.height = window.innerHeight;
});
}
// Trigger on component created (not used since we need to wait for calcite action)
connectedCallback() {}
// Trigger on component delete
disconnectedCallback() {
window.removeEventListener("resize", this._onResize)
if (this.isDraggable) this.heading.removeEventListener("pointerdown", this._onDown)
if (this.closeButton) this.closeButton.removeEventListener("click", this._close)
if (this.test) this.test.removeEventListener("click", this._testClick)
}
static get observedAttributes() {
return ["title", "close-disabled", "scale", "draggable", "resizable", "handle-enabled"];
}
// Trigger when attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (name === "title") this._updateTitle();
if (name === "close-disabled") this._updateClose();
if (name === "scale") this._setScale();
if (name === "draggable") this._updateDraggable();
if (name === "resizable") this._updateResizable();
if (name === "handle-enabled") this._updateHandle();
}
_testClick = () => {
if (!this.isHandleEnabled) this.isHandleEnabled = true
else this.isHandleEnabled = false
}
_updateHandle = () => {
if (this.isHandleEnabled) this._sendHeadingItemsToDropdown();
else this._sendDropdownItemsToHeading();
}
_sendHeadingItemsToDropdown = () => {
if (!this.hasAttribute("close-disabled"))
this.headingDropdownItems.insertBefore(this.closeButton, this.headingEndDropdownSlot)
const endSlot = this.headingEndSlot.assignedElements()
if (endSlot.length === 0) return;
endSlot.forEach(element => {
element.setAttribute("slot", "dropdown-heading-end");
});
}
_sendDropdownItemsToHeading = () => {
if (!this.hasAttribute("close-disabled")) this.headingEnd.append(this.closeButton)
const endSlot = this.headingEndDropdownSlot.assignedElements()
if (endSlot.length === 0) return;
endSlot.forEach(element => {
element.setAttribute("slot", "heading-end");
});
}
_close = () => {
console.log("closed btn clicked")
this.dispatchEvent(new CustomEvent("floaterClose", {
bubbles: true,
composed: true
}));
if (!this.hasAttribute("manual-close"))
this.remove();
}
_onResize = () => {
// Could be improved by saving the floater's left value before it gets pushed, so
// that when the window could grow it could stick to that value
const winWidth = window.innerWidth
const winHeight = window.innerHeight
// Calculate window delta
const deltaX = winWidth - this.lastWindowWH.width;
const deltaY = winHeight - this.lastWindowWH.height;
// Get floater's current properties
const floaterRect = this.floater.getBoundingClientRect();
const currentTop = floaterRect.top
const currentLeft = floaterRect.left
const fw = this.floater.offsetWidth;
const fh = this.floater.offsetHeight;
// Remove inital position class
this.floaterCont.classList.remove("top-left", "top-right", "bottom-left", "bottom-right", "center");
let newTop = currentTop + deltaY;
let newLeft = currentLeft + deltaX;
// Horizontal nudge only on shrink AND if closer to the right edge
if (deltaX < 0) {
const distRight = winWidth - fw - currentLeft;
if (distRight <= currentLeft) // if right is smaller than left or equal
newLeft = currentLeft + deltaX;
else newLeft = currentLeft
}
// Vertical nudge only on shrink AND if closer to the bottom edge
if (deltaY < 0) {
const distBottom = winHeight - fh - currentTop;
if (distBottom <= currentTop)
newTop = currentTop + deltaY;
else newTop = currentTop
}
// Clamp absolute position to viewport
newLeft = Math.max(0, Math.min(winWidth - fw, newLeft));
newTop = Math.max(0, Math.min(winHeight - fh, newTop));
// Convert back to container-relative
const contRect = this.floaterCont.getBoundingClientRect();
newLeft -= contRect.left;
newTop -= contRect.top;
// Apply
this.floater.style.top = `${newTop}px`;
this.floater.style.left = `${newLeft}px`;
// Save updated values
this.lastWindowWH.width = winWidth
this.lastWindowWH.height = winHeight
}
// This function will either create or delete a component, depending on the attribute
_handleElementLife = (atr, currentEl, originalEl, parent) => {
// If empty then remove the element
if (!atr.trim()) {
if (currentEl) currentEl.remove()
return null;
}
// Add the element
if (!currentEl && originalEl) {
const temp = document.createElement("div");
temp.innerHTML = originalEl;
currentEl = temp.firstElementChild;
parent.insertBefore(currentEl, parent.firstChild);
}
if (currentEl) {
currentEl.textContent = atr;
return currentEl;
}
}
_updateDraggable = () => {
if (this.isDraggable) {
this.heading.classList.add("draggable")
this.heading.addEventListener("pointerdown", this._onDown)
} else {
this.heading.classList.remove("draggable")
this.heading.removeEventListener("pointerdown", this._onDown)
}
}
_updateResizable = () => {
this.floaterContent.classList.remove("resize-horizontal", "resize-vertical", "resize-both");
switch (this.isResizable) {
case "horizontal":
this.floaterContent.classList.add("resize-horizontal")
break
case "vertical":
this.floaterContent.classList.add("resize-vertical")
break
case "both":
this.floaterContent.classList.add("resize-both")
break
}
}
_updateTitle = () => {
const titleAtr = this.getAttribute("title") || "";
this.hTitle = this._handleElementLife(titleAtr, this.hTitle, this.originalhTitle, this.heading)
}
_updateClose = () => {
const disabled = this.hasAttribute("close-disabled");
if (disabled) {
this.closeButton.removeEventListener("click", this._close);
this.closeButton.remove();
this.closeButton = null;
return;
}
// Add the element
if (!this.closeButton && this.originalCloseButton) {
const temp = document.createElement("div");
temp.innerHTML = this.originalCloseButton;
this.closeButton = temp.firstElementChild;
this.closeButton.addEventListener("click", this._close);
this.closeButton.scale = this.getAttribute("scale") || "s"
if (this.isHandleEnabled) this.headingDropdownItems.insertBefore(this.closeButton, this.headingEndDropdownSlot)
else this.headingEnd.append(this.closeButton);
}
}
_setStartingPosition = () => {
switch (this.position) {
case "center":
this.floaterCont.classList.add("center")
break;
case "top-left":
this.floaterCont.classList.add("top-left")
break;
case "top-right":
this.floaterCont.classList.add("top-right")
break;
case "bottom-left":
this.floaterCont.classList.add("bottom-left")
break;
case "bottom-right":
this.floaterCont.classList.add("bottom-right")
break;
}
}
_setScale = () => {
let scaleAtr = this.getAttribute("scale") || "s";
if (this.closeButton) this.closeButton.scale = scaleAtr
if (this.headingDropdownAction) this.headingDropdownAction.scale = scaleAtr
}
// Handle floater movement
_onDown = (e) => {
if (e.target.closest(".floater-heading-end")) return;
if (this.headingEndSlot.assignedElements().some(el => el === e.target)) return
e.preventDefault();
this.isDragging = true;
// capture the pointer so we don't lose events
this.setPointerCapture(e.pointerId);
// Compute position based on visual location
const rect = this.floater.getBoundingClientRect();
this.dragOffsetX = e.clientX - rect.left;
this.dragOffsetY = e.clientY - rect.top;
this.addEventListener("pointermove", this._onMove);
this.addEventListener("pointerup", this._onUp);
};
_onMove = (e) => {
if (!this.isDragging) return;
// Remove previous
this.floaterCont.classList.remove("top-left", "top-right", "bottom-left", "bottom-right", "center");
// New positions
let newTop = e.clientY - this.dragOffsetY;
let newLeft = e.clientX - this.dragOffsetX;
const vw = window.innerWidth;
const vh = window.innerHeight;
const fw = this.floater.offsetWidth;
const fh = this.floater.offsetHeight;
// Clamp to viewport
newTop = Math.max(0, Math.min(vh - fh, newTop));
newLeft = Math.max(0, Math.min(vw - fw, newLeft));
// Remove container offset
const contRect = this.floaterCont.getBoundingClientRect();
newTop -= contRect.top;
newLeft -= contRect.left;
this.floater.style.top = `${newTop}px`;
this.floater.style.left = `${newLeft}px`;
}
_onUp = (e) => {
this.isDragging = false;
this.releasePointerCapture(e.pointerId);
this.removeEventListener("mousemove", this._onMove);
this.removeEventListener("mouseup", this._onUp);
}
}
if (!customElements.get("custom-floater"))
customElements.define("custom-floater", Floater);
<script type="module" src="https://cdn.jsdelivr.net/npm/@esri/calcite-components@latest/dist/calcite/calcite.esm.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@esri/calcite-components@latest/dist/calcite/calcite.css" />
<template id="floater-template">
<style>
:host {
--floater-offset-x: 0px;
--floater-offset-y: 0px;
--floater-min-size-x: auto;
--floater-max-size-x: none;
--floater-min-size-y: auto;
--floater-max-size-y: none;
--floater-content-padding: var(--calcite-spacing-md);
--floater-opacity: 1;
--floater-content-opacity: 1;
}
.floater-container {
position: fixed;
z-index: var(--calcite-z-index-modal);
top: var(--floater-offset-y, 0);
left: var(--floater-offset-x, 0);
pointer-events: none;
}
/* Starting positions */
.floater-container.center {
inset: 0;
>.floater {
top: calc(50% + var(--floater-offset-y, 0px));
left: calc(50% + var(--floater-offset-x, 0px));
transform: translate(-50%, -50%);
}
}
.floater-container.top-right {
top: var(--floater-offset-y, 0);
right: var(--floater-offset-x, 0);
left: auto;
>.floater {
top: var(--calcite-spacing-md);
right: var(--calcite-spacing-md);
}
}
.floater-container.top-left>.floater {
top: var(--calcite-spacing-md);
left: var(--calcite-spacing-md);
}
.floater-container.bottom-left {
bottom: var(--floater-offset-y, 0);
left: var(--floater-offset-x, 0);
top: auto;
>.floater {
bottom: var(--calcite-spacing-md);
left: var(--calcite-spacing-md);
}
}
.floater-container.bottom-right {
bottom: var(--floater-offset-y, 0);
right: var(--floater-offset-x, 0);
top: auto;
left: auto;
>.floater {
bottom: var(--calcite-spacing-md);
right: var(--calcite-spacing-md);
}
}
/* End of starting positions */
.floater {
display: flex;
flex-direction: column;
max-height: 100vh;
position: absolute;
pointer-events: all;
box-sizing: border-box;
border-radius: 0.25rem;
background-color: var(--floater-background-color);
font-family: var(--calcite-sans-family);
box-shadow: var(--calcite-shadow-sm);
opacity: var(--floater-opacity);
}
.floater-heading {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
flex: 0 0 auto;
border-bottom: 1px solid var(--calcite-color-border-3);
user-select: none;
}
.floater-heading.draggable {
cursor: move;
}
.floater-heading-title {
padding: var(--calcite-spacing-xs) var(--calcite-spacing-md-plus);
font-weight: var(--calcite-font-weight-medium);
font-size: var(--calcite-font-size-0);
color: var(--calcite-color-text-1);
}
.floater-heading-end {
margin-left: auto;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
/* Dropdown menu */
.floater-heading-dropdown-action {
height: 100%;
}
.floater-heading-dropdown-items {
pointer-events: all;
visibility: hidden;
position: absolute;
top: auto;
right: 0;
background: var(--calcite-color-background);
padding: 0.5rem;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
z-index: 100;
border-radius: 4px;
cursor: default;
overflow-y: auto;
height: 100%;
max-height: 300px;
/* Firefox */
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-color);
}
slot[name="dropdown-heading-end"] {
display: flex;
flex-direction: column-reverse;
}
/* Chrome, Edge, Safari */
@supports selector(::-webkit-scrollbar) {
.floater-heading-dropdown-items {
/* Override rules */
scrollbar-width: auto;
scrollbar-color: auto;
}
.floater-heading-dropdown-items::-webkit-scrollbar {
width: var(--scrollbar-width);
}
.floater-heading-dropdown-items::-webkit-scrollbar-thumb {
background: var(--scrollbar-background-color);
border-radius: var(--scrollbar-border-radius);
}
}
.floater-heading-dropdown:hover .floater-heading-dropdown-items {
visibility: visible;
}
/* End of Dropdown menu */
/* .floater-close{ */
/* height: 100%; */
/* width: auto; */
/* background-color: transparent; */
/* border: 0; */
/* cursor: pointer; */
/* } */
.floater-content {
box-sizing: border-box;
padding: var(--floater-content-padding);
overflow: auto;
flex: 1 1 auto;
max-width: var(--floater-max-size-x);
min-width: var(--floater-min-size-x);
max-height: var(--floater-max-size-y);
min-height: var(--floater-min-size-y);
opacity: var(--floater-content-opacity);
}
.floater-content.resize-horizontal {
resize: horizontal;
}
.floater-content.resize-vertical {
resize: vertical;
}
.floater-content.resize-both {
resize: both;
}
/* Hide the resize handle */
.floater-content.resize-horizontal::-webkit-resizer,
.floater-content.resize-vertical::-webkit-resizer,
.floater-content.resize-both::-webkit-resizer {
display: none;
}
.floater-content.resize-horizontal::-moz-resizer,
.floater-content.resize-vertical::-moz-resizer,
.floater-content.resize-both::-moz-resizer {
display: none;
}
</style>
<div class="floater-container">
<div class="floater">
<div class="floater-heading">
<div class="floater-heading-title"></div>
<div class="floater-heading-end">
<slot name="heading-end"></slot>
<calcite-action class="floater-close" aria-label="Close" title="Close" icon="x"></calcite-action>
<button class="floater-test">test</button>
</div>
<div class="floater-heading-dropdown">
<calcite-action class="floater-heading-dropdown-action" aria-label="Actions Dropdown" title="Actions Dropdown" icon="handle-vertical"></calcite-action>
<div class="floater-heading-dropdown-items">
<slot name="dropdown-heading-end"></slot>
</div>
</div>
</div>
<div class="floater-content">
<slot></slot>
</div>
</div>
</div>
</template>
<custom-floater title="Draw" position="top-right" scale="s" manual-close style={{
"--floater-offset-x": offsetX + "rem",
"--floater-offset-y": offsetY + "rem",
"--floater-min-size-x": "379px"}}>
<calcite-action scale="s" slot="heading-end" title="Change Layout" text="Change Layout" icon="layout-horizontal" onclick="console.log('clicked change layout')"></calcite-action>
<calcite-action scale="s" slot="heading-end" title="Dock widget" text="Dock" icon="right-edge" onclick="console.log('clicked dock')"></calcite-action>
<div className="custom-sketch" id="draw-container">
<div>This is some placeholder content</div>
</div>
</custom-floater>