diff --git a/package-lock.json b/package-lock.json index e01d0089b94..bfb3b6b0662 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15282,9 +15282,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001498", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001498.tgz", - "integrity": "sha512-LFInN2zAwx3ANrGCDZ5AKKJroHqNKyjXitdV5zRIVIaQlXKj3GmxUKagoKsjqUfckpAObPCEWnk5EeMlyMWcgw==", + "version": "1.0.30001504", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001504.tgz", + "integrity": "sha512-5uo7eoOp2mKbWyfMXnGO9rJWOGU8duvzEiYITW+wivukL7yHH4gX9yuRaobu6El4jPxo6jKZfG+N6fB621GD/Q==", "dev": true, "funding": [ { @@ -55345,9 +55345,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001498", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001498.tgz", - "integrity": "sha512-LFInN2zAwx3ANrGCDZ5AKKJroHqNKyjXitdV5zRIVIaQlXKj3GmxUKagoKsjqUfckpAObPCEWnk5EeMlyMWcgw==", + "version": "1.0.30001504", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001504.tgz", + "integrity": "sha512-5uo7eoOp2mKbWyfMXnGO9rJWOGU8duvzEiYITW+wivukL7yHH4gX9yuRaobu6El4jPxo6jKZfG+N6fB621GD/Q==", "dev": true }, "capture-exit": { diff --git a/packages/calcite-components/CHANGELOG.md b/packages/calcite-components/CHANGELOG.md index d05fcceb5bd..468a298203e 100644 --- a/packages/calcite-components/CHANGELOG.md +++ b/packages/calcite-components/CHANGELOG.md @@ -7,6 +7,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Bug Fixes +- **combobox, dropdown, input-date-picker, input-time-picker, popover, tooltip:** Prevent repositioning from affecting other floating components ([#7178](https://github.com/Esri/calcite-components/issues/7178)) ([1b02dae](https://github.com/Esri/calcite-components/commit/1b02dae4ef4e9594ece0a72bb8bc69fd2f7cf84a)), closes [#7158](https://github.com/Esri/calcite-components/issues/7158) + - **alert:** Sets autoCloseDuration to "medium" by default ([#7157](https://github.com/Esri/calcite-components/issues/7157)) ([1b9a8ed](https://github.com/Esri/calcite-components/commit/1b9a8edc1b7fab87899bd59c74ad036b5f53140c)) - **alert:** Update alert queue when an alert is removed from the DOM ([#7189](https://github.com/Esri/calcite-components/issues/7189)) ([edd59eb](https://github.com/Esri/calcite-components/commit/edd59eb0bff21aa41fc7e537a2df2dbd2143a15a)) - Ensure mouse events are blocked for disabled components in Firefox ([#7107](https://github.com/Esri/calcite-components/issues/7107)) ([271d985](https://github.com/Esri/calcite-components/commit/271d9855eef4aa94cb7131381c98ab03eea57e4e)) diff --git a/packages/calcite-components/package.json b/packages/calcite-components/package.json index 69805553208..dcd31156766 100644 --- a/packages/calcite-components/package.json +++ b/packages/calcite-components/package.json @@ -19,7 +19,7 @@ ], "scripts": { "build": "npm run util:prep-build-reqs && stencil build", - "postbuild": "npm run util:patch && git restore src/components/*/readme.md", + "postbuild": "npm run util:patch && npm run util:generate-t9n-docs-json && git restore src/components/*/readme.md", "build:watch": "npm run util:prep-build-reqs && stencil build --no-docs --watch", "build:watch-dev": "npm run util:prep-build-reqs && stencil build --no-docs --dev --watch", "build-storybook": "npm run util:build-docs && build-storybook --output-dir ./docs --quiet", @@ -43,6 +43,7 @@ "util:clean-tested-build": "npm ci && npm test && npm run build", "util:copy-assets": "npm run util:copy-icons", "util:copy-icons": "cpy \"../../node_modules/@esri/calcite-ui-icons/js/*.json\" \"./src/components/icon/assets/icon/\" --flat", + "util:generate-t9n-docs-json": "ts-node --esm support/generateT9nDocsJSON.ts", "util:generate-t9n-types": "ts-node --esm support/generateT9nTypes.ts", "util:hydration-styles": "ts-node --esm support/hydrationStyles.ts", "util:patch": "npm run util:patch-esm-resolution && npm run util:patch-tree-shaking", 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 20a1eea3017..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 @@ -581,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/utils/floating-ui.spec.ts b/packages/calcite-components/src/utils/floating-ui.spec.ts index 3d3e2e08da1..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 { 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,8 +58,8 @@ describe("repositioning", () => { let referenceEl: HTMLButtonElement; let positionOptions: Parameters[1]; - beforeEach(() => { - fakeFloatingUiComponent = { + function createFakeFloatingUiComponent(): FloatingUIComponent { + return { open: false, reposition: async () => { /* noop */ @@ -65,6 +67,10 @@ describe("repositioning", () => { overlayPositioning: "absolute", placement: "auto" }; + } + + beforeEach(() => { + fakeFloatingUiComponent = createFakeFloatingUiComponent(); floatingEl = document.createElement("div"); referenceEl = document.createElement("button"); @@ -155,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 9a66171a50e..c3ee1ee8cbf 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"; @@ -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 */ @@ -321,151 +453,35 @@ export async function reposition( return; } - return delayed ? debouncedReposition(component, options) : positionFloatingUI(component, options); + const positionFunction = delayed ? getDebouncedReposition(component) : positionFloatingUI; + + return positionFunction(component, options); } -const debouncedReposition = debounce(positionFloatingUI, repositionDebounceTimeout, { - leading: true, - maxWait: repositionDebounceTimeout -}); +function getDebouncedReposition(component: FloatingUIComponent): DebouncedFunc { + let debounced = componentToDebouncedRepositionMap.get(component); -const ARROW_CSS_TRANSFORM = { - top: "", - left: "rotate(-90deg)", - bottom: "rotate(180deg)", - right: "rotate(90deg)" -}; - -/** - * 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 async function positionFloatingUI( - 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; + if (debounced) { + return debounced; } - 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 - }) + debounced = debounce(positionFloatingUI, repositionDebounceTimeout, { + leading: true, + maxWait: repositionDebounceTimeout }); - 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; + componentToDebouncedRepositionMap.set(component, debounced); - 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 - }); + return debounced; } +const ARROW_CSS_TRANSFORM = { + top: "", + left: "rotate(-90deg)", + bottom: "rotate(180deg)", + right: "rotate(90deg)" +}; + /** * Exported for testing purposes only * @@ -473,6 +489,8 @@ export async function positionFloatingUI( */ export const cleanupMap = new WeakMap void>(); +const componentToDebouncedRepositionMap = new WeakMap>(); + /** * Helper to set up floating element interactions on connectedCallback. * @@ -532,13 +550,11 @@ export function disconnectFloatingUI( return; } - const cleanup = cleanupMap.get(component); - - if (cleanup) { - cleanup(); - } - + cleanupMap.get(component)?.(); cleanupMap.delete(component); + + componentToDebouncedRepositionMap.get(component)?.cancel(); + componentToDebouncedRepositionMap.delete(component); } const visiblePointerSize = 4; diff --git a/packages/calcite-components/stencil.config.ts b/packages/calcite-components/stencil.config.ts index c9ae5734c90..0442b7d992c 100644 --- a/packages/calcite-components/stencil.config.ts +++ b/packages/calcite-components/stencil.config.ts @@ -127,6 +127,7 @@ export const create: () => Config = () => ({ }) ], testing: { + watchPathIgnorePatterns: ["/../../node_modules", "/dist", "/www", "/hydrate"], moduleNameMapper: { "^/assets/(.*)$": "/src/tests/iconPathDataStub.ts", "^lodash-es$": "lodash" diff --git a/packages/calcite-components/support/generateT9nDocsJSON.ts b/packages/calcite-components/support/generateT9nDocsJSON.ts new file mode 100755 index 00000000000..2be3d3dcc30 --- /dev/null +++ b/packages/calcite-components/support/generateT9nDocsJSON.ts @@ -0,0 +1,46 @@ +// generates a JSON file containing the per component t9n translation values +(async () => { + const { dirname, resolve } = await import("path"); + const { fileURLToPath } = await import("url"); + const { + existsSync, + promises: { readFile, readdir, writeFile } + } = await import("fs"); + try { + const __dirname = dirname(fileURLToPath(import.meta.url)); + + const outfile = resolve(__dirname, "..", "dist", "extras", "translations-json.json"); + const assetsPaths = resolve(__dirname, "..", "dist", "calcite", "assets"); + const components = await readdir(assetsPaths); + + const data = {}; + const messagesFilenameRegex = /messages_(.*)\.json/; + + for (const component of components) { + const t9nPath = resolve(assetsPaths, component, "t9n"); + if (existsSync(t9nPath)) { + data[component] = {}; + const messagesFileMain = JSON.parse(await readFile(resolve(t9nPath, "messages.json"), { encoding: "utf-8" })); + Object.keys(messagesFileMain).forEach((key) => (data[component][key] = {})); + + const messagesFilenames = (await readdir(t9nPath, { withFileTypes: true })).map((dirent) => dirent.name); + for (const messagesFilename of messagesFilenames) { + const messagesFilenameMatch = messagesFilename.match(messagesFilenameRegex); + + if (messagesFilenameMatch && messagesFilenameMatch.length > 1) { + const lang = messagesFilenameMatch[1]; + const messagesFile = JSON.parse(await readFile(resolve(t9nPath, messagesFilename), { encoding: "utf-8" })); + + for (const [key, value] of Object.entries(messagesFile)) { + data[component][key][lang] = value; + } + } + } + } + } + await writeFile(outfile, JSON.stringify(data), "utf-8"); + } catch (err) { + console.error(err); + process.exit(1); + } +})();