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
148 changes: 148 additions & 0 deletions pages/master-detail/simple.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import AppLayout from '~components/app-layout';
import Box from '~components/box';
import ContentLayout from '~components/content-layout';
import Header from '~components/header';
import MasterDetailLayout from '~components/internal/components/master-detail-layout';
import KeyValuePairs from '~components/key-value-pairs';
import SpaceBetween from '~components/space-between';
import StatusIndicator from '~components/status-indicator';
import Table from '~components/table';

import ScreenshotArea from '../utils/screenshot-area';

interface Instance {
id: string;
name: string;
type: string;
state: 'running' | 'stopped' | 'pending';
zone: string;
publicDns: string;
}

const INSTANCES: ReadonlyArray<Instance> = [
{
id: 'i-01a2',
name: 'web-server-1',
type: 't3.medium',
state: 'running',
zone: 'us-east-1a',
publicDns: 'ec2-1.compute.amazonaws.com',
},
{
id: 'i-02b3',
name: 'web-server-2',
type: 't3.medium',
state: 'running',
zone: 'us-east-1b',
publicDns: 'ec2-2.compute.amazonaws.com',
},
{ id: 'i-03c4', name: 'batch-worker', type: 'c5.large', state: 'stopped', zone: 'us-east-1a', publicDns: '—' },
{
id: 'i-04d5',
name: 'db-primary',
type: 'r5.xlarge',
state: 'running',
zone: 'us-east-1c',
publicDns: 'ec2-4.compute.amazonaws.com',
},
{ id: 'i-05e6', name: 'db-replica', type: 'r5.xlarge', state: 'pending', zone: 'us-east-1c', publicDns: '—' },
];

const STATE_TYPE: Record<Instance['state'], 'success' | 'stopped' | 'in-progress'> = {
running: 'success',
stopped: 'stopped',
pending: 'in-progress',
};

function InstanceDetail({ instance }: { instance: Instance }) {
return (
<SpaceBetween size="l">
<Header variant="h2">{instance.name}</Header>
<KeyValuePairs
columns={2}
items={[
{ label: 'Instance ID', value: instance.id },
{ label: 'Instance type', value: instance.type },
{
label: 'State',
value: <StatusIndicator type={STATE_TYPE[instance.state]}>{instance.state}</StatusIndicator>,
},
{ label: 'Availability zone', value: instance.zone },
{ label: 'Public DNS', value: instance.publicDns },
]}
/>
</SpaceBetween>
);
}

/**
* Dev/demo page for the Master/Detail pattern (v0).
*
* Demonstrates a list/master pane (Table) alongside a detail pane driven by the
* selected item, composed with the experimental `MasterDetailLayout` helper.
* Selecting a row updates the detail pane; the helper collapses to a single
* column on narrow container widths (resize the browser to observe).
*/
export default function MasterDetailSimplePage() {
const [selectedInstance, setSelectedInstance] = useState<Instance | null>(null);

return (
<ScreenshotArea gutters={false}>
<AppLayout
contentType="table"
toolsHide={true}
navigationHide={true}
ariaLabels={{ navigation: 'Navigation', notifications: 'Notifications', tools: 'Tools' }}
content={
<ContentLayout header={<Header variant="h1">Master/Detail pattern (WIP v0)</Header>}>
<MasterDetailLayout
hasSelection={selectedInstance !== null}
onClearSelection={() => setSelectedInstance(null)}
masterAriaLabel="Instances list"
detailAriaLabel="Instance details"
backLabel="Back to instances"
master={
<Table<Instance>
variant="container"
selectionType="single"
trackBy="id"
items={INSTANCES}
selectedItems={selectedInstance ? [selectedInstance] : []}
onSelectionChange={({ detail }) => setSelectedInstance(detail.selectedItems[0] ?? null)}
ariaLabels={{
selectionGroupLabel: 'Instance selection',
itemSelectionLabel: (_data, row) => row.name,
tableLabel: 'Instances',
}}
header={<Header counter={`(${INSTANCES.length})`}>Instances</Header>}
columnDefinitions={[
{ id: 'name', header: 'Name', cell: item => item.name, isRowHeader: true },
{ id: 'type', header: 'Type', cell: item => item.type },
{
id: 'state',
header: 'State',
cell: item => <StatusIndicator type={STATE_TYPE[item.state]}>{item.state}</StatusIndicator>,
},
]}
/>
}
detail={selectedInstance && <InstanceDetail instance={selectedInstance} />}
detailPlaceholder={
<Box textAlign="center" color="text-body-secondary" padding={{ vertical: 'xxl' }}>
<b>No instance selected</b>
<Box variant="p" color="text-body-secondary">
Select an instance from the list to view its details.
</Box>
</Box>
}
/>
</ContentLayout>
}
/>
</ScreenshotArea>
);
}
57 changes: 57 additions & 0 deletions src/internal/components/master-detail-layout/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!--
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
-->

# MasterDetailLayout (experimental, internal — WIP v0)

`MasterDetailLayout` is a first-cut, **internal** layout helper for the
**Master/Detail** pattern: a list/master pane (typically a `Table` or `Cards`
collection) sits alongside a detail pane that reflects the currently selected
item. Selecting an item updates the detail view. On narrow container widths the
layout collapses to a single column.

> **Status:** WIP v0. This lives under `src/internal/components` and is **not**
> part of the public API. It is intentionally additive and opt-in so we can
> iterate on the pattern before committing to a public surface.

## Why a composition helper (and not a full component yet)

The Master/Detail pattern is fundamentally a *composition* of existing
Cloudscape primitives (`AppLayout`, `Table`/`Cards`, `KeyValuePairs`, etc.).
The only reusable, non-trivial piece is the two-column layout + responsive
collapse, which this helper owns. Concrete master and detail content stay with
the consumer. See `pages/master-detail/simple.page.tsx` for a worked example.

## API (v0)

| Prop | Type | Description |
| --- | --- | --- |
| `master` | `React.ReactNode` | The list/master pane (e.g. a `Table`). |
| `detail` | `React.ReactNode` | Detail content for the selected item. |
| `hasSelection` | `boolean` | Whether an item is selected. Drives the narrow-view switch. |
| `detailPlaceholder` | `React.ReactNode` | Empty state shown in the detail pane when nothing is selected. |
| `onClearSelection` | `() => void` | If provided, renders a "back to list" control in the narrow layout. |
| `masterAriaLabel` / `detailAriaLabel` | `string` | Accessible labels for the two regions. |
| `backLabel` | `string` | Label for the back control. Defaults to `"Back"`. |
| `narrowBreakpoint` | `number` | Container width (px) below which it collapses. Defaults to `688`. |

The pure `resolveMasterDetailView({ isNarrow, hasSelection, canClearSelection })`
helper is exported for testing and reuse.

## Responsive behavior

- **Wide** container: master and detail render side by side.
- **Narrow** container: a single pane renders — the detail pane when an item is
selected (with an optional back control), otherwise the master pane.

Width is observed with `useContainerQuery` from `@cloudscape-design/component-toolkit`.

## Known limitations / follow-ups (out of scope for v0)

- No keyboard focus management when switching panes on narrow widths.
- No URL/deep-link syncing of the selected item.
- Detail pane is inline; a `SplitPanel`/drawer-based variant is not yet offered.
- No public API, i18n messages, test-utils wrapper, or documenter definitions.
- No visual-regression / integration (`__integ__`) coverage yet.
- Column ratio and minimum widths are fixed; not yet configurable.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';

import MasterDetailLayout, {
DEFAULT_NARROW_BREAKPOINT,
resolveMasterDetailView,
} from '../../../../../lib/components/internal/components/master-detail-layout';

describe('resolveMasterDetailView', () => {
test('wide layout shows both panes and no back button', () => {
expect(resolveMasterDetailView({ isNarrow: false, hasSelection: false, canClearSelection: true })).toEqual({
showMaster: true,
showDetail: true,
showBackButton: false,
});
expect(resolveMasterDetailView({ isNarrow: false, hasSelection: true, canClearSelection: true })).toEqual({
showMaster: true,
showDetail: true,
showBackButton: false,
});
});

test('narrow layout without selection shows only the master pane', () => {
expect(resolveMasterDetailView({ isNarrow: true, hasSelection: false, canClearSelection: true })).toEqual({
showMaster: true,
showDetail: false,
showBackButton: false,
});
});

test('narrow layout with selection shows only the detail pane', () => {
expect(resolveMasterDetailView({ isNarrow: true, hasSelection: true, canClearSelection: true })).toEqual({
showMaster: false,
showDetail: true,
showBackButton: true,
});
});

test('narrow layout with selection hides the back button when selection cannot be cleared', () => {
expect(resolveMasterDetailView({ isNarrow: true, hasSelection: true, canClearSelection: false })).toEqual({
showMaster: false,
showDetail: true,
showBackButton: false,
});
});
});

describe('MasterDetailLayout', () => {
const renderLayout = (props: Partial<React.ComponentProps<typeof MasterDetailLayout>> = {}) =>
render(
<MasterDetailLayout
master={<div>MASTER</div>}
detail={<div>DETAIL</div>}
detailPlaceholder={<div>PLACEHOLDER</div>}
hasSelection={false}
masterAriaLabel="Items"
detailAriaLabel="Item details"
{...props}
/>
);

test('exposes a sensible default breakpoint', () => {
expect(DEFAULT_NARROW_BREAKPOINT).toBeGreaterThan(0);
});

test('renders both regions in the default (wide) layout', () => {
renderLayout({ hasSelection: true });
expect(screen.getByLabelText('Items')).toBeInTheDocument();
expect(screen.getByLabelText('Item details')).toBeInTheDocument();
expect(screen.getByText('MASTER')).toBeInTheDocument();
expect(screen.getByText('DETAIL')).toBeInTheDocument();
});

test('renders the placeholder in the detail pane when nothing is selected', () => {
renderLayout({ hasSelection: false });
expect(screen.getByText('PLACEHOLDER')).toBeInTheDocument();
expect(screen.queryByText('DETAIL')).not.toBeInTheDocument();
});

test('does not render a back control in the wide layout even when clearable', () => {
const onClearSelection = jest.fn();
renderLayout({ hasSelection: true, onClearSelection, backLabel: 'Back to list' });
expect(screen.queryByText('Back to list')).not.toBeInTheDocument();
expect(onClearSelection).not.toHaveBeenCalled();
// sanity: detail content is still visible in the wide layout
expect(screen.getByText('DETAIL')).toBeInTheDocument();
fireEvent.resize(window);
});
});
Loading
Loading