diff --git a/src/assets/styles/_floating-ui.scss b/src/assets/styles/_floating-ui.scss index 4cc40d8437d..dec57b574b1 100644 --- a/src/assets/styles/_floating-ui.scss +++ b/src/assets/styles/_floating-ui.scss @@ -80,7 +80,6 @@ $floating-ui-default-z-index: 900; display: block; position: absolute; z-index: $zIndex; - transform: scale(0); } @mixin floatingUIWrapper { diff --git a/src/components/combobox/combobox.tsx b/src/components/combobox/combobox.tsx index c8856b2818d..8aadcf0c87e 100644 --- a/src/components/combobox/combobox.tsx +++ b/src/components/combobox/combobox.tsx @@ -16,7 +16,6 @@ import { filter } from "../../utils/filter"; import { debounce } from "lodash-es"; import { - positionFloatingUI, FloatingCSS, OverlayPositioning, FloatingUIComponent, @@ -26,7 +25,8 @@ import { EffectivePlacement, defaultMenuPlacement, filterComputedPlacements, - repositionDebounceTimeout + reposition, + updateAfterClose } from "../../utils/floating-ui"; import { guid } from "../../utils/guid"; import { DeprecatedEventPayload, Scale } from "../interfaces"; @@ -115,11 +115,16 @@ export class Combobox @Watch("open") openHandler(value: boolean): void { + if (!value) { + updateAfterClose(this.floatingEl); + } + if (this.disabled) { this.active = false; this.open = false; return; } + this.active = value; this.setMaxScrollerHeight(); } @@ -170,7 +175,7 @@ export class Combobox @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -224,7 +229,7 @@ export class Combobox @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } //-------------------------------------------------------------------------- @@ -260,19 +265,27 @@ export class Combobox // //-------------------------------------------------------------------------- - /** Updates the position of the component. */ + /** + * Updates the position of the component. + * + * @param delayed + */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return positionFloatingUI({ - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }); + return reposition( + this, + { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }, + delayed + ); } /** Sets focus on the component. */ @@ -343,7 +356,7 @@ export class Combobox connectForm(this); connectOpenCloseComponent(this); this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); if (this.active) { this.activeHandler(this.active); } @@ -358,12 +371,12 @@ export class Combobox componentDidLoad(): void { afterConnectDefaultValueSet(this, this.getValue()); - this.debouncedReposition(); + this.reposition(true); } componentDidRender(): void { if (this.el.offsetHeight !== this.inputHeight) { - this.debouncedReposition(); + this.reposition(true); this.inputHeight = this.el.offsetHeight; } @@ -458,8 +471,6 @@ export class Combobox // // -------------------------------------------------------------------------- - private debouncedReposition = debounce(() => this.reposition(), repositionDebounceTimeout); - setFilteredPlacements = (): void => { const { el, flipPlacements } = this; @@ -622,11 +633,11 @@ export class Combobox return; } - await this.debouncedReposition(); + await this.reposition(true); const maxScrollerHeight = this.getMaxScrollerHeight(); listContainerEl.style.maxHeight = maxScrollerHeight > 0 ? `${maxScrollerHeight}px` : ""; listContainerEl.style.minWidth = `${referenceEl.clientWidth}px`; - await this.debouncedReposition(); + await this.reposition(true); }; calciteChipDismissHandler = ( diff --git a/src/components/dropdown/dropdown.tsx b/src/components/dropdown/dropdown.tsx index 5511a1b78d9..da71eb07a99 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/dropdown/dropdown.tsx @@ -15,7 +15,6 @@ import { ItemKeyboardEvent, Selection } from "./interfaces"; import { focusElement, isPrimaryPointerButton, toAriaBoolean } from "../../utils/dom"; import { - positionFloatingUI, FloatingCSS, OverlayPositioning, FloatingUIComponent, @@ -25,7 +24,8 @@ import { MenuPlacement, defaultMenuPlacement, filterComputedPlacements, - repositionDebounceTimeout + reposition, + updateAfterClose } from "../../utils/floating-ui"; import { Scale } from "../interfaces"; import { SLOTS } from "./resources"; @@ -39,7 +39,6 @@ import { import { guid } from "../../utils/guid"; import { RequestedItem } from "../dropdown-group/interfaces"; import { isActivationKey } from "../../utils/key"; -import { debounce } from "lodash-es"; /** * @slot - A slot for adding `calcite-dropdown-group` components. Every `calcite-dropdown-item` must have a parent `calcite-dropdown-group`, even if the `groupTitle` property is not set. @@ -83,11 +82,19 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float @Watch("open") openHandler(value: boolean): void { if (!this.disabled) { - this.debouncedReposition(); + if (value) { + this.reposition(true); + } else { + updateAfterClose(this.floatingEl); + } this.active = value; return; } + if (!value) { + updateAfterClose(this.floatingEl); + } + this.open = false; } @@ -115,7 +122,7 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -141,7 +148,7 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -153,7 +160,7 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float @Watch("placement") placementHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** specify the scale of dropdown, defaults to m */ @@ -181,7 +188,7 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float connectedCallback(): void { this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); if (this.open) { this.openHandler(this.open); } @@ -192,7 +199,7 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float } componentDidLoad(): void { - this.debouncedReposition(); + this.reposition(true); } componentDidRender(): void { @@ -254,19 +261,27 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float // //-------------------------------------------------------------------------- - /** Updates the position of the component. */ + /** + * Updates the position of the component. + * + * @param delayed + */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return positionFloatingUI({ - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }); + return reposition( + this, + { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }, + delayed + ); } //-------------------------------------------------------------------------- @@ -420,8 +435,6 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float // //-------------------------------------------------------------------------- - private debouncedReposition = debounce(() => this.reposition(), repositionDebounceTimeout); - slotChangeHandler = (event: Event): void => { this.defaultAssignedElements = (event.target as HTMLSlotElement).assignedElements({ flatten: true @@ -443,7 +456,7 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float flatten: true }) as HTMLElement[]; - this.debouncedReposition(); + this.reposition(true); }; updateItems = (): void => { @@ -453,7 +466,7 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float this.updateSelectedItems(); - this.debouncedReposition(); + this.reposition(true); }; updateGroups = (event: Event): void => { @@ -494,10 +507,10 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float return; } - this.debouncedReposition(); + this.reposition(true); const maxScrollerHeight = this.getMaxScrollerHeight(); scrollerEl.style.maxHeight = maxScrollerHeight > 0 ? `${maxScrollerHeight}px` : ""; - this.debouncedReposition(); + this.reposition(true); }; setScrollerAndTransitionEl = (el: HTMLDivElement): void => { diff --git a/src/components/input-date-picker/input-date-picker.tsx b/src/components/input-date-picker/input-date-picker.tsx index 95a8330f20a..1717d4e8290 100644 --- a/src/components/input-date-picker/input-date-picker.tsx +++ b/src/components/input-date-picker/input-date-picker.tsx @@ -36,7 +36,6 @@ import { submitForm } from "../../utils/form"; import { - positionFloatingUI, FloatingCSS, OverlayPositioning, FloatingUIComponent, @@ -46,7 +45,8 @@ import { MenuPlacement, defaultMenuPlacement, filterComputedPlacements, - repositionDebounceTimeout + reposition, + updateAfterClose } from "../../utils/floating-ui"; import { DateRangeChange } from "../date-picker/interfaces"; import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @@ -63,7 +63,6 @@ import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; -import { debounce } from "lodash-es"; import { numberKeys } from "../../utils/key"; @Component({ @@ -140,7 +139,7 @@ export class InputDatePicker @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -211,11 +210,18 @@ export class InputDatePicker this.active = value; if (this.disabled || this.readOnly) { + if (!value) { + updateAfterClose(this.floatingEl); + } this.open = false; return; } - this.debouncedReposition(); + if (value) { + this.reposition(true); + } else { + updateAfterClose(this.floatingEl); + } } /** @@ -304,7 +310,7 @@ export class InputDatePicker @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -386,19 +392,27 @@ export class InputDatePicker this.startInput?.setFocus(); } - /** Updates the position of the component. */ + /** + * Updates the position of the component. + * + * @param delayed + */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return positionFloatingUI({ - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }); + return reposition( + this, + { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }, + delayed + ); } // -------------------------------------------------------------------------- @@ -444,7 +458,7 @@ export class InputDatePicker connectOpenCloseComponent(this); this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); numberStringFormatter.numberFormatOptions = { numberingSystem: this.numberingSystem, @@ -460,7 +474,7 @@ export class InputDatePicker } componentDidLoad(): void { - this.debouncedReposition(); + this.reposition(true); } disconnectedCallback(): void { @@ -662,8 +676,6 @@ export class InputDatePicker // //-------------------------------------------------------------------------- - private debouncedReposition = debounce(() => this.reposition(), repositionDebounceTimeout); - setFilteredPlacements = (): void => { const { el, flipPlacements } = this; diff --git a/src/components/input-time-picker/input-time-picker.tsx b/src/components/input-time-picker/input-time-picker.tsx index deaaae61117..1a66fd70442 100644 --- a/src/components/input-time-picker/input-time-picker.tsx +++ b/src/components/input-time-picker/input-time-picker.tsx @@ -86,7 +86,9 @@ export class InputTimePicker return; } - this.reposition(); + if (value) { + this.reposition(true); + } } /** When `true`, interaction is prevented and the component is displayed with lower opacity. */ @@ -370,10 +372,14 @@ export class InputTimePicker this.calciteInputEl?.setFocus(); } - /** Updates the position of the component. */ + /** + * Updates the position of the component. + * + * @param delayed + */ @Method() - async reposition(): Promise { - this.popoverEl?.reposition(); + async reposition(delayed = false): Promise { + this.popoverEl?.reposition(delayed); } // -------------------------------------------------------------------------- diff --git a/src/components/popover/popover.e2e.ts b/src/components/popover/popover.e2e.ts index 93d5c958cfa..dbbd12bac19 100644 --- a/src/components/popover/popover.e2e.ts +++ b/src/components/popover/popover.e2e.ts @@ -94,26 +94,25 @@ describe("calcite-popover", () => { it("popover positions when referenceElement is set", async () => { const page = await newE2EPage(); - - await page.setContent(`
referenceElement
`); - + await page.setContent( + html` +
referenceElement
` + ); const element = await page.find("calcite-popover"); let computedStyle: CSSStyleDeclaration = await element.getComputedStyle(); - expect(computedStyle.transform).toBe("matrix(0, 0, 0, 0, 0, 0)"); + expect(computedStyle.transform).toBe("none"); - await page.$eval("calcite-popover", (elm: any) => { - const referenceElement = document.createElement("div"); - document.body.appendChild(referenceElement); - elm.referenceElement = referenceElement; + await page.$eval("calcite-popover", (el: HTMLCalcitePopoverElement): void => { + const referenceElement = document.getElementById("ref"); + el.referenceElement = referenceElement; }); - await page.waitForChanges(); computedStyle = await element.getComputedStyle(); - expect(computedStyle.transform).not.toBe("matrix(0, 0, 0, 0, 0, 0)"); + expect(computedStyle.transform).not.toBe("none"); }); it("open popover should be visible", async () => { @@ -322,13 +321,9 @@ describe("calcite-popover", () => { it("should emit open and beforeOpen events", async () => { const page = await newE2EPage(); - await page.setContent( `content
referenceElement
` ); - - await page.waitForChanges(); - const popover = await page.find("calcite-popover"); const openEvent = await popover.spyOnEvent("calcitePopoverOpen"); @@ -340,10 +335,8 @@ describe("calcite-popover", () => { const popoverOpenEvent = page.waitForEvent("calcitePopoverOpen"); const popoverBeforeOpenEvent = page.waitForEvent("calcitePopoverBeforeOpen"); - await page.evaluate(() => { - const popover = document.querySelector("calcite-popover"); - popover.open = true; - }); + await popover.setProperty("open", true); + await page.waitForChanges(); await popoverOpenEvent; await popoverBeforeOpenEvent; @@ -641,7 +634,7 @@ describe("calcite-popover", () => { it("owns a floating-ui", () => floatingUIOwner( - `content
referenceElement
`, + `content
referenceElement
`, "open" )); diff --git a/src/components/popover/popover.stories.ts b/src/components/popover/popover.stories.ts index 083310498d2..0c9db44af82 100644 --- a/src/components/popover/popover.stories.ts +++ b/src/components/popover/popover.stories.ts @@ -1,7 +1,7 @@ import { select, number, text } from "@storybook/addon-knobs"; import { html } from "../../../support/formatting"; -import { boolean, storyFilters } from "../../../.storybook/helpers"; -import { placements } from "../../utils/floating-ui"; +import { boolean, createSteps, stepStory, storyFilters } from "../../../.storybook/helpers"; +import { placements, repositionDebounceTimeout } from "../../utils/floating-ui"; import readme from "./readme.md"; import { defaultPopoverPlacement } from "../popover/resources"; import { themesDarkDefault } from "../../../.storybook/utils"; diff --git a/src/components/popover/popover.tsx b/src/components/popover/popover.tsx index 4feafcb309e..ee62d5e7a39 100644 --- a/src/components/popover/popover.tsx +++ b/src/components/popover/popover.tsx @@ -21,7 +21,6 @@ import { defaultPopoverPlacement } from "./resources"; import { - positionFloatingUI, FloatingCSS, OverlayPositioning, FloatingUIComponent, @@ -32,7 +31,8 @@ import { defaultOffsetDistance, filterComputedPlacements, ReferenceElement, - repositionDebounceTimeout + reposition, + updateAfterClose } from "../../utils/floating-ui"; import { guid } from "../../utils/guid"; @@ -45,7 +45,6 @@ import { import { HeadingLevel, Heading } from "../functional/Heading"; import PopoverManager from "./PopoverManager"; -import { debounce } from "lodash-es"; const manager = new PopoverManager(); @@ -114,7 +113,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -139,7 +138,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { @Watch("offsetDistance") offsetDistanceOffsetHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -149,7 +148,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { @Watch("offsetSkidding") offsetSkiddingHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -158,8 +157,13 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { @Prop({ reflect: true, mutable: true }) open = false; @Watch("open") - openHandler(): void { - this.debouncedReposition(); + openHandler(value: boolean): void { + if (value) { + this.reposition(true); + } else { + updateAfterClose(this.el); + } + this.setExpandedAttr(); } @@ -175,7 +179,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -187,7 +191,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { @Watch("placement") placementHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -198,7 +202,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { @Watch("referenceElement") referenceElementHandler(): void { this.setUpReferenceElement(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -262,7 +266,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { if (this.referenceElement && !this.effectiveReferenceElement) { this.setUpReferenceElement(); } - this.debouncedReposition(); + this.reposition(); this.hasLoaded = true; } @@ -296,9 +300,13 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { // // -------------------------------------------------------------------------- - /** Updates the position of the component. */ + /** + * Updates the position of the component. + * + * @param delayed + */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { el, effectiveReferenceElement, @@ -310,19 +318,23 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { offsetSkidding, arrowEl } = this; - - return positionFloatingUI({ - floatingEl: el, - referenceEl: effectiveReferenceElement, - overlayPositioning, - placement, - disableFlip, - flipPlacements: filteredFlipPlacements, - offsetDistance, - offsetSkidding, - arrowEl, - type: "popover" - }); + return reposition( + this, + { + floatingEl: el, + referenceEl: effectiveReferenceElement, + overlayPositioning, + placement, + disableFlip, + flipPlacements: filteredFlipPlacements, + offsetDistance, + offsetSkidding, + includeArrow: !this.disablePointer, + arrowEl, + type: "popover" + }, + delayed + ); } /** @@ -360,8 +372,6 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { // // -------------------------------------------------------------------------- - private debouncedReposition = debounce(() => this.reposition(), repositionDebounceTimeout); - private setTransitionEl = (el): void => { this.transitionEl = el; connectOpenCloseComponent(this); @@ -470,7 +480,7 @@ export class Popover implements FloatingUIComponent, OpenCloseComponent { storeArrowEl = (el: HTMLDivElement): void => { this.arrowEl = el; - this.debouncedReposition(); + this.reposition(true); }; // -------------------------------------------------------------------------- diff --git a/src/components/tooltip/tooltip.e2e.ts b/src/components/tooltip/tooltip.e2e.ts index 318a7f66307..41d2ef46004 100644 --- a/src/components/tooltip/tooltip.e2e.ts +++ b/src/components/tooltip/tooltip.e2e.ts @@ -67,26 +67,25 @@ describe("calcite-tooltip", () => { it("tooltip positions when referenceElement is set", async () => { const page = await newE2EPage(); - - await page.setContent(`
referenceElement
`); - + await page.setContent( + html` +
referenceElement
` + ); const element = await page.find("calcite-tooltip"); let computedStyle: CSSStyleDeclaration = await element.getComputedStyle(); - expect(computedStyle.transform).toBe("matrix(0, 0, 0, 0, 0, 0)"); + expect(computedStyle.transform).toBe("none"); - await page.$eval("calcite-tooltip", (elm: any) => { - const referenceElement = document.createElement("div"); - document.body.appendChild(referenceElement); - elm.referenceElement = referenceElement; + await page.$eval("calcite-tooltip", (el: HTMLCalciteTooltipElement): void => { + const referenceElement = document.getElementById("ref"); + el.referenceElement = referenceElement; }); - await page.waitForChanges(); computedStyle = await element.getComputedStyle(); - expect(computedStyle.transform).not.toBe("matrix(0, 0, 0, 0, 0, 0)"); + expect(computedStyle.transform).not.toBe("none"); }); it("open tooltip should be visible", async () => { diff --git a/src/components/tooltip/tooltip.tsx b/src/components/tooltip/tooltip.tsx index ed709468b3c..0c3ad53cc1e 100644 --- a/src/components/tooltip/tooltip.tsx +++ b/src/components/tooltip/tooltip.tsx @@ -2,8 +2,6 @@ import { Component, Element, Host, Method, Prop, State, Watch, h, VNode } from " import { CSS, ARIA_DESCRIBED_BY } from "./resources"; import { guid } from "../../utils/guid"; import { - positionFloatingUI, - FloatingCSS, OverlayPositioning, FloatingUIComponent, connectFloatingUI, @@ -11,12 +9,13 @@ import { LogicalPlacement, defaultOffsetDistance, ReferenceElement, - repositionDebounceTimeout + reposition, + FloatingCSS, + updateAfterClose } from "../../utils/floating-ui"; import { queryElementRoots, toAriaBoolean } from "../../utils/dom"; import TooltipManager from "./TooltipManager"; -import { debounce } from "lodash-es"; const manager = new TooltipManager(); @@ -50,7 +49,7 @@ export class Tooltip implements FloatingUIComponent { @Watch("offsetDistance") offsetDistanceOffsetHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -60,7 +59,7 @@ export class Tooltip implements FloatingUIComponent { @Watch("offsetSkidding") offsetSkiddingHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -69,8 +68,12 @@ export class Tooltip implements FloatingUIComponent { @Prop({ reflect: true }) open = false; @Watch("open") - openHandler(): void { - this.debouncedReposition(); + openHandler(value: boolean): void { + if (value) { + this.reposition(true); + } else { + updateAfterClose(this.el); + } } /** @@ -85,7 +88,7 @@ export class Tooltip implements FloatingUIComponent { @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -97,7 +100,7 @@ export class Tooltip implements FloatingUIComponent { @Watch("placement") placementHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -144,7 +147,7 @@ export class Tooltip implements FloatingUIComponent { if (this.referenceElement && !this.effectiveReferenceElement) { this.setUpReferenceElement(); } - this.debouncedReposition(); + this.reposition(true); this.hasLoaded = true; } @@ -159,9 +162,13 @@ export class Tooltip implements FloatingUIComponent { // // -------------------------------------------------------------------------- - /** Updates the position of the component. */ + /** + * Updates the position of the component. + * + * @param delayed + */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { el, effectiveReferenceElement, @@ -172,16 +179,21 @@ export class Tooltip implements FloatingUIComponent { arrowEl } = this; - return positionFloatingUI({ - floatingEl: el, - referenceEl: effectiveReferenceElement, - overlayPositioning, - placement, - offsetDistance, - offsetSkidding, - arrowEl, - type: "tooltip" - }); + return reposition( + this, + { + floatingEl: el, + referenceEl: effectiveReferenceElement, + overlayPositioning, + placement, + offsetDistance, + offsetSkidding, + includeArrow: true, + arrowEl, + type: "tooltip" + }, + delayed + ); } // -------------------------------------------------------------------------- @@ -190,8 +202,6 @@ export class Tooltip implements FloatingUIComponent { // // -------------------------------------------------------------------------- - private debouncedReposition = debounce(() => this.reposition(), repositionDebounceTimeout); - setUpReferenceElement = (warn = true): void => { this.removeReferences(); this.effectiveReferenceElement = this.getReferenceElement(); diff --git a/src/utils/floating-ui.spec.ts b/src/utils/floating-ui.spec.ts index 0df17cc8801..09d2162fd8e 100644 --- a/src/utils/floating-ui.spec.ts +++ b/src/utils/floating-ui.spec.ts @@ -1,10 +1,18 @@ import { - getEffectivePlacement, + cleanupMap, + connectFloatingUI, defaultOffsetDistance, + disconnectFloatingUI, + effectivePlacements, filterComputedPlacements, + FloatingUIComponent, + getEffectivePlacement, placements, - effectivePlacements + positionFloatingUI, + reposition, + repositionDebounceTimeout } from "./floating-ui"; +import { waitForAnimationFrame } from "../tests/utils"; it("should set calcite placement to FloatingUI placement", () => { const el = document.createElement("div"); @@ -26,6 +34,95 @@ it("should set calcite placement to FloatingUI placement", () => { expect(getEffectivePlacement(el, "trailing-end")).toBe("left-end"); }); +describe("repositioning", () => { + let fakeFloatingUiComponent: FloatingUIComponent; + let floatingEl: HTMLDivElement; + let referenceEl: HTMLButtonElement; + let positionOptions: Parameters[0]; + + beforeEach(() => { + fakeFloatingUiComponent = { + open: false, + reposition: async () => { + /* noop */ + }, + overlayPositioning: "absolute", + placement: "auto" + }; + + floatingEl = document.createElement("div"); + referenceEl = document.createElement("button"); + + positionOptions = { + floatingEl, + referenceEl, + overlayPositioning: fakeFloatingUiComponent.overlayPositioning, + placement: fakeFloatingUiComponent.placement, + type: "popover" + }; + }); + + it("repositions only for open components", async () => { + await reposition(fakeFloatingUiComponent, positionOptions); + expect(floatingEl.style.transform).toBe(""); + + fakeFloatingUiComponent.open = true; + + await reposition(fakeFloatingUiComponent, positionOptions); + expect(floatingEl.style.transform).not.toBe(""); + }); + + it("repositions immediately by default", async () => { + fakeFloatingUiComponent.open = true; + + reposition(fakeFloatingUiComponent, positionOptions); + + expect(floatingEl.style.transform).toBe(""); + + await waitForAnimationFrame(); + expect(floatingEl.style.transform).not.toBe(""); + }); + + it("can reposition after a delay", async () => { + fakeFloatingUiComponent.open = true; + + reposition(fakeFloatingUiComponent, positionOptions, true); + + expect(floatingEl.style.transform).toBe(""); + + await new Promise((resolve) => setTimeout(resolve, repositionDebounceTimeout)); + expect(floatingEl.style.transform).not.toBe(""); + }); + + describe("connect/disconnect helpers", () => { + it("has connectedCallback and disconnectedCallback helpers", () => { + expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(false); + expect(floatingEl.style.position).toBe(""); + + connectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl); + + expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(true); + expect(floatingEl.style.position).toBe("absolute"); + + disconnectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl); + + expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(false); + expect(floatingEl.style.position).toBe("absolute"); + + fakeFloatingUiComponent.overlayPositioning = "fixed"; + connectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl); + + expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(true); + expect(floatingEl.style.position).toBe("fixed"); + + disconnectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl); + + expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(false); + expect(floatingEl.style.position).toBe("fixed"); + }); + }); +}); + it("should have correct value for defaultOffsetDistance", () => { expect(defaultOffsetDistance).toBe(6); }); diff --git a/src/utils/floating-ui.ts b/src/utils/floating-ui.ts index 63c49a5965b..4966118c66f 100644 --- a/src/utils/floating-ui.ts +++ b/src/utils/floating-ui.ts @@ -13,7 +13,8 @@ import { Strategy, VirtualElement } from "@floating-ui/dom"; -import { getElementDir } from "./dom"; +import { closestElementCrossShadowBoundary, getElementDir } from "./dom"; +import { debounce } from "lodash-es"; import { Build } from "@stencil/core"; import { config } from "./config"; @@ -52,6 +53,11 @@ async function patchFloatingUiForNonChromiumBrowsers(): Promise { } } +const placementDataAttribute = "data-placement"; + +/** + * Exported for testing purposes only + */ export const repositionDebounceTimeout = 100; export type ReferenceElement = VirtualElement | Element; @@ -200,8 +206,10 @@ export interface FloatingUIComponent { /** * Updates the position of the component. + * + * @param delayed – (internal) when true, it will reposition the component after a delay. the default is false. This is useful for components that have multiple watched properties that schedule repositioning. */ - reposition(): Promise; + reposition(delayed?: boolean): Promise; } export const FloatingCSS = { @@ -306,9 +314,47 @@ export function getEffectivePlacement(floatingEl: HTMLElement, placement: Logica .replace(/trailing/gi, placements[1]) as EffectivePlacement; } +/** + * Convenience function to manage `reposition` calls for FloatingUIComponents that use `positionFloatingUI. + * + * Note: this is not needed for components that use `calcite-popover`. + * + * @param component + * @param options + * @param options.referenceEl + * @param options.floatingEl + * @param options.overlayPositioning + * @param options.placement + * @param options.disableFlip + * @param options.flipPlacements + * @param options.offsetDistance + * @param options.offsetSkidding + * @param options.arrowEl + * @param options.type + * @param delayed + */ +export async function reposition( + component: FloatingUIComponent, + options: Parameters[0], + delayed = false +): Promise { + if (!component.open) { + return; + } + + return delayed ? debouncedReposition(options) : positionFloatingUI(options); +} + +const debouncedReposition = debounce(positionFloatingUI, repositionDebounceTimeout, { + leading: true, + maxWait: repositionDebounceTimeout +}); + /** * Positions the floating element relative to the reference element. * + * **Note:** exported for testing purposes only + * * @param root0 * @param root0.referenceEl * @param root0.floatingEl @@ -320,6 +366,7 @@ export function getEffectivePlacement(floatingEl: HTMLElement, placement: Logica * @param root0.offsetSkidding * @param root0.arrowEl * @param root0.type + * @param root0.includeArrow */ export async function positionFloatingUI({ referenceEl, @@ -330,6 +377,7 @@ export async function positionFloatingUI({ flipPlacements, offsetDistance, offsetSkidding, + includeArrow = false, arrowEl, type }: { @@ -343,9 +391,10 @@ export async function positionFloatingUI({ offsetDistance?: number; offsetSkidding?: number; arrowEl?: HTMLElement; + includeArrow?: boolean; type: UIType; }): Promise { - if (!referenceEl || !floatingEl) { + if (!referenceEl || !floatingEl || (includeArrow && !arrowEl)) { return null; } @@ -387,7 +436,7 @@ export async function positionFloatingUI({ const visibility = referenceHidden ? "hidden" : null; const pointerEvents = visibility ? "none" : null; - floatingEl.setAttribute("data-placement", effectivePlacement); + floatingEl.setAttribute(placementDataAttribute, effectivePlacement); const transform = `translate(${Math.round(x)}px,${Math.round(y)}px)`; @@ -401,7 +450,12 @@ export async function positionFloatingUI({ }); } -const cleanupMap = new WeakMap void>(); +/** + * Exported for testing purposes only + * + * @internal + */ +export const cleanupMap = new WeakMap void>(); /** * Helper to set up floating element interactions on connectedCallback. @@ -421,13 +475,27 @@ export function connectFloatingUI( disconnectFloatingUI(component, referenceEl, floatingEl); + const position = component.overlayPositioning; + + // ensure position matches for initial positioning + floatingEl.style.position = position; + + if (position === "absolute") { + moveOffScreen(floatingEl); + } + + const runAutoUpdate = Build.isBrowser + ? autoUpdate + : (_refEl: HTMLElement, _floatingEl: HTMLElement, updateCallback: Function): (() => void) => { + updateCallback(); + return () => { + /* noop */ + }; + }; + cleanupMap.set( component, - autoUpdate(referenceEl, floatingEl, () => { - if (component.open) { - component.reposition(); - } - }) + runAutoUpdate(referenceEl, floatingEl, () => component.reposition()) ); } @@ -447,6 +515,8 @@ export function disconnectFloatingUI( return; } + getTransitionTarget(floatingEl).removeEventListener("transitionend", handleTransitionElTransitionEnd); + const cleanup = cleanupMap.get(component); if (cleanup) { @@ -464,3 +534,47 @@ const visiblePointerSize = 4; * @default 6 */ export const defaultOffsetDistance = Math.ceil(Math.hypot(visiblePointerSize, visiblePointerSize)); + +/** + * This utils applies floating element styles to avoid affecting layout when closed. + * + * This should be called when the closing transition will start. + * + * @param floatingEl + */ +export function updateAfterClose(floatingEl: HTMLElement): void { + if (!floatingEl || floatingEl.style.position !== "absolute") { + return; + } + + getTransitionTarget(floatingEl).addEventListener("transitionend", handleTransitionElTransitionEnd); +} + +function getTransitionTarget(floatingEl: HTMLElement): ShadowRoot | HTMLElement { + // assumes floatingEl w/ shadowRoot is a FloatingUIComponent + return floatingEl.shadowRoot || floatingEl; +} + +function handleTransitionElTransitionEnd(event: TransitionEvent): void { + const floatingTransitionEl = event.target as HTMLElement; + + if ( + // using any prop from floating-ui transition + event.propertyName === "opacity" && + floatingTransitionEl.classList.contains(FloatingCSS.animation) + ) { + const floatingEl = getFloatingElFromTransitionTarget(floatingTransitionEl); + moveOffScreen(floatingEl); + getTransitionTarget(floatingEl).removeEventListener("transitionend", handleTransitionElTransitionEnd); + } +} + +function moveOffScreen(floatingEl: HTMLElement): void { + floatingEl.style.transform = ""; + floatingEl.style.top = "-99999px"; + floatingEl.style.left = "-99999px"; +} + +function getFloatingElFromTransitionTarget(floatingTransitionEl: HTMLElement): HTMLElement { + return closestElementCrossShadowBoundary(floatingTransitionEl, `[${placementDataAttribute}]`); +} diff --git a/stencil.config.ts b/stencil.config.ts index d391598cd30..e46b02a3b25 100644 --- a/stencil.config.ts +++ b/stencil.config.ts @@ -113,7 +113,8 @@ export const create: () => Config = () => ({ ], testing: { moduleNameMapper: { - "^/assets/(.*)$": "/src/tests/iconPathDataStub.ts" + "^/assets/(.*)$": "/src/tests/iconPathDataStub.ts", + "^lodash-es$": "lodash" }, setupFilesAfterEnv: ["/src/tests/setupTests.ts"] },