diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 01fbd65f7e..b24be0c032 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -17520,6 +17520,28 @@ render to an element under \`document.body\`.", "optional": true, "type": "HTMLElement", }, + { + "defaultValue": "'center'", + "description": "Controls the vertical positioning of the modal. + +- \`center\` (default) - Modal is vertically centered in viewport and re-centers + when content height changes. Use for dialogs with static, predictable content. + +- \`top\` - Modal anchors at fixed distance and grows downward + as content expands. Use when content changes dynamically to prevent disruptive + vertical repositioning that causes users to lose focus.", + "inlineType": { + "name": "ModalProps.Position", + "type": "union", + "values": [ + "center", + "top", + ], + }, + "name": "position", + "optional": true, + "type": "string", + }, { "description": "Use this property when \`getModalRoot\` is used to clean up the modal root element after a user closes the dialog. The function receives the return value @@ -17542,8 +17564,8 @@ of the most recent getModalRoot call as an argument.", { "defaultValue": "'medium'", "description": "Sets the width of the modal. \`max\` uses variable width up to the -largest size allowed by the design guidelines. Other sizes -(\`small\`/\`medium\`/\`large\`) have fixed widths.", +largest size allowed by the design guidelines. Other sizes: +\`small\` (320px), \`medium\` (600px), \`large\` (820px), \`x-large\` (1024px), \`xx-large\` (1280px).", "inlineType": { "name": "ModalProps.Size", "type": "union", @@ -17552,6 +17574,8 @@ largest size allowed by the design guidelines. Other sizes "max", "medium", "large", + "x-large", + "xx-large", ], }, "name": "size", diff --git a/src/modal/__tests__/modal.test.tsx b/src/modal/__tests__/modal.test.tsx index 94d43364e1..a185397d0c 100644 --- a/src/modal/__tests__/modal.test.tsx +++ b/src/modal/__tests__/modal.test.tsx @@ -112,13 +112,22 @@ describe('Modal component', () => { describe('size property', () => { it('displays correct size', () => { - (['small', 'medium', 'large', 'max'] as ModalProps.Size[]).forEach(size => { + (['small', 'medium', 'large', 'x-large', 'xx-large', 'max'] as ModalProps.Size[]).forEach(size => { const wrapper = renderModal({ size }); expect(wrapper.findDialog().getElement()).toHaveClass(styles[size]); }); }); }); + describe('position property', () => { + it('displays correct position', () => { + (['center', 'top'] as ModalProps.Position[]).forEach(position => { + const wrapper = renderModal({ position }); + expect(wrapper.findFocusLock().getElement()).toHaveClass(styles[`position-${position}`]); + }); + }); + }); + describe('dismiss on click', () => { it('closes the dialog when clicked on the overlay section of the container', () => { const onDismissSpy = jest.fn(); diff --git a/src/modal/index.tsx b/src/modal/index.tsx index c2b6982958..4195ce68c8 100644 --- a/src/modal/index.tsx +++ b/src/modal/index.tsx @@ -70,7 +70,7 @@ function ModalWithAnalyticsFunnel({ ); } -export default function Modal({ size = 'medium', ...props }: ModalProps) { +export default function Modal({ size = 'medium', position = 'center', ...props }: ModalProps) { const { isInFunnel } = useFunnel(); const analyticsMetadata = getAnalyticsMetadataProps(props as BasePropsWithAnalyticsMetadata); const baseComponentProps = useBaseComponent( @@ -78,6 +78,7 @@ export default function Modal({ size = 'medium', ...props }: ModalProps) { { props: { size, + position, disableContentPaddings: props.disableContentPaddings, flowType: analyticsMetadata.flowType, }, @@ -95,12 +96,21 @@ export default function Modal({ size = 'medium', ...props }: ModalProps) { analyticsMetadata={analyticsMetadata} baseComponentProps={baseComponentProps} size={size} + position={position} {...props} /> ); } - return ; + return ( + + ); } applyDisplayName(Modal, 'Modal'); diff --git a/src/modal/interfaces.ts b/src/modal/interfaces.ts index f13a4b90c3..99ff019aaf 100644 --- a/src/modal/interfaces.ts +++ b/src/modal/interfaces.ts @@ -26,10 +26,21 @@ export interface BaseModalProps { export interface ModalProps extends BaseComponentProps, BaseModalProps { /** * Sets the width of the modal. `max` uses variable width up to the - * largest size allowed by the design guidelines. Other sizes - * (`small`/`medium`/`large`) have fixed widths. + * largest size allowed by the design guidelines. Other sizes: + * `small` (320px), `medium` (600px), `large` (820px), `x-large` (1024px), `xx-large` (1280px). */ size?: ModalProps.Size; + /** + * Controls the vertical positioning of the modal. + * + * - `center` (default) - Modal is vertically centered in viewport and re-centers + * when content height changes. Use for dialogs with static, predictable content. + * + * - `top` - Modal anchors at fixed distance and grows downward + * as content expands. Use when content changes dynamically to prevent disruptive + * vertical repositioning that causes users to lose focus. + */ + position?: ModalProps.Position; /** * Determines whether the modal is displayed on the screen. Modals are hidden by default. * Set this property to `true` to show them. @@ -81,7 +92,8 @@ export interface ModalProps extends BaseComponentProps, BaseModalProps { } export namespace ModalProps { - export type Size = 'small' | 'medium' | 'large' | 'max'; + export type Size = 'small' | 'medium' | 'large' | 'x-large' | 'xx-large' | 'max'; + export type Position = 'center' | 'top'; export interface DismissDetail { reason: string; diff --git a/src/modal/internal.tsx b/src/modal/internal.tsx index 79e9e88c31..be6b41fa89 100644 --- a/src/modal/internal.tsx +++ b/src/modal/internal.tsx @@ -94,6 +94,7 @@ function PortaledModal({ children, footer, disableContentPaddings, + position = 'center', onButtonClick = () => {}, onDismiss, __internalRootRef, @@ -108,7 +109,7 @@ function PortaledModal({ const instanceUniqueId = useUniqueId(); const headerId = `${rest.id || instanceUniqueId}-header`; const lastMouseDownElementRef = useRef(null); - const [breakpoint, breakpointsRef] = useContainerBreakpoints(['xs']); + const [breakpoint, breakpointsRef] = useContainerBreakpoints(['l']); const i18n = useInternalI18n('modal'); const closeAriaLabel = i18n('closeAriaLabel', rest.closeAriaLabel); @@ -247,7 +248,12 @@ function PortaledModal({ style={footerHeight ? { scrollPaddingBottom: footerHeight } : undefined} data-awsui-referrer-id={subStepRef.current?.id || referrerId} > - +