Skip to content

Commit

Permalink
fix: Prevent dropdown misplacement in iOS (#3202)
Browse files Browse the repository at this point in the history
  • Loading branch information
jperals authored Jan 23, 2025
1 parent 13ff9d4 commit 3839ca0
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -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');
});
});
5 changes: 3 additions & 2 deletions src/internal/components/dropdown/dropdown-fit-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -393,6 +394,6 @@ export const calculatePosition = (
isMobile,
stretchBeyondTriggerWidth,
});
const triggerBox = triggerElement.getBoundingClientRect();
const triggerBox = getLogicalBoundingClientRect(triggerElement);
return [position, triggerBox];
};
49 changes: 49 additions & 0 deletions src/internal/components/dropdown/dropdown-position.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
}
44 changes: 16 additions & 28 deletions src/internal/components/dropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -196,7 +197,7 @@ const Dropdown = ({

const setDropdownPosition = (
position: DropdownPosition | InteriorDropdownPosition,
triggerBox: DOMRect,
triggerBox: LogicalDOMRect,
target: HTMLDivElement,
verticalContainer: HTMLDivElement
) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
}
};

Expand All @@ -416,7 +404,7 @@ const Dropdown = ({
return () => {
controller.abort();
};
}, [open, expandToViewport]);
}, [open, expandToViewport, isMobile]);

const referrerId = useUniqueId();

Expand Down

0 comments on commit 3839ca0

Please sign in to comment.