diff --git a/src/internal/components/dropdown/__tests__/dropdown-position.test.ts b/src/internal/components/dropdown/__tests__/dropdown-position.test.ts new file mode 100644 index 0000000000..897d918723 --- /dev/null +++ b/src/internal/components/dropdown/__tests__/dropdown-position.test.ts @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { applyDropdownPositionRelativeToViewport } from '../../../../../lib/components/internal/components/dropdown/dropdown-position'; + +describe('applyDropdownPositionRelativeToViewport', () => { + const triggerRect = { + blockSize: 50, + inlineSize: 100, + insetBlockStart: 100, + insetInlineStart: 100, + insetBlockEnd: 150, + insetInlineEnd: 200, + }; + + const baseDropdownPosition = { + blockSize: '100px', + inlineSize: '100px', + insetInlineStart: '100px', + dropBlockStart: false, + dropInlineStart: false, + }; + + test("sets block end when the dropdown is anchored to the trigger's block start (expands up)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: { ...baseDropdownPosition, dropBlockStart: true }, + isMobile: false, + }); + expect(dropdownElement.style.insetBlockEnd).toBeTruthy(); + expect(dropdownElement.style.insetBlockStart).toBeFalsy(); + }); + + test("aligns block start with the trigger's block end when the dropdown is anchored to the trigger's block end (expands down)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.insetBlockEnd).toBeFalsy(); + expect(dropdownElement.style.insetBlockStart).toEqual(`${triggerRect.insetBlockEnd}px`); + }); + + test("aligns inline start with the trigger's inline start when the dropdown is anchored to the trigger's inline start (anchored from the left in LTR)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.insetInlineStart).toEqual(`${triggerRect.insetInlineStart}px`); + }); + + test("sets inline end when the dropdown is anchored to the trigger's inline start (anchored from the right in LTR)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: { ...baseDropdownPosition, dropInlineStart: true }, + isMobile: false, + }); + expect(dropdownElement.style.insetInlineStart).toBeTruthy(); + }); + + test('uses fixed position on desktop', () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.position).toEqual('fixed'); + }); + + test('uses absolute position on mobile', () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: true, + }); + expect(dropdownElement.style.position).toEqual('absolute'); + }); +}); diff --git a/src/internal/components/dropdown/dropdown-fit-handler.ts b/src/internal/components/dropdown/dropdown-fit-handler.ts index d856fd7cc1..0144763e18 100644 --- a/src/internal/components/dropdown/dropdown-fit-handler.ts +++ b/src/internal/components/dropdown/dropdown-fit-handler.ts @@ -4,6 +4,7 @@ import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolk import { getBreakpointValue } from '../../breakpoints'; import { BoundingBox, getOverflowParentDimensions, getOverflowParents } from '../../utils/scrollable-containers'; +import { LogicalDOMRect } from './dropdown-position'; import styles from './styles.css.js'; @@ -361,7 +362,7 @@ export const calculatePosition = ( isMobile: boolean, minWidth?: number, stretchBeyondTriggerWidth?: boolean -): [DropdownPosition, DOMRect] => { +): [DropdownPosition, LogicalDOMRect] => { // cleaning previously assigned values, // so that they are not reused in case of screen resize and similar events verticalContainerElement.style.maxBlockSize = ''; @@ -393,6 +394,6 @@ export const calculatePosition = ( isMobile, stretchBeyondTriggerWidth, }); - const triggerBox = triggerElement.getBoundingClientRect(); + const triggerBox = getLogicalBoundingClientRect(triggerElement); return [position, triggerBox]; }; diff --git a/src/internal/components/dropdown/dropdown-position.ts b/src/internal/components/dropdown/dropdown-position.ts new file mode 100644 index 0000000000..3ad3aaf2c0 --- /dev/null +++ b/src/internal/components/dropdown/dropdown-position.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DropdownPosition } from './dropdown-fit-handler'; + +export interface LogicalDOMRect { + blockSize: number; + inlineSize: number; + insetBlockStart: number; + insetBlockEnd: number; + insetInlineStart: number; + insetInlineEnd: number; +} + +// Applies its position to the dropdown element when expandToViewport is set to true. +export function applyDropdownPositionRelativeToViewport({ + position, + dropdownElement, + triggerRect, + isMobile, +}: { + position: DropdownPosition; + dropdownElement: HTMLElement; + triggerRect: LogicalDOMRect; + isMobile: boolean; +}) { + // Fixed positions are not respected in iOS when the virtual keyboard is being displayed. + // For this reason we use absolute positioning in mobile. + const useAbsolutePositioning = isMobile; + + // Since when using expandToViewport=true the dropdown is attached to the root of the body, + // the same coordinates can be used for fixed or absolute position, + // except when using absolute position we need to take into account the scroll position of the body itself. + const verticalScrollOffset = useAbsolutePositioning ? document.documentElement.scrollTop : 0; + const horizontalScrollOffset = useAbsolutePositioning ? document.documentElement.scrollLeft : 0; + + dropdownElement.style.position = useAbsolutePositioning ? 'absolute' : 'fixed'; + + if (position.dropBlockStart) { + dropdownElement.style.insetBlockEnd = `calc(100% - ${verticalScrollOffset + triggerRect.insetBlockStart}px)`; + } else { + dropdownElement.style.insetBlockStart = `${verticalScrollOffset + triggerRect.insetBlockEnd}px`; + } + if (position.dropInlineStart) { + dropdownElement.style.insetInlineStart = `calc(${horizontalScrollOffset + triggerRect.insetInlineEnd}px - ${position.inlineSize})`; + } else { + dropdownElement.style.insetInlineStart = `${horizontalScrollOffset + triggerRect.insetInlineStart}px`; + } +} diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index 9a449914fd..124a84d054 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -26,6 +26,7 @@ import { hasEnoughSpaceToStretchBeyondTriggerWidth, InteriorDropdownPosition, } from './dropdown-fit-handler'; +import { applyDropdownPositionRelativeToViewport, LogicalDOMRect } from './dropdown-position'; import { DropdownProps } from './interfaces'; import styles from './styles.css.js'; @@ -196,7 +197,7 @@ const Dropdown = ({ const setDropdownPosition = ( position: DropdownPosition | InteriorDropdownPosition, - triggerBox: DOMRect, + triggerBox: LogicalDOMRect, target: HTMLDivElement, verticalContainer: HTMLDivElement ) => { @@ -233,17 +234,12 @@ const Dropdown = ({ // Position normal overflow dropdowns with fixed positioning relative to viewport if (expandToViewport && !interior) { - target.style.position = 'fixed'; - if (position.dropBlockStart) { - target.style.insetBlockEnd = `calc(100% - ${triggerBox.top}px)`; - } else { - target.style.insetBlockStart = `${triggerBox.bottom}px`; - } - if (position.dropInlineStart) { - target.style.insetInlineStart = `calc(${triggerBox.right}px - ${position.inlineSize})`; - } else { - target.style.insetInlineStart = `${triggerBox.left}px`; - } + applyDropdownPositionRelativeToViewport({ + position, + dropdownElement: target, + triggerRect: triggerBox, + isMobile, + }); // Keep track of the initial dropdown position and direction. // Dropdown direction doesn't need to change as the user scrolls, just needs to stay attached to the trigger. fixedPosition.current = position; @@ -390,21 +386,13 @@ const Dropdown = ({ return; } const updateDropdownPosition = () => { - if (triggerRef.current && dropdownRef.current && verticalContainerRef.current) { - const triggerRect = getLogicalBoundingClientRect(triggerRef.current); - const target = dropdownRef.current; - if (fixedPosition.current) { - if (fixedPosition.current.dropBlockStart) { - dropdownRef.current.style.insetBlockEnd = `calc(100% - ${triggerRect.insetBlockStart}px)`; - } else { - target.style.insetBlockStart = `${triggerRect.insetBlockEnd}px`; - } - if (fixedPosition.current.dropInlineStart) { - target.style.insetInlineStart = `calc(${triggerRect.insetInlineEnd}px - ${fixedPosition.current.inlineSize})`; - } else { - target.style.insetInlineStart = `${triggerRect.insetInlineStart}px`; - } - } + if (triggerRef.current && dropdownRef.current && verticalContainerRef.current && fixedPosition.current) { + applyDropdownPositionRelativeToViewport({ + position: fixedPosition.current, + dropdownElement: dropdownRef.current, + triggerRect: getLogicalBoundingClientRect(triggerRef.current), + isMobile, + }); } }; @@ -416,7 +404,7 @@ const Dropdown = ({ return () => { controller.abort(); }; - }, [open, expandToViewport]); + }, [open, expandToViewport, isMobile]); const referrerId = useUniqueId();