Skip to content
Draft
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
84 changes: 84 additions & 0 deletions pages/content-layout/secondary-header-breakpoint.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box padding="m">
<SpaceBetween size="s">
<Box variant="h3" padding="n">
Quick launch
</Box>
<SpaceBetween size="xs">
<Button fullWidth={true}>Launch instance</Button>
<Button fullWidth={true}>Create volume</Button>
<Button fullWidth={true}>View documentation</Button>
</SpaceBetween>
</SpaceBetween>
</Box>
);
}

export default function () {
const [breakpoint, setBreakpoint] = useState<ContentLayoutProps.SecondaryHeaderBreakpoint>('xs');

return (
<main>
<Box padding="m">
<FormField label="secondaryHeaderBreakpoint">
<Select
selectedOption={{ value: breakpoint }}
options={breakpointOptions}
onChange={({ detail }) =>
setBreakpoint(detail.selectedOption.value as ContentLayoutProps.SecondaryHeaderBreakpoint)
}
/>
</FormField>
</Box>
<ScreenshotArea gutters={false}>
<ContentLayout
secondaryHeaderBreakpoint={breakpoint}
header={
<div style={{ padding: '20px 40px 0' }}>
<Header
variant="h1"
description="Adjust the breakpoint above to control when the secondary header stacks underneath the header."
>
Service homepage
</Header>
</div>
}
secondaryHeader={
<div style={{ padding: '20px 40px 0' }}>
<QuickLaunch />
</div>
}
>
<div style={{ padding: '0 40px 20px' }}>
<Containers />
</div>
</ContentLayout>
</ScreenshotArea>
</main>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
44 changes: 44 additions & 0 deletions src/content-layout/__tests__/content-layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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</>,
Expand Down Expand Up @@ -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 });
});
});
18 changes: 18 additions & 0 deletions src/content-layout/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
18 changes: 17 additions & 1 deletion src/content-layout/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<GridProps.ElementDefinition> {
return [{ colspan: { default: 12, [breakpoint]: 9 } }, { colspan: { default: 12, [breakpoint]: 3 } }];
}

export default function InternalContentLayout({
children,
disableOverlap,
Expand All @@ -35,6 +50,7 @@ export default function InternalContentLayout({
notifications,
defaultPadding,
secondaryHeader,
secondaryHeaderBreakpoint = defaultSecondaryHeaderBreakpoint,
...rest
}: InternalContentLayoutProps) {
const mainRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -111,7 +127,7 @@ export default function InternalContentLayout({
[styles['with-divider']]: headerVariant === 'divider',
})}
>
<InternalGrid gridDefinition={[{ colspan: { default: 12, xs: 9 } }, { colspan: { default: 12, xs: 3 } }]}>
<InternalGrid gridDefinition={getSecondaryHeaderGridDefinition(secondaryHeaderBreakpoint)}>
<div className={clsx(testutilStyles.header, contentHeaderClassName)}>{header}</div>
<div className={testutilStyles['secondary-header']}>{secondaryHeader}</div>
</InternalGrid>
Expand Down
Loading