Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions pages/popover/max-height.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { useContext, useState } from 'react';

import Popover from '~components/popover';

import AppContext, { AppContextType } from '../app/app-context';

const longContent =
'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus.';

type DemoContext = React.Context<
AppContextType<{
renderWithPortal: boolean;
maxHeight: string;
}>
>;

export default function () {
const { urlParams, setUrlParams } = useContext(AppContext as DemoContext);
const [maxHeightInput, setMaxHeightInput] = useState(urlParams.maxHeight || '200');

const maxHeight = maxHeightInput ? parseInt(maxHeightInput, 10) : undefined;

return (
<>
<h1>Popover maxHeight test</h1>

<div style={{ marginBlockEnd: 20 }}>
<label>
Max height (px):{' '}
<input
type="number"
value={maxHeightInput}
onChange={event => {
setMaxHeightInput(event.target.value);
setUrlParams({ maxHeight: event.target.value });
}}
style={{ width: 80 }}
/>
</label>
<span style={{ marginInlineStart: 20 }}>
<label>
Render with portal{' '}
<input
type="checkbox"
checked={urlParams.renderWithPortal || false}
onChange={event => setUrlParams({ renderWithPortal: event.target.checked })}
/>
</label>
</span>
</div>

<div style={{ display: 'flex', gap: 40, flexWrap: 'wrap' }}>
<div>
<h2>With maxHeight={maxHeight}</h2>
<Popover
size="medium"
header="Constrained height"
content={<div>{longContent}</div>}
dismissAriaLabel="Close"
renderWithPortal={urlParams.renderWithPortal}
maxHeight={maxHeight}
>
Click me (constrained)
</Popover>
</div>

<div>
<h2>Without maxHeight (default)</h2>
<Popover
size="medium"
header="Default height"
content={<div>{longContent}</div>}
dismissAriaLabel="Close"
renderWithPortal={urlParams.renderWithPortal}
>
Click me (default)
</Popover>
</div>

<div>
<h2>Short content with maxHeight</h2>
<Popover
size="medium"
header="Short content"
content={<div>This is short content that fits within the maxHeight.</div>}
dismissAriaLabel="Close"
renderWithPortal={urlParams.renderWithPortal}
maxHeight={maxHeight}
>
Click me (short content)
</Popover>
</div>
</div>

<div style={{ marginBlockStart: 40 }}>
<h2>Different positions with maxHeight={maxHeight}</h2>
<div
style={{
display: 'flex',
gap: 40,
flexWrap: 'wrap',
marginBlockStart: 20,
justifyContent: 'center',
padding: '100px 200px',
}}
>
{(['top', 'bottom', 'left', 'right'] as const).map(position => (
<Popover
key={position}
position={position}
size="medium"
header={`Position: ${position}`}
content={<div>{longContent}</div>}
dismissAriaLabel="Close"
renderWithPortal={urlParams.renderWithPortal}
maxHeight={maxHeight}
>
{position}
</Popover>
))}
</div>
</div>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18946,6 +18946,14 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
"optional": true,
"type": "string",
},
{
"description": "Specifies the maximum height of the popover body in pixels.
If the content exceeds this height, the popover body becomes scrollable.
By default, the popover extends to the edge of the viewport.",
"name": "maxHeight",
"optional": true,
"type": "number",
},
{
"defaultValue": "'right'",
"description": "Determines where the popover is displayed when opened, relative to the trigger.
Expand Down
40 changes: 40 additions & 0 deletions src/popover/__tests__/popover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class PopoverInternalWrapper extends PopoverWrapper {
}
return this.findByClassName(styles.body);
}
findContainerBody({ renderWithPortal } = { renderWithPortal: false }): ElementWrapper | null {
if (renderWithPortal) {
return createWrapper().findByClassName(styles['container-body']);
}
return this.findByClassName(styles['container-body']);
}
}

function renderPopover(props: PopoverProps & { ref?: React.Ref<PopoverProps.Ref> }) {
Expand Down Expand Up @@ -466,3 +472,37 @@ test('does not add portal to the body unless visible', () => {

expect(document.querySelectorAll('body > div')).toHaveLength(1);
});

describe('maxHeight prop', () => {
it('applies maxHeight style when maxHeight is specified', () => {
const wrapper = renderPopover({ children: 'Trigger', content: 'Content', maxHeight: 100 });
wrapper.findTrigger().click();
const body = wrapper.findContainerBody()!.getElement();
expect(body.style.overflowY).toBe('auto');
expect(body.style.overflowX).toBe('hidden');
});

it('works with portal rendering', () => {
const wrapper = renderPopover({ children: 'Trigger', content: 'Content', maxHeight: 100, renderWithPortal: true });
wrapper.findTrigger().click();
const body = wrapper.findContainerBody({ renderWithPortal: true })!.getElement();
expect(body.style.overflowY).toBe('auto');
expect(body.style.overflowX).toBe('hidden');
});

it('applies maxHeight when specified', () => {
const wrapper = renderPopover({ children: 'Trigger', content: 'Content', maxHeight: 50 });
wrapper.findTrigger().click();
const body = wrapper.findContainerBody()!.getElement();
expect(body.style.maxBlockSize).toBeTruthy();
expect(parseInt(body.style.maxBlockSize)).toBeLessThanOrEqual(50);
});

it('does not apply maxBlockSize when maxHeight is not specified', () => {
const wrapper = renderPopover({ children: 'Trigger', content: 'Content' });
wrapper.findTrigger().click();
const body = wrapper.findContainerBody()!.getElement();
const maxBlockSize = body.style.maxBlockSize;
expect(maxBlockSize === '' || !isNaN(parseInt(maxBlockSize))).toBe(true);
});
});
4 changes: 4 additions & 0 deletions src/popover/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ interface PopoverContainerProps {
hideOnOverscroll?: boolean;
hoverArea?: boolean;
className?: string;
// Maximum height of the popover body in pixels.
maxHeight?: number;
}

export default function PopoverContainer({
Expand All @@ -66,6 +68,7 @@ export default function PopoverContainer({
hideOnOverscroll,
hoverArea,
className,
maxHeight,
}: PopoverContainerProps) {
const bodyRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -99,6 +102,7 @@ export default function PopoverContainer({
keepPosition,
hideOnOverscroll,
minVisibleBlockSize,
maxHeight,
});

// Recalculate position when properties change.
Expand Down
4 changes: 3 additions & 1 deletion src/popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const Popover = React.forwardRef(
dismissButton = true,
renderWithPortal = false,
wrapTriggerText = true,
maxHeight,
header,
...rest
}: PopoverProps,
Expand All @@ -36,7 +37,7 @@ const Popover = React.forwardRef(
}

const baseComponentProps = useBaseComponent('Popover', {
props: { dismissButton, fixedWidth, position, renderWithPortal, size, triggerType },
props: { dismissButton, fixedWidth, maxHeight, position, renderWithPortal, size, triggerType },
});
const externalProps = getExternalProps(rest);

Expand All @@ -51,6 +52,7 @@ const Popover = React.forwardRef(
dismissButton={dismissButton}
renderWithPortal={renderWithPortal}
wrapTriggerText={wrapTriggerText}
maxHeight={maxHeight}
{...externalProps}
{...baseComponentProps}
/>
Expand Down
7 changes: 7 additions & 0 deletions src/popover/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export interface PopoverProps extends BaseComponentProps {
*/
size?: PopoverProps.Size;

/**
* Specifies the maximum height of the popover body in pixels.
* If the content exceeds this height, the popover body becomes scrollable.
* By default, the popover extends to the edge of the viewport.
*/
maxHeight?: number;

/**
* Expands the popover body to its maximum width regardless of content.
* For example, use it when you need to place a column layout in the popover content.
Expand Down
2 changes: 2 additions & 0 deletions src/popover/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function InternalPopover(
fixedWidth = false,
triggerType = 'text',
dismissButton = true,
maxHeight,

children,
header,
Expand Down Expand Up @@ -156,6 +157,7 @@ function InternalPopover(
arrow={position => <Arrow position={position} />}
renderWithPortal={renderWithPortal}
zIndex={renderWithPortal ? 7000 : undefined}
maxHeight={maxHeight}
>
<LinkDefaultVariantContext.Provider value={{ defaultVariant: 'primary' }}>
<PopoverBody
Expand Down
17 changes: 15 additions & 2 deletions src/popover/use-popover-position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function usePopoverPosition({
keepPosition,
hideOnOverscroll,
minVisibleBlockSize,
maxHeight,
}: {
popoverRef: React.RefObject<HTMLDivElement | null>;
bodyRef: React.RefObject<HTMLDivElement | null>;
Expand All @@ -41,6 +42,7 @@ export default function usePopoverPosition({
keepPosition?: boolean;
hideOnOverscroll?: boolean;
minVisibleBlockSize?: number;
maxHeight?: number;
}) {
const previousInternalPositionRef = useRef<InternalPosition | null>(null);
const [popoverStyle, setPopoverStyle] = useState<Partial<Offset>>({});
Expand Down Expand Up @@ -111,6 +113,13 @@ export default function usePopoverPosition({
blockSize: contentRect.blockSize + 2 * bodyBorderWidth,
};

// Apply maxHeight constraint to the bounding box used for position calculation
const constrainedBoundingBox = {
inlineSize: contentBoundingBox.inlineSize,
blockSize:
maxHeight !== undefined ? Math.min(contentBoundingBox.blockSize, maxHeight) : contentBoundingBox.blockSize,
};

// When keepPosition is true and the recalculation was triggered by a resize of the popover content,
// we maintain the previously defined internal position,
// but we still call calculatePosition to know if the popover should be scrollable.
Expand All @@ -127,7 +136,7 @@ export default function usePopoverPosition({
fixedInternalPosition,
trigger: trackRect,
arrow: arrowRect,
body: contentBoundingBox,
body: constrainedBoundingBox,
container: boundaryRect,
viewport: viewportRect,
renderWithPortal,
Expand All @@ -148,9 +157,12 @@ export default function usePopoverPosition({

// Allow popover body to scroll if can't fit the popover into the container/viewport otherwise.
if (scrollable) {
body.style.maxBlockSize = rect.blockSize + 'px';
const effectiveMaxHeight = maxHeight !== undefined ? maxHeight : rect.blockSize;
body.style.maxBlockSize = effectiveMaxHeight + 'px';
body.style.overflowX = 'hidden';
body.style.overflowY = 'auto';
} else if (maxHeight !== undefined) {
body.style.maxBlockSize = maxHeight + 'px';
}

// Remember the internal position in case we want to keep it later.
Expand Down Expand Up @@ -216,6 +228,7 @@ export default function usePopoverPosition({
allowScrollToFit,
hideOnOverscroll,
minVisibleBlockSize,
maxHeight,
]
);
return { updatePositionHandler, popoverStyle, internalPosition, positionHandlerRef, isOverscrolling };
Expand Down