From e179011c693adad3970578d2b94b3d57defebf9e Mon Sep 17 00:00:00 2001 From: Matt Driscoll Date: Wed, 14 Jun 2023 16:16:59 -0700 Subject: [PATCH 1/2] fix(combobox, dropdown, input-date-picker, input-time-picker, popover, tooltip): Prevent repositioning from affecting other floating components. #7158 --- .../src/components/combobox/combobox.tsx | 44 ++++++------- .../src/components/dropdown/dropdown.tsx | 50 +++++++-------- .../input-date-picker/input-date-picker.tsx | 40 ++++++------ .../input-time-picker/input-time-picker.tsx | 18 ++++-- .../src/components/popover/popover.tsx | 54 ++++++++-------- .../src/components/tooltip/tooltip.tsx | 46 ++++++------- .../src/utils/floating-ui.spec.ts | 23 +++++-- .../src/utils/floating-ui.ts | 64 +++++++------------ 8 files changed, 170 insertions(+), 169 deletions(-) diff --git a/packages/calcite-components/src/components/combobox/combobox.tsx b/packages/calcite-components/src/components/combobox/combobox.tsx index e16bd81874b..78361839835 100644 --- a/packages/calcite-components/src/components/combobox/combobox.tsx +++ b/packages/calcite-components/src/components/combobox/combobox.tsx @@ -18,6 +18,7 @@ import { filter } from "../../utils/filter"; import { isPrimaryPointerButton, toAriaBoolean } from "../../utils/dom"; import { connectFloatingUI, + debounceReposition, defaultMenuPlacement, disconnectFloatingUI, EffectivePlacement, @@ -26,7 +27,7 @@ import { FloatingUIComponent, LogicalPlacement, OverlayPositioning, - reposition + positionFloatingUI } from "../../utils/floating-ui"; import { afterConnectDefaultValueSet, @@ -190,7 +191,7 @@ export class Combobox @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -259,7 +260,7 @@ export class Combobox @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.reposition(true); + this.debouncedReposition(); } /** @@ -319,24 +320,21 @@ export class Combobox /** * Updates the position of the component. * - * @param delayed + * @param {boolean} delayed [Deprecated] - No longer necessary. + * @returns {Promise} */ @Method() - async reposition(delayed = false): Promise { + async reposition(): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return reposition( - this, - { - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }, - delayed - ); + return positionFloatingUI(this, { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }); } /** Sets focus on the component. */ @@ -397,7 +395,7 @@ export class Combobox connectForm(this); connectOpenCloseComponent(this); this.setFilteredPlacements(); - this.reposition(true); + this.debouncedReposition(); if (this.open) { this.openHandler(); } @@ -411,13 +409,13 @@ export class Combobox componentDidLoad(): void { afterConnectDefaultValueSet(this, this.getValue()); - this.reposition(true); + this.debouncedReposition(); setComponentLoaded(this); } componentDidRender(): void { if (this.el.offsetHeight !== this.inputHeight) { - this.reposition(true); + this.debouncedReposition(); this.inputHeight = this.el.offsetHeight; } @@ -441,6 +439,8 @@ export class Combobox // //-------------------------------------------------------------------------- + debouncedReposition = debounceReposition(this); + placement: LogicalPlacement = defaultMenuPlacement; filteredFlipPlacements: EffectivePlacement[]; @@ -698,11 +698,11 @@ export class Combobox return; } - await this.reposition(true); + await this.debouncedReposition(); const maxScrollerHeight = this.getMaxScrollerHeight(); listContainerEl.style.maxHeight = maxScrollerHeight > 0 ? `${maxScrollerHeight}px` : ""; listContainerEl.style.minWidth = `${referenceEl.clientWidth}px`; - await this.reposition(true); + await this.debouncedReposition(); }; calciteChipCloseHandler = (comboboxItem: HTMLCalciteComboboxItemElement): void => { diff --git a/packages/calcite-components/src/components/dropdown/dropdown.tsx b/packages/calcite-components/src/components/dropdown/dropdown.tsx index 2be288fe1ec..5756d3541e1 100644 --- a/packages/calcite-components/src/components/dropdown/dropdown.tsx +++ b/packages/calcite-components/src/components/dropdown/dropdown.tsx @@ -21,6 +21,7 @@ import { } from "../../utils/dom"; import { connectFloatingUI, + debounceReposition, defaultMenuPlacement, disconnectFloatingUI, EffectivePlacement, @@ -29,7 +30,7 @@ import { FloatingUIComponent, MenuPlacement, OverlayPositioning, - reposition + positionFloatingUI } from "../../utils/floating-ui"; import { guid } from "../../utils/guid"; import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @@ -87,7 +88,7 @@ export class Dropdown openHandler(value: boolean): void { if (!this.disabled) { if (value) { - this.reposition(true); + this.debouncedReposition(); } return; } @@ -123,7 +124,7 @@ export class Dropdown @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.reposition(true); + this.debouncedReposition(); } /** @@ -149,7 +150,7 @@ export class Dropdown @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -161,7 +162,7 @@ export class Dropdown @Watch("placement") placementHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -208,7 +209,7 @@ export class Dropdown connectedCallback(): void { this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); this.setFilteredPlacements(); - this.reposition(true); + this.debouncedReposition(); if (this.open) { this.openHandler(this.open); } @@ -221,7 +222,7 @@ export class Dropdown componentDidLoad(): void { setComponentLoaded(this); - this.reposition(true); + this.debouncedReposition(); } componentDidRender(): void { @@ -289,24 +290,21 @@ export class Dropdown /** * Updates the position of the component. * - * @param delayed + * @param {boolean} delayed [Deprecated] - No longer necessary. + * @returns {Promise} */ @Method() - async reposition(delayed = false): Promise { + async reposition(): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return reposition( - this, - { - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }, - delayed - ); + return positionFloatingUI(this, { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }); } //-------------------------------------------------------------------------- @@ -427,6 +425,8 @@ export class Dropdown // //-------------------------------------------------------------------------- + debouncedReposition = debounceReposition(this); + filteredFlipPlacements: EffectivePlacement[]; private items: HTMLCalciteDropdownItemElement[] = []; @@ -481,7 +481,7 @@ export class Dropdown flatten: true }) as HTMLElement[]; - this.reposition(true); + this.debouncedReposition(); }; updateItems = (): void => { @@ -491,7 +491,7 @@ export class Dropdown this.updateSelectedItems(); - this.reposition(true); + this.debouncedReposition(); }; updateGroups = (event: Event): void => { @@ -532,10 +532,10 @@ export class Dropdown return; } - this.reposition(true); + this.debouncedReposition(); const maxScrollerHeight = this.getMaxScrollerHeight(); scrollerEl.style.maxHeight = maxScrollerHeight > 0 ? `${maxScrollerHeight}px` : ""; - this.reposition(true); + this.debouncedReposition(); }; setScrollerAndTransitionEl = (el: HTMLDivElement): void => { diff --git a/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx b/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx index 1aa60bb23fd..9c3b7675251 100644 --- a/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx +++ b/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx @@ -24,6 +24,7 @@ import { import { toAriaBoolean } from "../../utils/dom"; import { connectFloatingUI, + debounceReposition, defaultMenuPlacement, disconnectFloatingUI, EffectivePlacement, @@ -32,7 +33,7 @@ import { FloatingUIComponent, MenuPlacement, OverlayPositioning, - reposition + positionFloatingUI } from "../../utils/floating-ui"; import { connectForm, @@ -198,7 +199,7 @@ export class InputDatePicker @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.reposition(true); + this.debouncedReposition(); } /** @@ -265,7 +266,7 @@ export class InputDatePicker } if (value) { - this.reposition(true); + this.debouncedReposition(); } } @@ -314,7 +315,7 @@ export class InputDatePicker @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -405,24 +406,21 @@ export class InputDatePicker /** * Updates the position of the component. * - * @param delayed + * @param {boolean} delayed [Deprecated] - No longer necessary. + * @returns {Promise} */ @Method() - async reposition(delayed = false): Promise { + async reposition(): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return reposition( - this, - { - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }, - delayed - ); + return positionFloatingUI(this, { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }); } // -------------------------------------------------------------------------- @@ -463,7 +461,7 @@ export class InputDatePicker connectMessages(this); this.setFilteredPlacements(); - this.reposition(true); + this.debouncedReposition(); numberStringFormatter.numberFormatOptions = { numberingSystem: this.numberingSystem, @@ -482,7 +480,7 @@ export class InputDatePicker componentDidLoad(): void { setComponentLoaded(this); this.localizeInputValues(); - this.reposition(true); + this.debouncedReposition(); } disconnectedCallback(): void { @@ -660,6 +658,8 @@ export class InputDatePicker // //-------------------------------------------------------------------------- + debouncedReposition = debounceReposition(this); + private datePickerEl: HTMLCalciteDatePickerElement; private dialogId = `date-picker-dialog--${guid()}`; diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx index a70d7fa9964..5f008976b45 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx @@ -11,7 +11,12 @@ import { VNode, Watch } from "@stencil/core"; -import { FloatingUIComponent, LogicalPlacement, OverlayPositioning } from "../../utils/floating-ui"; +import { + debounceReposition, + FloatingUIComponent, + LogicalPlacement, + OverlayPositioning +} from "../../utils/floating-ui"; import { connectForm, disconnectForm, @@ -169,7 +174,7 @@ export class InputTimePicker } if (value) { - this.reposition(true); + this.debouncedReposition(); } } @@ -306,6 +311,8 @@ export class InputTimePicker // //-------------------------------------------------------------------------- + debouncedReposition = debounceReposition(this); + defaultValue: InputTimePicker["value"]; formEl: HTMLFormElement; @@ -460,11 +467,12 @@ export class InputTimePicker /** * Updates the position of the component. * - * @param delayed + * @param {boolean} delayed [Deprecated] - No longer necessary. + * @returns {Promise} */ @Method() - async reposition(delayed = false): Promise { - this.popoverEl?.reposition(delayed); + async reposition(): Promise { + this.popoverEl?.reposition(); } // -------------------------------------------------------------------------- diff --git a/packages/calcite-components/src/components/popover/popover.tsx b/packages/calcite-components/src/components/popover/popover.tsx index 4092929ca83..85b2ba3b2a3 100644 --- a/packages/calcite-components/src/components/popover/popover.tsx +++ b/packages/calcite-components/src/components/popover/popover.tsx @@ -24,7 +24,8 @@ import { LogicalPlacement, OverlayPositioning, ReferenceElement, - reposition + positionFloatingUI, + debounceReposition } from "../../utils/floating-ui"; import { activateFocusTrap, @@ -132,7 +133,7 @@ export class Popover @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.reposition(true); + this.debouncedReposition(); } /** @@ -176,7 +177,7 @@ export class Popover @Watch("offsetDistance") offsetDistanceOffsetHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -186,7 +187,7 @@ export class Popover @Watch("offsetSkidding") offsetSkiddingHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -197,7 +198,7 @@ export class Popover @Watch("open") openHandler(value: boolean): void { if (value) { - this.reposition(true); + this.debouncedReposition(); } this.setExpandedAttr(); @@ -215,7 +216,7 @@ export class Popover @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -225,7 +226,7 @@ export class Popover @Watch("placement") placementHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -236,7 +237,7 @@ export class Popover @Watch("referenceElement") referenceElementHandler(): void { this.setUpReferenceElement(); - this.reposition(true); + this.debouncedReposition(); } /** Specifies the size of the component. */ @@ -255,6 +256,8 @@ export class Popover // // -------------------------------------------------------------------------- + debouncedReposition = debounceReposition(this); + mutationObserver: MutationObserver = createObserver("mutation", () => this.updateFocusTrapElements() ); @@ -355,10 +358,11 @@ export class Popover /** * Updates the position of the component. * - * @param delayed + * @param {boolean} delayed [Deprecated] - No longer necessary. + * @returns {Promise} */ @Method() - async reposition(delayed = false): Promise { + async reposition(): Promise { const { el, effectiveReferenceElement, @@ -370,22 +374,18 @@ export class Popover offsetSkidding, arrowEl } = this; - return reposition( - this, - { - floatingEl: el, - referenceEl: effectiveReferenceElement, - overlayPositioning, - placement, - flipDisabled, - flipPlacements: filteredFlipPlacements, - offsetDistance, - offsetSkidding, - arrowEl, - type: "popover" - }, - delayed - ); + return positionFloatingUI(this, { + floatingEl: el, + referenceEl: effectiveReferenceElement, + overlayPositioning, + placement, + flipDisabled, + flipPlacements: filteredFlipPlacements, + offsetDistance, + offsetSkidding, + arrowEl, + type: "popover" + }); } /** @@ -522,7 +522,7 @@ export class Popover storeArrowEl = (el: SVGElement): void => { this.arrowEl = el; - this.reposition(true); + this.debouncedReposition(); }; // -------------------------------------------------------------------------- diff --git a/packages/calcite-components/src/components/tooltip/tooltip.tsx b/packages/calcite-components/src/components/tooltip/tooltip.tsx index 9687b241b55..8d4c75f4383 100644 --- a/packages/calcite-components/src/components/tooltip/tooltip.tsx +++ b/packages/calcite-components/src/components/tooltip/tooltip.tsx @@ -22,7 +22,8 @@ import { LogicalPlacement, OverlayPositioning, ReferenceElement, - reposition + positionFloatingUI, + debounceReposition } from "../../utils/floating-ui"; import { guid } from "../../utils/guid"; import { @@ -68,7 +69,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("offsetDistance") offsetDistanceOffsetHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -78,7 +79,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("offsetSkidding") offsetSkiddingHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -89,7 +90,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("open") openHandler(value: boolean): void { if (value) { - this.reposition(true); + this.debouncedReposition(); } } @@ -105,7 +106,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -115,7 +116,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("placement") placementHandler(): void { - this.reposition(true); + this.debouncedReposition(); } /** @@ -138,6 +139,8 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { // // -------------------------------------------------------------------------- + debouncedReposition = debounceReposition(this); + @Element() el: HTMLCalciteTooltipElement; @State() effectiveReferenceElement: ReferenceElement; @@ -169,7 +172,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { if (this.referenceElement && !this.effectiveReferenceElement) { this.setUpReferenceElement(); } - this.reposition(true); + this.debouncedReposition(); this.hasLoaded = true; } @@ -206,10 +209,11 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { /** * Updates the position of the component. * - * @param delayed + * @param {boolean} delayed [Deprecated] - No longer necessary. + * @returns {Promise} */ @Method() - async reposition(delayed = false): Promise { + async reposition(): Promise { const { el, effectiveReferenceElement, @@ -220,20 +224,16 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { arrowEl } = this; - return reposition( - this, - { - floatingEl: el, - referenceEl: effectiveReferenceElement, - overlayPositioning, - placement, - offsetDistance, - offsetSkidding, - arrowEl, - type: "tooltip" - }, - delayed - ); + return positionFloatingUI(this, { + floatingEl: el, + referenceEl: effectiveReferenceElement, + overlayPositioning, + placement, + offsetDistance, + offsetSkidding, + arrowEl, + type: "tooltip" + }); } // -------------------------------------------------------------------------- diff --git a/packages/calcite-components/src/utils/floating-ui.spec.ts b/packages/calcite-components/src/utils/floating-ui.spec.ts index 3d3e2e08da1..462c1bcd5ba 100644 --- a/packages/calcite-components/src/utils/floating-ui.spec.ts +++ b/packages/calcite-components/src/utils/floating-ui.spec.ts @@ -1,3 +1,4 @@ +import { debounce } from "lodash-es"; import { waitForAnimationFrame } from "../tests/utils"; import { cleanupMap, @@ -10,7 +11,6 @@ import { getEffectivePlacement, placements, positionFloatingUI, - reposition, repositionDebounceTimeout } from "./floating-ui"; @@ -62,6 +62,9 @@ describe("repositioning", () => { reposition: async () => { /* noop */ }, + debouncedReposition: debounce(() => { + fakeFloatingUiComponent.reposition(); + }, repositionDebounceTimeout), overlayPositioning: "absolute", placement: "auto" }; @@ -91,19 +94,19 @@ describe("repositioning", () => { } it("repositions only for open components", async () => { - await reposition(fakeFloatingUiComponent, positionOptions); + await positionFloatingUI(fakeFloatingUiComponent, positionOptions); assertPreOpenPositionining(floatingEl); fakeFloatingUiComponent.open = true; - await reposition(fakeFloatingUiComponent, positionOptions); + await positionFloatingUI(fakeFloatingUiComponent, positionOptions); assertOpenPositionining(floatingEl); }); it("repositions immediately by default", async () => { fakeFloatingUiComponent.open = true; - reposition(fakeFloatingUiComponent, positionOptions); + positionFloatingUI(fakeFloatingUiComponent, positionOptions); assertPreOpenPositionining(floatingEl); @@ -113,12 +116,20 @@ describe("repositioning", () => { it("can reposition after a delay", async () => { fakeFloatingUiComponent.open = true; + fakeFloatingUiComponent.reposition = async () => { + await positionFloatingUI(fakeFloatingUiComponent, positionOptions); + }; - reposition(fakeFloatingUiComponent, positionOptions, true); + fakeFloatingUiComponent.debouncedReposition(); assertPreOpenPositionining(floatingEl); - await new Promise((resolve) => setTimeout(resolve, repositionDebounceTimeout)); + await new Promise((resolve) => + setTimeout(resolve, repositionDebounceTimeout, { + leading: true, + maxWait: repositionDebounceTimeout + }) + ); assertOpenPositionining(floatingEl); }); diff --git a/packages/calcite-components/src/utils/floating-ui.ts b/packages/calcite-components/src/utils/floating-ui.ts index 9e3267be703..508c2faa610 100644 --- a/packages/calcite-components/src/utils/floating-ui.ts +++ b/packages/calcite-components/src/utils/floating-ui.ts @@ -15,7 +15,7 @@ import { VirtualElement } from "@floating-ui/dom"; import { Build } from "@stencil/core"; -import { debounce } from "lodash-es"; +import { debounce, DebouncedFunc } from "lodash-es"; import { config } from "./config"; import { getElementDir } from "./dom"; import { Layout } from "../components/interfaces"; @@ -195,10 +195,13 @@ 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(delayed?: boolean): Promise; + reposition(): Promise; + + /** + * Updates the position of the component after a delay. + */ + debouncedReposition: DebouncedFunc; /** * Used to store the effective floating layout for components that use arrows. @@ -310,42 +313,6 @@ export function getEffectivePlacement(floatingEl: HTMLElement, placement: Logica return placement.replace(/leading/gi, placements[0]).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.flipDisabled - * @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[1], - delayed = false -): Promise { - if (!component.open) { - return; - } - - return delayed ? debouncedReposition(component, options) : positionFloatingUI(component, options); -} - -const debouncedReposition = debounce(positionFloatingUI, repositionDebounceTimeout, { - leading: true, - maxWait: repositionDebounceTimeout -}); - const ARROW_CSS_TRANSFORM = { top: "", left: "rotate(-90deg)", @@ -417,7 +384,7 @@ export async function positionFloatingUI( type: UIType; } ): Promise { - if (!referenceEl || !floatingEl) { + if (!referenceEl || !floatingEl || !component.open) { return null; } @@ -490,6 +457,19 @@ export async function positionFloatingUI( */ export const cleanupMap = new WeakMap void>(); +/** + * Helper to set up debouncing the reposition method. + * + * @param {FloatingUIComponent} component - The FloatingUIComponent + * @returns {DebouncedFunc} - A debounced reposition function. + */ +export function debounceReposition(component: FloatingUIComponent): DebouncedFunc { + return debounce(component.reposition, repositionDebounceTimeout, { + leading: true, + maxWait: repositionDebounceTimeout + }); +} + /** * Helper to set up floating element interactions on connectedCallback. * @@ -549,6 +529,8 @@ export function disconnectFloatingUI( return; } + component.debouncedReposition?.cancel(); + const cleanup = cleanupMap.get(component); if (cleanup) { From 454987c8afee50f7e163cb7a57e9daeaf1f5fdb2 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Wed, 21 Jun 2023 17:56:28 -0700 Subject: [PATCH 2/2] revisit approach --- .../src/components/combobox/combobox.tsx | 44 +-- .../src/components/dropdown/dropdown.tsx | 50 +-- .../input-date-picker/input-date-picker.tsx | 40 +-- .../input-time-picker/input-time-picker.tsx | 20 +- .../src/components/popover/popover.tsx | 54 +-- .../src/components/tooltip/tooltip.tsx | 46 +-- .../src/utils/floating-ui.spec.ts | 56 +-- .../src/utils/floating-ui.ts | 326 ++++++++++-------- 8 files changed, 337 insertions(+), 299 deletions(-) diff --git a/packages/calcite-components/src/components/combobox/combobox.tsx b/packages/calcite-components/src/components/combobox/combobox.tsx index 9eb14b00e3c..827a8bb3074 100644 --- a/packages/calcite-components/src/components/combobox/combobox.tsx +++ b/packages/calcite-components/src/components/combobox/combobox.tsx @@ -18,7 +18,6 @@ import { filter } from "../../utils/filter"; import { isPrimaryPointerButton, toAriaBoolean } from "../../utils/dom"; import { connectFloatingUI, - debounceReposition, defaultMenuPlacement, disconnectFloatingUI, EffectivePlacement, @@ -27,7 +26,7 @@ import { FloatingUIComponent, LogicalPlacement, OverlayPositioning, - positionFloatingUI + reposition } from "../../utils/floating-ui"; import { afterConnectDefaultValueSet, @@ -196,7 +195,7 @@ export class Combobox @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -265,7 +264,7 @@ export class Combobox @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -325,21 +324,24 @@ export class Combobox /** * Updates the position of the component. * - * @param {boolean} delayed [Deprecated] - No longer necessary. - * @returns {Promise} + * @param delayed */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return positionFloatingUI(this, { - 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. */ @@ -401,7 +403,7 @@ export class Combobox connectForm(this); connectOpenCloseComponent(this); this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); if (this.open) { this.openHandler(); } @@ -415,13 +417,13 @@ export class Combobox componentDidLoad(): void { afterConnectDefaultValueSet(this, this.getValue()); - this.debouncedReposition(); + this.reposition(true); setComponentLoaded(this); } componentDidRender(): void { if (this.el.offsetHeight !== this.inputHeight) { - this.debouncedReposition(); + this.reposition(true); this.inputHeight = this.el.offsetHeight; } @@ -446,8 +448,6 @@ export class Combobox // //-------------------------------------------------------------------------- - debouncedReposition = debounceReposition(this); - placement: LogicalPlacement = defaultMenuPlacement; filteredFlipPlacements: EffectivePlacement[]; @@ -705,11 +705,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); }; calciteChipCloseHandler = (comboboxItem: HTMLCalciteComboboxItemElement): void => { diff --git a/packages/calcite-components/src/components/dropdown/dropdown.tsx b/packages/calcite-components/src/components/dropdown/dropdown.tsx index 271562f1a26..d4d14e23184 100644 --- a/packages/calcite-components/src/components/dropdown/dropdown.tsx +++ b/packages/calcite-components/src/components/dropdown/dropdown.tsx @@ -21,7 +21,6 @@ import { } from "../../utils/dom"; import { connectFloatingUI, - debounceReposition, defaultMenuPlacement, disconnectFloatingUI, EffectivePlacement, @@ -30,7 +29,7 @@ import { FloatingUIComponent, MenuPlacement, OverlayPositioning, - positionFloatingUI + reposition } from "../../utils/floating-ui"; import { guid } from "../../utils/guid"; import { @@ -93,7 +92,7 @@ export class Dropdown openHandler(value: boolean): void { if (!this.disabled) { if (value) { - this.debouncedReposition(); + this.reposition(true); } return; } @@ -129,7 +128,7 @@ export class Dropdown @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -155,7 +154,7 @@ export class Dropdown @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -167,7 +166,7 @@ export class Dropdown @Watch("placement") placementHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -214,7 +213,7 @@ export class Dropdown 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); } @@ -228,7 +227,7 @@ export class Dropdown componentDidLoad(): void { setComponentLoaded(this); - this.debouncedReposition(); + this.reposition(true); } componentDidRender(): void { @@ -297,21 +296,24 @@ export class Dropdown /** * Updates the position of the component. * - * @param {boolean} delayed [Deprecated] - No longer necessary. - * @returns {Promise} + * @param delayed */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return positionFloatingUI(this, { - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }); + return reposition( + this, + { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }, + delayed + ); } //-------------------------------------------------------------------------- @@ -432,8 +434,6 @@ export class Dropdown // //-------------------------------------------------------------------------- - debouncedReposition = debounceReposition(this); - filteredFlipPlacements: EffectivePlacement[]; private items: HTMLCalciteDropdownItemElement[] = []; @@ -488,7 +488,7 @@ export class Dropdown flatten: true }) as HTMLElement[]; - this.debouncedReposition(); + this.reposition(true); }; updateItems = (): void => { @@ -498,7 +498,7 @@ export class Dropdown this.updateSelectedItems(); - this.debouncedReposition(); + this.reposition(true); }; updateGroups = (event: Event): void => { @@ -539,10 +539,10 @@ export class Dropdown 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/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx b/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx index bffc8357205..9ee4ae2b328 100644 --- a/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx +++ b/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx @@ -24,7 +24,6 @@ import { import { toAriaBoolean } from "../../utils/dom"; import { connectFloatingUI, - debounceReposition, defaultMenuPlacement, disconnectFloatingUI, EffectivePlacement, @@ -33,7 +32,7 @@ import { FloatingUIComponent, MenuPlacement, OverlayPositioning, - positionFloatingUI + reposition } from "../../utils/floating-ui"; import { connectForm, @@ -205,7 +204,7 @@ export class InputDatePicker @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -272,7 +271,7 @@ export class InputDatePicker } if (value) { - this.debouncedReposition(); + this.reposition(true); } } @@ -321,7 +320,7 @@ export class InputDatePicker @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -412,21 +411,24 @@ export class InputDatePicker /** * Updates the position of the component. * - * @param {boolean} delayed [Deprecated] - No longer necessary. - * @returns {Promise} + * @param delayed */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { floatingEl, referenceEl, placement, overlayPositioning, filteredFlipPlacements } = this; - return positionFloatingUI(this, { - floatingEl, - referenceEl, - overlayPositioning, - placement, - flipPlacements: filteredFlipPlacements, - type: "menu" - }); + return reposition( + this, + { + floatingEl, + referenceEl, + overlayPositioning, + placement, + flipPlacements: filteredFlipPlacements, + type: "menu" + }, + delayed + ); } // -------------------------------------------------------------------------- @@ -468,7 +470,7 @@ export class InputDatePicker connectMessages(this); this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); numberStringFormatter.numberFormatOptions = { numberingSystem: this.numberingSystem, @@ -487,7 +489,7 @@ export class InputDatePicker componentDidLoad(): void { setComponentLoaded(this); this.localizeInputValues(); - this.debouncedReposition(); + this.reposition(true); } disconnectedCallback(): void { @@ -666,8 +668,6 @@ export class InputDatePicker // //-------------------------------------------------------------------------- - debouncedReposition = debounceReposition(this); - private datePickerEl: HTMLCalciteDatePickerElement; private dialogId = `date-picker-dialog--${guid()}`; diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx index 477e23272e9..1f4e8565ae3 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx @@ -11,12 +11,7 @@ import { VNode, Watch } from "@stencil/core"; -import { - debounceReposition, - FloatingUIComponent, - LogicalPlacement, - OverlayPositioning -} from "../../utils/floating-ui"; +import { FloatingUIComponent, LogicalPlacement, OverlayPositioning } from "../../utils/floating-ui"; import { connectForm, disconnectForm, @@ -179,7 +174,7 @@ export class InputTimePicker } if (value) { - this.debouncedReposition(); + this.reposition(true); } } @@ -316,8 +311,6 @@ export class InputTimePicker // //-------------------------------------------------------------------------- - debouncedReposition = debounceReposition(this); - defaultValue: InputTimePicker["value"]; formEl: HTMLFormElement; @@ -472,12 +465,11 @@ export class InputTimePicker /** * Updates the position of the component. * - * @param {boolean} delayed [Deprecated] - No longer necessary. - * @returns {Promise} + * @param delayed */ @Method() - async reposition(): Promise { - this.popoverEl?.reposition(); + async reposition(delayed = false): Promise { + this.popoverEl?.reposition(delayed); } // -------------------------------------------------------------------------- @@ -589,7 +581,7 @@ export class InputTimePicker private getExtendedLocaleConfig( locale: string - ): Parameters[1] | undefined { + ): Parameters<(typeof dayjs)["updateLocale"]>[1] | undefined { if (locale === "ar") { return { meridiem: (hour) => (hour > 12 ? "م" : "ص"), diff --git a/packages/calcite-components/src/components/popover/popover.tsx b/packages/calcite-components/src/components/popover/popover.tsx index 85b2ba3b2a3..4092929ca83 100644 --- a/packages/calcite-components/src/components/popover/popover.tsx +++ b/packages/calcite-components/src/components/popover/popover.tsx @@ -24,8 +24,7 @@ import { LogicalPlacement, OverlayPositioning, ReferenceElement, - positionFloatingUI, - debounceReposition + reposition } from "../../utils/floating-ui"; import { activateFocusTrap, @@ -133,7 +132,7 @@ export class Popover @Watch("flipPlacements") flipPlacementsHandler(): void { this.setFilteredPlacements(); - this.debouncedReposition(); + this.reposition(true); } /** @@ -177,7 +176,7 @@ export class Popover @Watch("offsetDistance") offsetDistanceOffsetHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -187,7 +186,7 @@ export class Popover @Watch("offsetSkidding") offsetSkiddingHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -198,7 +197,7 @@ export class Popover @Watch("open") openHandler(value: boolean): void { if (value) { - this.debouncedReposition(); + this.reposition(true); } this.setExpandedAttr(); @@ -216,7 +215,7 @@ export class Popover @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -226,7 +225,7 @@ export class Popover @Watch("placement") placementHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -237,7 +236,7 @@ export class Popover @Watch("referenceElement") referenceElementHandler(): void { this.setUpReferenceElement(); - this.debouncedReposition(); + this.reposition(true); } /** Specifies the size of the component. */ @@ -256,8 +255,6 @@ export class Popover // // -------------------------------------------------------------------------- - debouncedReposition = debounceReposition(this); - mutationObserver: MutationObserver = createObserver("mutation", () => this.updateFocusTrapElements() ); @@ -358,11 +355,10 @@ export class Popover /** * Updates the position of the component. * - * @param {boolean} delayed [Deprecated] - No longer necessary. - * @returns {Promise} + * @param delayed */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { el, effectiveReferenceElement, @@ -374,18 +370,22 @@ export class Popover offsetSkidding, arrowEl } = this; - return positionFloatingUI(this, { - floatingEl: el, - referenceEl: effectiveReferenceElement, - overlayPositioning, - placement, - flipDisabled, - flipPlacements: filteredFlipPlacements, - offsetDistance, - offsetSkidding, - arrowEl, - type: "popover" - }); + return reposition( + this, + { + floatingEl: el, + referenceEl: effectiveReferenceElement, + overlayPositioning, + placement, + flipDisabled, + flipPlacements: filteredFlipPlacements, + offsetDistance, + offsetSkidding, + arrowEl, + type: "popover" + }, + delayed + ); } /** @@ -522,7 +522,7 @@ export class Popover storeArrowEl = (el: SVGElement): void => { this.arrowEl = el; - this.debouncedReposition(); + this.reposition(true); }; // -------------------------------------------------------------------------- diff --git a/packages/calcite-components/src/components/tooltip/tooltip.tsx b/packages/calcite-components/src/components/tooltip/tooltip.tsx index 8d4c75f4383..9687b241b55 100644 --- a/packages/calcite-components/src/components/tooltip/tooltip.tsx +++ b/packages/calcite-components/src/components/tooltip/tooltip.tsx @@ -22,8 +22,7 @@ import { LogicalPlacement, OverlayPositioning, ReferenceElement, - positionFloatingUI, - debounceReposition + reposition } from "../../utils/floating-ui"; import { guid } from "../../utils/guid"; import { @@ -69,7 +68,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("offsetDistance") offsetDistanceOffsetHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -79,7 +78,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("offsetSkidding") offsetSkiddingHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -90,7 +89,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("open") openHandler(value: boolean): void { if (value) { - this.debouncedReposition(); + this.reposition(true); } } @@ -106,7 +105,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("overlayPositioning") overlayPositioningHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -116,7 +115,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { @Watch("placement") placementHandler(): void { - this.debouncedReposition(); + this.reposition(true); } /** @@ -139,8 +138,6 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { // // -------------------------------------------------------------------------- - debouncedReposition = debounceReposition(this); - @Element() el: HTMLCalciteTooltipElement; @State() effectiveReferenceElement: ReferenceElement; @@ -172,7 +169,7 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { if (this.referenceElement && !this.effectiveReferenceElement) { this.setUpReferenceElement(); } - this.debouncedReposition(); + this.reposition(true); this.hasLoaded = true; } @@ -209,11 +206,10 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { /** * Updates the position of the component. * - * @param {boolean} delayed [Deprecated] - No longer necessary. - * @returns {Promise} + * @param delayed */ @Method() - async reposition(): Promise { + async reposition(delayed = false): Promise { const { el, effectiveReferenceElement, @@ -224,16 +220,20 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent { arrowEl } = this; - return positionFloatingUI(this, { - floatingEl: el, - referenceEl: effectiveReferenceElement, - overlayPositioning, - placement, - offsetDistance, - offsetSkidding, - arrowEl, - type: "tooltip" - }); + return reposition( + this, + { + floatingEl: el, + referenceEl: effectiveReferenceElement, + overlayPositioning, + placement, + offsetDistance, + offsetSkidding, + arrowEl, + type: "tooltip" + }, + delayed + ); } // -------------------------------------------------------------------------- diff --git a/packages/calcite-components/src/utils/floating-ui.spec.ts b/packages/calcite-components/src/utils/floating-ui.spec.ts index 462c1bcd5ba..1eb894da7bc 100644 --- a/packages/calcite-components/src/utils/floating-ui.spec.ts +++ b/packages/calcite-components/src/utils/floating-ui.spec.ts @@ -1,18 +1,20 @@ -import { debounce } from "lodash-es"; import { waitForAnimationFrame } from "../tests/utils"; -import { +import * as floatingUI from "./floating-ui"; +import { FloatingUIComponent } from "./floating-ui"; + +const { cleanupMap, connectFloatingUI, defaultOffsetDistance, disconnectFloatingUI, effectivePlacements, filterComputedPlacements, - FloatingUIComponent, getEffectivePlacement, placements, positionFloatingUI, + reposition, repositionDebounceTimeout -} from "./floating-ui"; +} = floatingUI; import * as floatingUIDOM from "@floating-ui/dom"; @@ -56,18 +58,19 @@ describe("repositioning", () => { let referenceEl: HTMLButtonElement; let positionOptions: Parameters[1]; - beforeEach(() => { - fakeFloatingUiComponent = { + function createFakeFloatingUiComponent(): FloatingUIComponent { + return { open: false, reposition: async () => { /* noop */ }, - debouncedReposition: debounce(() => { - fakeFloatingUiComponent.reposition(); - }, repositionDebounceTimeout), overlayPositioning: "absolute", placement: "auto" }; + } + + beforeEach(() => { + fakeFloatingUiComponent = createFakeFloatingUiComponent(); floatingEl = document.createElement("div"); referenceEl = document.createElement("button"); @@ -94,19 +97,19 @@ describe("repositioning", () => { } it("repositions only for open components", async () => { - await positionFloatingUI(fakeFloatingUiComponent, positionOptions); + await reposition(fakeFloatingUiComponent, positionOptions); assertPreOpenPositionining(floatingEl); fakeFloatingUiComponent.open = true; - await positionFloatingUI(fakeFloatingUiComponent, positionOptions); + await reposition(fakeFloatingUiComponent, positionOptions); assertOpenPositionining(floatingEl); }); it("repositions immediately by default", async () => { fakeFloatingUiComponent.open = true; - positionFloatingUI(fakeFloatingUiComponent, positionOptions); + reposition(fakeFloatingUiComponent, positionOptions); assertPreOpenPositionining(floatingEl); @@ -116,20 +119,12 @@ describe("repositioning", () => { it("can reposition after a delay", async () => { fakeFloatingUiComponent.open = true; - fakeFloatingUiComponent.reposition = async () => { - await positionFloatingUI(fakeFloatingUiComponent, positionOptions); - }; - fakeFloatingUiComponent.debouncedReposition(); + reposition(fakeFloatingUiComponent, positionOptions, true); assertPreOpenPositionining(floatingEl); - await new Promise((resolve) => - setTimeout(resolve, repositionDebounceTimeout, { - leading: true, - maxWait: repositionDebounceTimeout - }) - ); + await new Promise((resolve) => setTimeout(resolve, repositionDebounceTimeout)); assertOpenPositionining(floatingEl); }); @@ -166,6 +161,23 @@ describe("repositioning", () => { expect(floatingEl.style.position).toBe("fixed"); }); }); + + it("debounces positioning per instance", async () => { + const positionSpy = jest.spyOn(floatingUI, "positionFloatingUI"); + fakeFloatingUiComponent.open = true; + + const anotherFakeFloatingUiComponent = createFakeFloatingUiComponent(); + anotherFakeFloatingUiComponent.open = true; + + floatingUI.reposition(fakeFloatingUiComponent, positionOptions, true); + expect(positionSpy).toHaveBeenCalledTimes(1); + + floatingUI.reposition(anotherFakeFloatingUiComponent, positionOptions, true); + expect(positionSpy).toHaveBeenCalledTimes(2); + + await new Promise((resolve) => setTimeout(resolve, repositionDebounceTimeout)); + expect(positionSpy).toHaveBeenCalledTimes(2); + }); }); it("should have correct value for defaultOffsetDistance", () => { diff --git a/packages/calcite-components/src/utils/floating-ui.ts b/packages/calcite-components/src/utils/floating-ui.ts index 5215abbaac9..c3ee1ee8cbf 100644 --- a/packages/calcite-components/src/utils/floating-ui.ts +++ b/packages/calcite-components/src/utils/floating-ui.ts @@ -23,7 +23,7 @@ import { getUserAgentData, getUserAgentString } from "./browser"; const floatingUIBrowserCheck = patchFloatingUiForNonChromiumBrowsers(); -export function isChrome109OrAbove(): boolean { +function isChrome109OrAbove(): boolean { const uaData = getUserAgentData(); if (uaData?.brands) { @@ -53,6 +53,138 @@ async function patchFloatingUiForNonChromiumBrowsers(): Promise { } } +/** + * Positions the floating element relative to the reference element. + * + * **Note:** exported for testing purposes only + * + * @param root0 + * @param root0.referenceEl + * @param root0.floatingEl + * @param root0.overlayPositioning + * @param root0.placement + * @param root0.flipDisabled + * @param root0.flipPlacements + * @param root0.offsetDistance + * @param root0.offsetSkidding + * @param root0.arrowEl + * @param root0.type + * @param component + * @param root0.referenceEl.referenceEl + * @param root0.referenceEl.floatingEl + * @param root0.referenceEl.overlayPositioning + * @param root0.referenceEl.placement + * @param root0.referenceEl.flipDisabled + * @param root0.referenceEl.flipPlacements + * @param root0.referenceEl.offsetDistance + * @param root0.referenceEl.offsetSkidding + * @param root0.referenceEl.arrowEl + * @param root0.referenceEl.type + * @param component.referenceEl + * @param component.floatingEl + * @param component.overlayPositioning + * @param component.placement + * @param component.flipDisabled + * @param component.flipPlacements + * @param component.offsetDistance + * @param component.offsetSkidding + * @param component.arrowEl + * @param component.type + */ +export const positionFloatingUI = + /* we export arrow function to allow us to spy on it during testing */ + async ( + component: FloatingUIComponent, + { + referenceEl, + floatingEl, + overlayPositioning = "absolute", + placement, + flipDisabled, + flipPlacements, + offsetDistance, + offsetSkidding, + arrowEl, + type + }: { + referenceEl: ReferenceElement; + floatingEl: HTMLElement; + overlayPositioning: Strategy; + placement: LogicalPlacement; + flipDisabled?: boolean; + flipPlacements?: EffectivePlacement[]; + offsetDistance?: number; + offsetSkidding?: number; + arrowEl?: SVGElement; + type: UIType; + } + ): Promise => { + if (!referenceEl || !floatingEl) { + return null; + } + + await floatingUIBrowserCheck; + + const { + x, + y, + placement: effectivePlacement, + strategy: position, + middlewareData + } = await computePosition(referenceEl, floatingEl, { + strategy: overlayPositioning, + placement: + placement === "auto" || placement === "auto-start" || placement === "auto-end" + ? undefined + : getEffectivePlacement(floatingEl, placement), + middleware: getMiddleware({ + placement, + flipDisabled, + flipPlacements, + offsetDistance, + offsetSkidding, + arrowEl, + type + }) + }); + + if (arrowEl && middlewareData.arrow) { + const { x, y } = middlewareData.arrow; + const side = effectivePlacement.split("-")[0] as Side; + const alignment = x != null ? "left" : "top"; + const transform = ARROW_CSS_TRANSFORM[side]; + const reset = { left: "", top: "", bottom: "", right: "" }; + + if ("floatingLayout" in component) { + component.floatingLayout = side === "left" || side === "right" ? "horizontal" : "vertical"; + } + + Object.assign(arrowEl.style, { + ...reset, + [alignment]: `${alignment == "left" ? x : y}px`, + [side]: "100%", + transform + }); + } + + const referenceHidden = middlewareData.hide?.referenceHidden; + const visibility = referenceHidden ? "hidden" : null; + const pointerEvents = visibility ? "none" : null; + + floatingEl.setAttribute(placementDataAttribute, effectivePlacement); + + const transform = `translate(${Math.round(x)}px,${Math.round(y)}px)`; + + Object.assign(floatingEl.style, { + visibility, + pointerEvents, + position, + top: "0", + left: "0", + transform + }); + }; + /** * Exported for testing purposes only */ @@ -178,13 +310,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; - - /** - * Updates the position of the component after a delay. - */ - debouncedReposition: DebouncedFunc; + reposition(delayed?: boolean): Promise; /** * Used to store the effective floating layout for components that use arrows. @@ -296,143 +425,63 @@ export function getEffectivePlacement(floatingEl: HTMLElement, placement: Logica return placement.replace(/leading/gi, placements[0]).replace(/trailing/gi, placements[1]) as EffectivePlacement; } -const ARROW_CSS_TRANSFORM = { - top: "", - left: "rotate(-90deg)", - bottom: "rotate(180deg)", - right: "rotate(90deg)" -}; - /** - * Positions the floating element relative to the reference element. + * Convenience function to manage `reposition` calls for FloatingUIComponents that use `positionFloatingUI. * - * **Note:** exported for testing purposes only + * Note: this is not needed for components that use `calcite-popover`. * - * @param root0 - * @param root0.referenceEl - * @param root0.floatingEl - * @param root0.overlayPositioning - * @param root0.placement - * @param root0.flipDisabled - * @param root0.flipPlacements - * @param root0.offsetDistance - * @param root0.offsetSkidding - * @param root0.arrowEl - * @param root0.type * @param component - * @param root0.referenceEl.referenceEl - * @param root0.referenceEl.floatingEl - * @param root0.referenceEl.overlayPositioning - * @param root0.referenceEl.placement - * @param root0.referenceEl.flipDisabled - * @param root0.referenceEl.flipPlacements - * @param root0.referenceEl.offsetDistance - * @param root0.referenceEl.offsetSkidding - * @param root0.referenceEl.arrowEl - * @param root0.referenceEl.type - * @param component.referenceEl - * @param component.floatingEl - * @param component.overlayPositioning - * @param component.placement - * @param component.flipDisabled - * @param component.flipPlacements - * @param component.offsetDistance - * @param component.offsetSkidding - * @param component.arrowEl - * @param component.type + * @param options + * @param options.referenceEl + * @param options.floatingEl + * @param options.overlayPositioning + * @param options.placement + * @param options.flipDisabled + * @param options.flipPlacements + * @param options.offsetDistance + * @param options.offsetSkidding + * @param options.arrowEl + * @param options.type + * @param delayed */ -export async function positionFloatingUI( +export async function reposition( component: FloatingUIComponent, - { - referenceEl, - floatingEl, - overlayPositioning = "absolute", - placement, - flipDisabled, - flipPlacements, - offsetDistance, - offsetSkidding, - arrowEl, - type - }: { - referenceEl: ReferenceElement; - floatingEl: HTMLElement; - overlayPositioning: Strategy; - placement: LogicalPlacement; - flipDisabled?: boolean; - flipPlacements?: EffectivePlacement[]; - offsetDistance?: number; - offsetSkidding?: number; - arrowEl?: SVGElement; - type: UIType; - } + options: Parameters[1], + delayed = false ): Promise { - if (!referenceEl || !floatingEl || !component.open) { - return null; + if (!component.open) { + return; } - await floatingUIBrowserCheck; - - const { - x, - y, - placement: effectivePlacement, - strategy: position, - middlewareData - } = await computePosition(referenceEl, floatingEl, { - strategy: overlayPositioning, - placement: - placement === "auto" || placement === "auto-start" || placement === "auto-end" - ? undefined - : getEffectivePlacement(floatingEl, placement), - middleware: getMiddleware({ - placement, - flipDisabled, - flipPlacements, - offsetDistance, - offsetSkidding, - arrowEl, - type - }) - }); + const positionFunction = delayed ? getDebouncedReposition(component) : positionFloatingUI; - if (arrowEl && middlewareData.arrow) { - const { x, y } = middlewareData.arrow; - const side = effectivePlacement.split("-")[0] as Side; - const alignment = x != null ? "left" : "top"; - const transform = ARROW_CSS_TRANSFORM[side]; - const reset = { left: "", top: "", bottom: "", right: "" }; + return positionFunction(component, options); +} - if ("floatingLayout" in component) { - component.floatingLayout = side === "left" || side === "right" ? "horizontal" : "vertical"; - } +function getDebouncedReposition(component: FloatingUIComponent): DebouncedFunc { + let debounced = componentToDebouncedRepositionMap.get(component); - Object.assign(arrowEl.style, { - ...reset, - [alignment]: `${alignment == "left" ? x : y}px`, - [side]: "100%", - transform - }); + if (debounced) { + return debounced; } - const referenceHidden = middlewareData.hide?.referenceHidden; - const visibility = referenceHidden ? "hidden" : null; - const pointerEvents = visibility ? "none" : null; - - floatingEl.setAttribute(placementDataAttribute, effectivePlacement); + debounced = debounce(positionFloatingUI, repositionDebounceTimeout, { + leading: true, + maxWait: repositionDebounceTimeout + }); - const transform = `translate(${Math.round(x)}px,${Math.round(y)}px)`; + componentToDebouncedRepositionMap.set(component, debounced); - Object.assign(floatingEl.style, { - visibility, - pointerEvents, - position, - top: "0", - left: "0", - transform - }); + return debounced; } +const ARROW_CSS_TRANSFORM = { + top: "", + left: "rotate(-90deg)", + bottom: "rotate(180deg)", + right: "rotate(90deg)" +}; + /** * Exported for testing purposes only * @@ -440,18 +489,7 @@ export async function positionFloatingUI( */ export const cleanupMap = new WeakMap void>(); -/** - * Helper to set up debouncing the reposition method. - * - * @param {FloatingUIComponent} component - The FloatingUIComponent - * @returns {DebouncedFunc} - A debounced reposition function. - */ -export function debounceReposition(component: FloatingUIComponent): DebouncedFunc { - return debounce(component.reposition, repositionDebounceTimeout, { - leading: true, - maxWait: repositionDebounceTimeout - }); -} +const componentToDebouncedRepositionMap = new WeakMap>(); /** * Helper to set up floating element interactions on connectedCallback. @@ -512,15 +550,11 @@ export function disconnectFloatingUI( return; } - component.debouncedReposition?.cancel(); - - const cleanup = cleanupMap.get(component); - - if (cleanup) { - cleanup(); - } - + cleanupMap.get(component)?.(); cleanupMap.delete(component); + + componentToDebouncedRepositionMap.get(component)?.cancel(); + componentToDebouncedRepositionMap.delete(component); } const visiblePointerSize = 4;