From ef08ad4050263519a4d06dcdb809db5f5868d925 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 Nov 2024 12:30:57 +0100 Subject: [PATCH 1/9] chore(language-selector): add floating-ui --- .../language-selector-web/package.json | 1 + pnpm-lock.yaml | 12 +++--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/pluggableWidgets/language-selector-web/package.json b/packages/pluggableWidgets/language-selector-web/package.json index d4dc62dfe4..f3139081b8 100644 --- a/packages/pluggableWidgets/language-selector-web/package.json +++ b/packages/pluggableWidgets/language-selector-web/package.json @@ -50,6 +50,7 @@ "@mendix/widget-plugin-platform": "workspace:*" }, "dependencies": { + "@floating-ui/react": "^0.26.27", "classnames": "^2.3.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41f7a57f83..25208bb053 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1523,6 +1523,9 @@ importers: packages/pluggableWidgets/language-selector-web: dependencies: + '@floating-ui/react': + specifier: ^0.26.27 + version: 0.26.27(react-dom@18.2.0(react@18.2.0))(react@18.2.0) classnames: specifier: ^2.3.2 version: 2.3.2 @@ -4850,7 +4853,6 @@ packages: abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} - deprecated: Use your platform's native atob() and btoa() methods instead abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} @@ -6061,7 +6063,6 @@ packages: domexception@4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} engines: {node: '>=12'} - deprecated: Use your platform's native DOMException instead domhandler@3.3.0: resolution: {integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==} @@ -6789,16 +6790,13 @@ packages: glob@7.2.0: resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Glob versions prior to v9 are no longer supported glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported global-dirs@0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} @@ -7101,7 +7099,6 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -9383,7 +9380,6 @@ packages: rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} - deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: @@ -9710,7 +9706,6 @@ packages: sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} - deprecated: Please use @jridgewell/sourcemap-codec instead spawn-command@0.0.2-1: resolution: {integrity: sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==} @@ -9754,7 +9749,6 @@ packages: stable@0.1.8: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} - deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' stack-trace@0.0.9: resolution: {integrity: sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==} From 830b1ec05618a2e3447ad6797691913d88218760 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 Nov 2024 13:29:14 +0100 Subject: [PATCH 2/9] feat(language-selector): refactor language selector to use floating-ui --- .../src/components/LanguageSwitcher.tsx | 226 +++++++-------- .../src/hooks/useFloatingUI.ts | 130 +++++++++ .../src/ui/LanguageSelector.scss | 1 + .../src/utils/document.ts | 264 ------------------ 4 files changed, 226 insertions(+), 395 deletions(-) create mode 100644 packages/pluggableWidgets/language-selector-web/src/hooks/useFloatingUI.ts delete mode 100644 packages/pluggableWidgets/language-selector-web/src/utils/document.ts diff --git a/packages/pluggableWidgets/language-selector-web/src/components/LanguageSwitcher.tsx b/packages/pluggableWidgets/language-selector-web/src/components/LanguageSwitcher.tsx index c17c0a542f..4b52d33af7 100644 --- a/packages/pluggableWidgets/language-selector-web/src/components/LanguageSwitcher.tsx +++ b/packages/pluggableWidgets/language-selector-web/src/components/LanguageSwitcher.tsx @@ -1,159 +1,123 @@ +import { FloatingFocusManager } from "@floating-ui/react"; import classNames from "classnames"; -import { createElement, ReactElement, useEffect, useRef, CSSProperties } from "react"; -import { - isBehindElement, - isBehindRandomElement, - isElementPartiallyOffScreen, - isElementVisibleByUser, - moveAbsoluteElementOnScreen, - unBlockAbsoluteElementBottom, - unBlockAbsoluteElementLeft, - unBlockAbsoluteElementRight, - unBlockAbsoluteElementTop -} from "../utils/document"; +import { createElement, CSSProperties, ReactElement, useState } from "react"; import { PositionEnum, TriggerEnum } from "../../typings/LanguageSelectorProps"; +import { useFloatingUI } from "../hooks/useFloatingUI"; import { LanguageItem } from "../LanguageSelector"; -import { useSelect } from "downshift"; export interface LanguageSwitcherProps { + className: string; currentLanguage: LanguageItem | undefined; languageList: LanguageItem[]; - position: PositionEnum; onSelect?: (lang: LanguageItem) => void; - trigger: TriggerEnum; - className: string; + position: PositionEnum; + screenReaderLabelCaption?: string; style?: CSSProperties; tabIndex: number; - screenReaderLabelCaption?: string; + trigger: TriggerEnum; } -export const LanguageSwitcher = (props: LanguageSwitcherProps): ReactElement => { - const { languageList } = props; - const ref = useRef(null); - - function itemToString(item: LanguageItem): string { - return item ? item.value : ""; - } - const { isOpen, selectItem, highlightedIndex, getMenuProps, getItemProps, getToggleButtonProps } = useSelect({ - items: languageList, - itemToString, - onSelectedItemChange(changes) { - if (!props.onSelect || !changes.selectedItem || changes.selectedItem === props.currentLanguage) { - return; - } - props.onSelect(changes.selectedItem); - } - }); - useEffect(() => { - if (props.currentLanguage === undefined) { - return; - } - selectItem(props.currentLanguage); - }, [props.currentLanguage, selectItem]); +export const LanguageSwitcher = ({ + className, + currentLanguage, + languageList, + onSelect, + position, + screenReaderLabelCaption, + style, + tabIndex, + trigger +}: LanguageSwitcherProps): ReactElement => { + const [isOpen, setOpen] = useState(false); - useEffect(() => { - const element = ref.current?.querySelector(".popupmenu-menu") as HTMLDivElement | null; - if (element) { - element.style.display = isOpen ? "flex" : "none"; - if (isOpen) { - correctPosition(element, props.position); - } - } - }, [props.position, isOpen]); + const { + activeIndex, + context, + floatingStyles, + getFloatingProps, + getItemProps, + getReferenceProps, + handleSelect, + isTypingRef, + listRef, + refs, + selectedIndex + } = useFloatingUI({ + currentLanguage, + isOpen, + languageList, + onSelect, + position, + setOpen, + triggerOn: trigger + }); return ( -
+
- {props.currentLanguage?.value || ""} + {currentLanguage?.value || ""}
); }; - -function correctPosition(element: HTMLElement, position: PositionEnum): void { - const dynamicDocument: Document = element.ownerDocument; - const dynamicWindow = dynamicDocument.defaultView as Window; - let boundingRect: DOMRect = element.getBoundingClientRect(); - const isOffScreen = isElementPartiallyOffScreen(dynamicWindow, boundingRect); - if (isOffScreen) { - moveAbsoluteElementOnScreen(dynamicWindow, element, boundingRect); - } - - boundingRect = element.getBoundingClientRect(); - const blockingElement = isBehindRandomElement(dynamicDocument, element, boundingRect, 3, "popupmenu"); - if (blockingElement && isElementVisibleByUser(dynamicDocument, dynamicWindow, blockingElement)) { - unBlockAbsoluteElement(element, boundingRect, blockingElement.getBoundingClientRect(), position); - } else if (blockingElement) { - let node = blockingElement; - do { - if (isBehindElement(element, node, 3) && isElementVisibleByUser(dynamicDocument, dynamicWindow, node)) { - return unBlockAbsoluteElement(element, boundingRect, node.getBoundingClientRect(), position); - } else if (node.parentElement) { - node = node.parentElement as HTMLElement; - } else { - break; - } - } while (node.parentElement); - } -} - -function unBlockAbsoluteElement( - element: HTMLElement, - boundingRect: DOMRect, - blockingElementRect: DOMRect, - position: PositionEnum -): void { - switch (position) { - case "left": - unBlockAbsoluteElementLeft(element, boundingRect, blockingElementRect); - unBlockAbsoluteElementBottom(element, boundingRect, blockingElementRect); - break; - case "right": - unBlockAbsoluteElementRight(element, boundingRect, blockingElementRect); - unBlockAbsoluteElementBottom(element, boundingRect, blockingElementRect); - break; - case "top": - unBlockAbsoluteElementTop(element, boundingRect, blockingElementRect); - break; - case "bottom": - unBlockAbsoluteElementBottom(element, boundingRect, blockingElementRect); - break; - } -} diff --git a/packages/pluggableWidgets/language-selector-web/src/hooks/useFloatingUI.ts b/packages/pluggableWidgets/language-selector-web/src/hooks/useFloatingUI.ts new file mode 100644 index 0000000000..eee6aef5e8 --- /dev/null +++ b/packages/pluggableWidgets/language-selector-web/src/hooks/useFloatingUI.ts @@ -0,0 +1,130 @@ +import { + autoUpdate, + flip, + offset, + Placement, + ReferenceElement, + safePolygon, + useClick, + useDismiss, + useFloating, + UseFloatingReturn, + useHover, + useInteractions, + useListNavigation, + useRole, + useTypeahead +} from "@floating-ui/react"; +import { MutableRefObject, useRef, useState } from "react"; +import { TriggerEnum } from "../../typings/LanguageSelectorProps"; +import { LanguageItem } from "src/LanguageSelector"; + +interface FloatingProps { + currentLanguage?: LanguageItem; + isOpen: boolean; + languageList: LanguageItem[]; + onSelect?: (lang: LanguageItem) => void; + position: Placement; + setOpen: React.Dispatch>; + triggerOn: TriggerEnum; +} + +interface InternalFloatingProps { + activeIndex: number | null; + handleSelect: (index: number) => void; + isTypingRef: MutableRefObject; + listRef: MutableRefObject>; + selectedIndex: number | null; +} + +type FloatingReturn = Pick, "context" | "floatingStyles" | "refs">; +type UseInteractionsReturn = ReturnType; + +type FloatingPropsReturn = Partial & InternalFloatingProps & Partial; + +export function useFloatingUI({ + currentLanguage, + isOpen, + languageList, + onSelect, + position, + setOpen, + triggerOn +}: FloatingProps): FloatingPropsReturn { + const options = languageList.map(item => item.value); + const index = languageList.findIndex(item => item.value === currentLanguage?.value); + + const [activeIndex, setActiveIndex] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(index ?? null); + + const { context, floatingStyles, refs } = useFloating({ + onOpenChange: setOpen, + open: isOpen, + placement: position, + strategy: "fixed", + whileElementsMounted: autoUpdate, + middleware: [ + offset(2), + flip({ + fallbackPlacements: ["top", "right", "bottom", "left"] + }) + ] + }); + + const listRef = useRef>([]); + const listContentRef = useRef(options); + const isTypingRef = useRef(false); + + const hover = useHover(context, { + enabled: triggerOn === "hover", + move: false, + handleClose: safePolygon() + }); + const click = useClick(context, { enabled: triggerOn === "click", event: "mousedown" }); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex + }); + const typeahead = useTypeahead(context, { + listRef: listContentRef, + activeIndex, + selectedIndex, + onMatch: isOpen ? setActiveIndex : setSelectedIndex, + onTypingChange(isTyping) { + isTypingRef.current = isTyping; + } + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + dismiss, + role, + listNav, + typeahead, + click, + hover + ]); + + const handleSelect = (index: number): void => { + onSelect?.(languageList[index]); + setSelectedIndex(index); + setOpen(false); + }; + + return { + activeIndex, + context, + floatingStyles, + getFloatingProps, + getItemProps, + getReferenceProps, + handleSelect, + isTypingRef, + listRef, + refs, + selectedIndex + }; +} diff --git a/packages/pluggableWidgets/language-selector-web/src/ui/LanguageSelector.scss b/packages/pluggableWidgets/language-selector-web/src/ui/LanguageSelector.scss index 4baa1dd99c..29d7ac521f 100644 --- a/packages/pluggableWidgets/language-selector-web/src/ui/LanguageSelector.scss +++ b/packages/pluggableWidgets/language-selector-web/src/ui/LanguageSelector.scss @@ -42,6 +42,7 @@ $ls-brand-primary: #264ae5; } .popupmenu-menu { background: #fff; + display: flex; .popupmenu-basic-item { &.active { font-weight: 600; diff --git a/packages/pluggableWidgets/language-selector-web/src/utils/document.ts b/packages/pluggableWidgets/language-selector-web/src/utils/document.ts deleted file mode 100644 index 4c9b398115..0000000000 --- a/packages/pluggableWidgets/language-selector-web/src/utils/document.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { RefObject, useEffect } from "react"; - -function isElementBlockedTop(dynamicWindow: Window, srcRect: DOMRect, blockingRect: DOMRect): boolean { - return ( - srcRect.top < blockingRect.bottom && - srcRect.bottom >= blockingRect.bottom && - srcRect.y + srcRect.height < dynamicWindow.document.documentElement.clientHeight - ); -} - -function isElementBlockedBottom(srcRect: DOMRect, blockingRect: DOMRect): boolean { - return srcRect.bottom > blockingRect.top && srcRect.top <= blockingRect.top && srcRect.y - srcRect.height > 0; -} - -function isElementBlockedLeft(srcRect: DOMRect, blockingRect: DOMRect): boolean { - return ( - srcRect.left < blockingRect.right && srcRect.right >= blockingRect.right && srcRect.left >= blockingRect.left - ); -} - -function isElementBlockedRight(srcRect: DOMRect, blockingRect: DOMRect): boolean { - return ( - srcRect.right > blockingRect.left && srcRect.left <= blockingRect.left && srcRect.right <= blockingRect.right - ); -} - -export function unBlockAbsoluteElementTop( - element: HTMLElement, - boundingRect: DOMRect, - blockingElementRect: DOMRect -): void { - const dynamicWindow = element.ownerDocument.defaultView as Window; - if (isElementBlockedTop(dynamicWindow, boundingRect, blockingElementRect)) { - element.style.top = - getPixelValueAsNumber(element, "top") + blockingElementRect.bottom - boundingRect.top + "px"; - } -} - -export function unBlockAbsoluteElementBottom( - element: HTMLElement, - boundingRect: DOMRect, - blockingElementRect: DOMRect -): void { - if (isElementBlockedBottom(boundingRect, blockingElementRect)) { - element.style.top = "unset"; // Unset top defined in PopupMenu.scss - element.style.bottom = - getPixelValueAsNumber(element, "bottom") + blockingElementRect.top - boundingRect.bottom + "px"; - } -} - -export function unBlockAbsoluteElementLeft( - element: HTMLElement, - boundingRect: DOMRect, - blockingElementRect: DOMRect -): void { - if (isElementBlockedLeft(boundingRect, blockingElementRect)) { - element.style.left = - getPixelValueAsNumber(element, "left") + blockingElementRect.right - boundingRect.left + "px"; - } -} - -export function unBlockAbsoluteElementRight( - element: HTMLElement, - boundingRect: DOMRect, - blockingElementRect: DOMRect -): void { - if (isElementBlockedRight(boundingRect, blockingElementRect)) { - element.style.right = - getPixelValueAsNumber(element, "right") + blockingElementRect.left - boundingRect.right + "px"; - } -} - -export function getPixelValueAsNumber(element: HTMLElement, prop: keyof CSSStyleDeclaration): number { - const value = (getComputedStyle(element) as CSSStyleDeclaration)[prop] as string; - const num = Number(value.split("px")[0]); - return value ? num : 0; -} - -function isBehindRandomElementCheck( - element: HTMLElement, - blockingElement: HTMLElement, - excludeElements: HTMLElement[], - excludeElementWithClass: string -): boolean { - return ( - blockingElement && - blockingElement !== element && - !blockingElement.classList.contains(excludeElementWithClass) && - (!excludeElements || - !excludeElements.map((elem: HTMLElement) => elem.contains(blockingElement)).filter(elem => elem).length) && - !element.contains(blockingElement) - ); -} - -export function isBehindRandomElement( - dynamicDocument: Document, - element: HTMLElement, - boundingRect: DOMRect, - offset = 3, - excludeElementWithClass = "" -): HTMLElement | false { - let excludeElements: HTMLElement[] = []; - const left = Math.round(boundingRect.left + offset); - const right = Math.round(boundingRect.right - offset); - const top = Math.round(boundingRect.top + offset); - const bottom = Math.round(boundingRect.bottom - offset); - const elementTopLeft = dynamicDocument.elementFromPoint(left, top) as HTMLElement; - const elementTopRight = dynamicDocument.elementFromPoint(right, top) as HTMLElement; - const elementBottomLeft = dynamicDocument.elementFromPoint(left, bottom) as HTMLElement; - const elementBottomRight = dynamicDocument.elementFromPoint(right, bottom) as HTMLElement; - if (excludeElementWithClass) { - excludeElementWithClass = excludeElementWithClass.replace(/\./g, ""); - excludeElements = [...(dynamicDocument.querySelectorAll(`.${excludeElementWithClass}`) as any)]; - } - - if (isBehindRandomElementCheck(element, elementTopLeft, excludeElements, excludeElementWithClass)) { - return elementTopLeft; - } - if (isBehindRandomElementCheck(element, elementTopRight, excludeElements, excludeElementWithClass)) { - return elementTopRight; - } - if (isBehindRandomElementCheck(element, elementBottomLeft, excludeElements, excludeElementWithClass)) { - return elementBottomLeft; - } - if (isBehindRandomElementCheck(element, elementBottomRight, excludeElements, excludeElementWithClass)) { - return elementBottomRight; - } - - return false; -} - -export function isBehindElement(element: HTMLElement, blockingElement: HTMLElement, offset = 3): boolean { - const elementRect: DOMRect = element.getBoundingClientRect(); - const blockingElementRect: DOMRect = blockingElement.getBoundingClientRect(); - const left = elementRect.left + offset; - const right = elementRect.right - offset; - const top = elementRect.top + offset; - const bottom = elementRect.bottom - offset; - - return ( - (left < blockingElementRect.right && left > blockingElementRect.left) || - (right > blockingElementRect.left && right < blockingElementRect.right) || - (top < blockingElementRect.bottom && top > blockingElementRect.top) || - (bottom > blockingElementRect.top && bottom < blockingElementRect.bottom) - ); -} - -export function isElementVisibleByUser( - dynamicDocument: Document, - dynamicWindow: Window, - element: HTMLElement -): boolean { - const style: CSSStyleDeclaration = getComputedStyle(element); - if (style.display === "none") { - return false; - } - if (style.visibility && style.visibility !== "visible") { - return false; - } - if (style.opacity && Number(style.opacity) < 0.1) { - return false; - } - const rect = element.getBoundingClientRect(); - if (Math.round(element.offsetWidth + element.offsetHeight + rect.height + rect.width) === 0) { - return false; - } - const elementCenter = { - x: Math.round(rect.left + element.offsetWidth / 2), - y: Math.round(rect.top + element.offsetHeight / 2) - }; - if (!elementCenter.x || !elementCenter.y) { - return false; - } - if (elementCenter.x < 0) { - return false; - } - if ( - elementCenter.x > - (dynamicDocument.documentElement.clientWidth || dynamicWindow.document.documentElement.clientWidth) - ) { - return false; - } - if (elementCenter.y < 0) { - return false; - } - if ( - elementCenter.y > - (dynamicDocument.documentElement.clientHeight || dynamicWindow.document.documentElement.clientHeight) - ) { - return false; - } - let pointContainer: Element | null = dynamicDocument.elementFromPoint(elementCenter.x, elementCenter.y); - if (pointContainer) { - do { - if (pointContainer === element) { - return true; - } else { - pointContainer = pointContainer.parentElement as HTMLElement; - } - } while (pointContainer.parentElement); - } - return false; -} - -export function isElementPartiallyOffScreen(dynamicWindow: Window, rect: DOMRect): boolean { - return ( - rect.x < 0 || - rect.y < 0 || - rect.x + rect.width > dynamicWindow.document.documentElement.clientWidth || - rect.y + rect.height > dynamicWindow.document.documentElement.clientHeight - ); -} - -export function moveAbsoluteElementOnScreen( - dynamicWindow: Window, - element: HTMLElement, - boundingRect: DOMRect -): DOMRect { - if (boundingRect.x < 0) { - const leftValue = Math.round(getPixelValueAsNumber(element, "left") - boundingRect.x); - element.style.left = leftValue + "px"; - boundingRect.x += leftValue; - } - if (boundingRect.y < 0) { - const topValue = Math.round(getPixelValueAsNumber(element, "top") - boundingRect.y); - element.style.top = topValue + "px"; - boundingRect.y += topValue; - } - if (boundingRect.x + boundingRect.width > dynamicWindow.document.documentElement.clientWidth) { - const rightValue = Math.round( - getPixelValueAsNumber(element, "right") + - (boundingRect.x + boundingRect.width - dynamicWindow.document.documentElement.clientWidth) - ); - element.style.right = rightValue + "px"; - boundingRect.x -= rightValue; - } - if (boundingRect.y + boundingRect.height > dynamicWindow.document.documentElement.clientHeight) { - const bottomValue = Math.round( - getPixelValueAsNumber(element, "bottom") + - (boundingRect.y + boundingRect.height - dynamicWindow.document.documentElement.clientHeight) - ); - element.style.top = "unset"; // Unset top defined in PopupMenu.scss - element.style.bottom = bottomValue + "px"; - boundingRect.y -= bottomValue; - } - return boundingRect; -} - -export function handleOnClickOutsideElement(ref: RefObject, handler: () => void): void { - useEffect(() => { - const listener = (event: MouseEvent & { target: Node | null }): void => { - if (!ref.current || ref.current.contains(event.target)) { - return; - } - handler(); - }; - ref.current?.ownerDocument.addEventListener("mousedown", listener); - ref.current?.ownerDocument.addEventListener("touchstart", listener); - return () => { - ref.current?.ownerDocument.removeEventListener("mousedown", listener); - ref.current?.ownerDocument.removeEventListener("touchstart", listener); - }; - }, [ref, handler]); -} From 890d934e582212636011e5e124d7b90650bbb7e4 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 Nov 2024 13:30:15 +0100 Subject: [PATCH 3/9] test(language-selector): update unit tests --- .../language-selector-web/jest.config.js | 3 ++ .../language-selector-web/package.json | 2 +- .../__tests__/LanguageSwitcher.spec.tsx | 18 +++++++-- .../LanguageSwitcher.spec.tsx.snap | 40 ++++++++++++------- 4 files changed, 45 insertions(+), 18 deletions(-) create mode 100644 packages/pluggableWidgets/language-selector-web/jest.config.js diff --git a/packages/pluggableWidgets/language-selector-web/jest.config.js b/packages/pluggableWidgets/language-selector-web/jest.config.js new file mode 100644 index 0000000000..88999d5568 --- /dev/null +++ b/packages/pluggableWidgets/language-selector-web/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js") +}; diff --git a/packages/pluggableWidgets/language-selector-web/package.json b/packages/pluggableWidgets/language-selector-web/package.json index f3139081b8..d8cf27a513 100644 --- a/packages/pluggableWidgets/language-selector-web/package.json +++ b/packages/pluggableWidgets/language-selector-web/package.json @@ -31,7 +31,7 @@ "build": "pluggable-widgets-tools build:web", "format": "prettier --write .", "lint": "eslint --ext .jsx,.js,.ts,.tsx src/", - "test": "pluggable-widgets-tools test:unit:web", + "test": "jest --projects jest.config.js", "release": "pluggable-widgets-tools release:web", "create-gh-release": "rui-create-gh-release", "create-translation": "rui-create-translation", diff --git a/packages/pluggableWidgets/language-selector-web/src/components/__tests__/LanguageSwitcher.spec.tsx b/packages/pluggableWidgets/language-selector-web/src/components/__tests__/LanguageSwitcher.spec.tsx index c88f6db7ec..38063f1bb6 100644 --- a/packages/pluggableWidgets/language-selector-web/src/components/__tests__/LanguageSwitcher.spec.tsx +++ b/packages/pluggableWidgets/language-selector-web/src/components/__tests__/LanguageSwitcher.spec.tsx @@ -1,7 +1,11 @@ -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { createElement } from "react"; import { PositionEnum, TriggerEnum } from "typings/LanguageSelectorProps"; import { LanguageSwitcher, LanguageSwitcherProps } from "../LanguageSwitcher"; +import "@testing-library/jest-dom"; + +jest.useFakeTimers(); let props: LanguageSwitcherProps = { currentLanguage: undefined, @@ -15,14 +19,22 @@ let props: LanguageSwitcherProps = { const language = { _guid: "111", value: "En us" }; describe("Language switcher", () => { - it("renders the structure with empty language list", () => { + it("renders the structure with empty language list", async () => { const { asFragment } = render(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const triggerElement = screen.getByRole("combobox"); + + await user.click(triggerElement); expect(asFragment()).toMatchSnapshot(); }); - it("renders the structure with language list and selected default language", () => { + it("renders the structure with language list and selected default language", async () => { props = { ...props, languageList: [language], currentLanguage: language }; const { asFragment } = render(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const triggerElement = screen.getByRole("combobox"); + + await user.click(triggerElement); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/packages/pluggableWidgets/language-selector-web/src/components/__tests__/__snapshots__/LanguageSwitcher.spec.tsx.snap b/packages/pluggableWidgets/language-selector-web/src/components/__tests__/__snapshots__/LanguageSwitcher.spec.tsx.snap index d3b6c154a0..5f5d1ac138 100644 --- a/packages/pluggableWidgets/language-selector-web/src/components/__tests__/__snapshots__/LanguageSwitcher.spec.tsx.snap +++ b/packages/pluggableWidgets/language-selector-web/src/components/__tests__/__snapshots__/LanguageSwitcher.spec.tsx.snap @@ -7,12 +7,13 @@ exports[`Language switcher renders the structure with empty language list 1`] = >