diff --git a/pages/popover/max-height.page.tsx b/pages/popover/max-height.page.tsx
new file mode 100644
index 0000000000..f775ae2562
--- /dev/null
+++ b/pages/popover/max-height.page.tsx
@@ -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 (
+ <>
+
Popover maxHeight test
+
+
+
+
+
+
+
+
+
+
+
With maxHeight={maxHeight}
+
{longContent}}
+ dismissAriaLabel="Close"
+ renderWithPortal={urlParams.renderWithPortal}
+ maxHeight={maxHeight}
+ >
+ Click me (constrained)
+
+
+
+
+
Without maxHeight (default)
+
{longContent} }
+ dismissAriaLabel="Close"
+ renderWithPortal={urlParams.renderWithPortal}
+ >
+ Click me (default)
+
+
+
+
+
Short content with maxHeight
+
This is short content that fits within the maxHeight. }
+ dismissAriaLabel="Close"
+ renderWithPortal={urlParams.renderWithPortal}
+ maxHeight={maxHeight}
+ >
+ Click me (short content)
+
+
+
+
+
+
Different positions with maxHeight={maxHeight}
+
+ {(['top', 'bottom', 'left', 'right'] as const).map(position => (
+
{longContent}}
+ dismissAriaLabel="Close"
+ renderWithPortal={urlParams.renderWithPortal}
+ maxHeight={maxHeight}
+ >
+ {position}
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index ff2491504c..b6a6745dff 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -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.
diff --git a/src/popover/__tests__/popover.test.tsx b/src/popover/__tests__/popover.test.tsx
index daf786f83f..8e213b5bc0 100644
--- a/src/popover/__tests__/popover.test.tsx
+++ b/src/popover/__tests__/popover.test.tsx
@@ -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 }) {
@@ -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);
+ });
+});
diff --git a/src/popover/container.tsx b/src/popover/container.tsx
index 92696def9c..7031b3654e 100644
--- a/src/popover/container.tsx
+++ b/src/popover/container.tsx
@@ -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({
@@ -66,6 +68,7 @@ export default function PopoverContainer({
hideOnOverscroll,
hoverArea,
className,
+ maxHeight,
}: PopoverContainerProps) {
const bodyRef = useRef(null);
const contentRef = useRef(null);
@@ -99,6 +102,7 @@ export default function PopoverContainer({
keepPosition,
hideOnOverscroll,
minVisibleBlockSize,
+ maxHeight,
});
// Recalculate position when properties change.
diff --git a/src/popover/index.tsx b/src/popover/index.tsx
index 71ebaa17b4..de04b66c3d 100644
--- a/src/popover/index.tsx
+++ b/src/popover/index.tsx
@@ -24,6 +24,7 @@ const Popover = React.forwardRef(
dismissButton = true,
renderWithPortal = false,
wrapTriggerText = true,
+ maxHeight,
header,
...rest
}: PopoverProps,
@@ -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);
@@ -51,6 +52,7 @@ const Popover = React.forwardRef(
dismissButton={dismissButton}
renderWithPortal={renderWithPortal}
wrapTriggerText={wrapTriggerText}
+ maxHeight={maxHeight}
{...externalProps}
{...baseComponentProps}
/>
diff --git a/src/popover/interfaces.ts b/src/popover/interfaces.ts
index 63f8d9a3da..b70b6df237 100644
--- a/src/popover/interfaces.ts
+++ b/src/popover/interfaces.ts
@@ -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.
diff --git a/src/popover/internal.tsx b/src/popover/internal.tsx
index 0c866d995e..0552d11b66 100644
--- a/src/popover/internal.tsx
+++ b/src/popover/internal.tsx
@@ -40,6 +40,7 @@ function InternalPopover(
fixedWidth = false,
triggerType = 'text',
dismissButton = true,
+ maxHeight,
children,
header,
@@ -156,6 +157,7 @@ function InternalPopover(
arrow={position => }
renderWithPortal={renderWithPortal}
zIndex={renderWithPortal ? 7000 : undefined}
+ maxHeight={maxHeight}
>
;
bodyRef: React.RefObject;
@@ -41,6 +42,7 @@ export default function usePopoverPosition({
keepPosition?: boolean;
hideOnOverscroll?: boolean;
minVisibleBlockSize?: number;
+ maxHeight?: number;
}) {
const previousInternalPositionRef = useRef(null);
const [popoverStyle, setPopoverStyle] = useState>({});
@@ -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.
@@ -127,7 +136,7 @@ export default function usePopoverPosition({
fixedInternalPosition,
trigger: trackRect,
arrow: arrowRect,
- body: contentBoundingBox,
+ body: constrainedBoundingBox,
container: boundaryRect,
viewport: viewportRect,
renderWithPortal,
@@ -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.
@@ -216,6 +228,7 @@ export default function usePopoverPosition({
allowScrollToFit,
hideOnOverscroll,
minVisibleBlockSize,
+ maxHeight,
]
);
return { updatePositionHandler, popoverStyle, internalPosition, positionHandlerRef, isOverscrolling };