diff --git a/pages/content-layout/secondary-header-breakpoint.page.tsx b/pages/content-layout/secondary-header-breakpoint.page.tsx
new file mode 100644
index 0000000000..e8ff113d9a
--- /dev/null
+++ b/pages/content-layout/secondary-header-breakpoint.page.tsx
@@ -0,0 +1,84 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useState } from 'react';
+
+import Box from '~components/box';
+import Button from '~components/button';
+import ContentLayout, { ContentLayoutProps } from '~components/content-layout';
+import FormField from '~components/form-field';
+import Header from '~components/header';
+import Select from '~components/select';
+import SpaceBetween from '~components/space-between';
+
+import { Containers } from '../app-layout/utils/content-blocks';
+import ScreenshotArea from '../utils/screenshot-area';
+
+const breakpointOptions: ReadonlyArray<{ value: ContentLayoutProps.SecondaryHeaderBreakpoint }> = [
+ { value: 'xxs' },
+ { value: 'xs' },
+ { value: 's' },
+ { value: 'm' },
+ { value: 'l' },
+ { value: 'xl' },
+];
+
+function QuickLaunch() {
+ return (
+
+
+
+ Quick launch
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function () {
+ const [breakpoint, setBreakpoint] = useState('xs');
+
+ return (
+
+
+
+
+
+
+
+
+
+ }
+ secondaryHeader={
+
+
+
+ }
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index b54c1b56ab..d69d61d009 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -10638,6 +10638,33 @@ If not set, all elements will occupy the full available width.",
"optional": true,
"type": "number",
},
+ {
+ "description": "Determines the [breakpoint](/foundation/visual-foundation/responsive-design/) at and above which the \`secondaryHeader\`
+slot is displayed side-by-side with the \`header\` slot. Below this breakpoint, the secondary header stacks underneath
+the header and occupies the full available width.
+
+One of \`xxs\`, \`xs\`, \`s\`, \`m\`, \`l\`, or \`xl\`. Defaults to \`xs\`.
+
+Lower this value (for example, to \`xxs\`) to keep the header and secondary header side-by-side on narrow viewports,
+or raise it (for example, to \`s\` or \`m\`) to make them stack on wider viewports.
+
+This property has no effect unless both the \`header\` and \`secondaryHeader\` slots are set.",
+ "inlineType": {
+ "name": "ContentLayoutProps.SecondaryHeaderBreakpoint",
+ "type": "union",
+ "values": [
+ "s",
+ "m",
+ "xxs",
+ "xs",
+ "l",
+ "xl",
+ ],
+ },
+ "name": "secondaryHeaderBreakpoint",
+ "optional": true,
+ "type": "string",
+ },
],
"regions": [
{
diff --git a/src/content-layout/__tests__/content-layout.test.tsx b/src/content-layout/__tests__/content-layout.test.tsx
index 60a4fed801..16cb720e7c 100644
--- a/src/content-layout/__tests__/content-layout.test.tsx
+++ b/src/content-layout/__tests__/content-layout.test.tsx
@@ -4,6 +4,10 @@ import React from 'react';
import { render } from '@testing-library/react';
import ContentLayout, { ContentLayoutProps } from '../../../lib/components/content-layout';
+import {
+ defaultSecondaryHeaderBreakpoint,
+ getSecondaryHeaderGridDefinition,
+} from '../../../lib/components/content-layout/internal';
import customCssProps from '../../../lib/components/internal/generated/custom-css-properties';
import { useVisualRefresh } from '../../../lib/components/internal/hooks/use-visual-mode';
import { highContrastHeaderClassName } from '../../../lib/components/internal/utils/content-header-utils';
@@ -64,6 +68,17 @@ function renderContentLayout(props: ContentLayoutProps = {}) {
expect(wrapper.findSecondaryHeader()!.getElement()).toHaveTextContent('Secondary text');
});
+ test('renders both header slots when secondaryHeaderBreakpoint is set', () => {
+ const { wrapper } = renderContentLayout({
+ header: <>Header text>,
+ secondaryHeader: <>Secondary text>,
+ secondaryHeaderBreakpoint: 'm',
+ });
+
+ expect(wrapper.findHeader()!.getElement()).toHaveTextContent('Header text');
+ expect(wrapper.findSecondaryHeader()!.getElement()).toHaveTextContent('Secondary text');
+ });
+
test('does not render the secondaryHeader slot if the header slot is not present', () => {
const { wrapper } = renderContentLayout({
secondaryHeader: <>Secondary text>,
@@ -282,3 +297,32 @@ function renderContentLayout(props: ContentLayoutProps = {}) {
});
});
});
+
+describe('secondaryHeaderBreakpoint', () => {
+ test('defaults to the "xs" breakpoint', () => {
+ expect(defaultSecondaryHeaderBreakpoint).toBe('xs');
+ });
+
+ test('getSecondaryHeaderGridDefinition keeps the 9/3 split and full-width stacking below the breakpoint (default xs)', () => {
+ expect(getSecondaryHeaderGridDefinition(defaultSecondaryHeaderBreakpoint)).toEqual([
+ { colspan: { default: 12, xs: 9 } },
+ { colspan: { default: 12, xs: 3 } },
+ ]);
+ });
+
+ test.each(['xxs', 'xs', 's', 'm', 'l', 'xl'] as const)(
+ 'getSecondaryHeaderGridDefinition applies the side-by-side split at the "%s" breakpoint',
+ breakpoint => {
+ expect(getSecondaryHeaderGridDefinition(breakpoint)).toEqual([
+ { colspan: { default: 12, [breakpoint]: 9 } },
+ { colspan: { default: 12, [breakpoint]: 3 } },
+ ]);
+ }
+ );
+
+ test('lowering the breakpoint to "xxs" keeps the slots side-by-side on narrow viewports', () => {
+ const [headerColumn, secondaryColumn] = getSecondaryHeaderGridDefinition('xxs');
+ expect(headerColumn.colspan).toEqual({ default: 12, xxs: 9 });
+ expect(secondaryColumn.colspan).toEqual({ default: 12, xxs: 3 });
+ });
+});
diff --git a/src/content-layout/interfaces.ts b/src/content-layout/interfaces.ts
index a48780bbad..4b717617eb 100644
--- a/src/content-layout/interfaces.ts
+++ b/src/content-layout/interfaces.ts
@@ -79,4 +79,22 @@ export interface ContentLayoutProps extends BaseComponentProps {
* Note that the secondary header will not have a high-contrast treatement, even if you set `headerVariant` to `high-contrast`.
*/
secondaryHeader?: React.ReactNode;
+
+ /**
+ * Determines the [breakpoint](/foundation/visual-foundation/responsive-design/) at and above which the `secondaryHeader`
+ * slot is displayed side-by-side with the `header` slot. Below this breakpoint, the secondary header stacks underneath
+ * the header and occupies the full available width.
+ *
+ * One of `xxs`, `xs`, `s`, `m`, `l`, or `xl`. Defaults to `xs`.
+ *
+ * Lower this value (for example, to `xxs`) to keep the header and secondary header side-by-side on narrow viewports,
+ * or raise it (for example, to `s` or `m`) to make them stack on wider viewports.
+ *
+ * This property has no effect unless both the `header` and `secondaryHeader` slots are set.
+ */
+ secondaryHeaderBreakpoint?: ContentLayoutProps.SecondaryHeaderBreakpoint;
+}
+
+export namespace ContentLayoutProps {
+ export type SecondaryHeaderBreakpoint = 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl';
}
diff --git a/src/content-layout/internal.tsx b/src/content-layout/internal.tsx
index a021bb862a..45b265f4ea 100644
--- a/src/content-layout/internal.tsx
+++ b/src/content-layout/internal.tsx
@@ -5,6 +5,7 @@ import clsx from 'clsx';
import { useCurrentMode, useMergeRefs } from '@cloudscape-design/component-toolkit/internal';
+import { GridProps } from '../grid/interfaces';
import InternalGrid from '../grid/internal';
import { getBaseProps } from '../internal/base-component';
import customCssProps from '../internal/generated/custom-css-properties';
@@ -23,6 +24,20 @@ const halfGeckoMaxCssLength = ((1 << 30) - 1) / 120;
// CSS lengths in Gecko are limited to at most (1<<30)-1 app units (Gecko uses 60 as app unit).
// Limit the maxContentWidth to the half of the upper boundary (≈4230^2) to be on the safe side.
+export const defaultSecondaryHeaderBreakpoint: ContentLayoutProps.SecondaryHeaderBreakpoint = 'xs';
+
+/**
+ * Builds the grid definition for the header / secondary header split.
+ *
+ * The header spans 9 columns and the secondary header 3 columns (25%) at and above the provided
+ * breakpoint. Below the breakpoint both slots stack and occupy the full 12 columns.
+ */
+export function getSecondaryHeaderGridDefinition(
+ breakpoint: ContentLayoutProps.SecondaryHeaderBreakpoint
+): ReadonlyArray {
+ return [{ colspan: { default: 12, [breakpoint]: 9 } }, { colspan: { default: 12, [breakpoint]: 3 } }];
+}
+
export default function InternalContentLayout({
children,
disableOverlap,
@@ -35,6 +50,7 @@ export default function InternalContentLayout({
notifications,
defaultPadding,
secondaryHeader,
+ secondaryHeaderBreakpoint = defaultSecondaryHeaderBreakpoint,
...rest
}: InternalContentLayoutProps) {
const mainRef = useRef(null);
@@ -111,7 +127,7 @@ export default function InternalContentLayout({
[styles['with-divider']]: headerVariant === 'divider',
})}
>
-
+
{header}
{secondaryHeader}