Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Prevent dropdown misplacement in iOS #3202

Merged
merged 11 commits into from
Jan 23, 2025
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();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a couple cases (here and 3 tests further down) I'm testing just that a value is set (truthy), instead of the actual value. This is because the value is a CSS expression using calc(), and I don't think it makes sense to be so specific about the implementation. We already have visual regression tests for the expected user-facing end result, which is what matters.

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 is 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
Loading