From 5a1fceb2505dc8b44917f835ec53338cb15d9dab Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 14:03:07 +0000 Subject: [PATCH] feat: add Master/Detail pattern (WIP) --- pages/master-detail/simple.page.tsx | 148 ++++++++++++++++++ .../components/master-detail-layout/README.md | 57 +++++++ .../__tests__/master-detail-layout.test.tsx | 91 +++++++++++ .../components/master-detail-layout/index.tsx | 105 +++++++++++++ .../master-detail-layout/interfaces.ts | 59 +++++++ .../master-detail-layout/styles.scss | 33 ++++ .../test-classes/styles.scss | 11 ++ 7 files changed, 504 insertions(+) create mode 100644 pages/master-detail/simple.page.tsx create mode 100644 src/internal/components/master-detail-layout/README.md create mode 100644 src/internal/components/master-detail-layout/__tests__/master-detail-layout.test.tsx create mode 100644 src/internal/components/master-detail-layout/index.tsx create mode 100644 src/internal/components/master-detail-layout/interfaces.ts create mode 100644 src/internal/components/master-detail-layout/styles.scss create mode 100644 src/internal/components/master-detail-layout/test-classes/styles.scss diff --git a/pages/master-detail/simple.page.tsx b/pages/master-detail/simple.page.tsx new file mode 100644 index 0000000000..77df9213d6 --- /dev/null +++ b/pages/master-detail/simple.page.tsx @@ -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 = [ + { + 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 = { + running: 'success', + stopped: 'stopped', + pending: 'in-progress', +}; + +function InstanceDetail({ instance }: { instance: Instance }) { + return ( + +
{instance.name}
+ {instance.state}, + }, + { label: 'Availability zone', value: instance.zone }, + { label: 'Public DNS', value: instance.publicDns }, + ]} + /> +
+ ); +} + +/** + * 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(null); + + return ( + + Master/Detail pattern (WIP v0)}> + setSelectedInstance(null)} + masterAriaLabel="Instances list" + detailAriaLabel="Instance details" + backLabel="Back to instances" + master={ + + 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={
Instances
} + 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 => {item.state}, + }, + ]} + /> + } + detail={selectedInstance && } + detailPlaceholder={ + + No instance selected + + Select an instance from the list to view its details. + + + } + /> + + } + /> +
+ ); +} diff --git a/src/internal/components/master-detail-layout/README.md b/src/internal/components/master-detail-layout/README.md new file mode 100644 index 0000000000..fdb59b855a --- /dev/null +++ b/src/internal/components/master-detail-layout/README.md @@ -0,0 +1,57 @@ + + +# 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. diff --git a/src/internal/components/master-detail-layout/__tests__/master-detail-layout.test.tsx b/src/internal/components/master-detail-layout/__tests__/master-detail-layout.test.tsx new file mode 100644 index 0000000000..7fbb25f5d5 --- /dev/null +++ b/src/internal/components/master-detail-layout/__tests__/master-detail-layout.test.tsx @@ -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> = {}) => + render( + MASTER} + detail={
DETAIL
} + detailPlaceholder={
PLACEHOLDER
} + 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); + }); +}); diff --git a/src/internal/components/master-detail-layout/index.tsx b/src/internal/components/master-detail-layout/index.tsx new file mode 100644 index 0000000000..c9c5430edd --- /dev/null +++ b/src/internal/components/master-detail-layout/index.tsx @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { useContainerQuery } from '@cloudscape-design/component-toolkit'; + +import InternalButton from '../../../button/internal'; +import { MasterDetailLayoutProps, MasterDetailView } from './interfaces'; + +import styles from './styles.css.js'; +import testClasses from './test-classes/styles.css.js'; + +export { MasterDetailLayoutProps }; + +/** + * Default container width (px) below which the layout collapses to a single column. + */ +export const DEFAULT_NARROW_BREAKPOINT = 688; + +/** + * Pure helper deciding which panes are visible for a given state. Extracted from + * the component so the responsive branching can be unit tested without a DOM + * layout engine (jsdom does not measure element widths). + * + * - Wide layout: master and detail render side by side. + * - Narrow layout: a single pane renders. The detail pane is shown when an item + * is selected, otherwise the master (list) pane is shown. When a selection can + * be cleared, a "back to list" control is offered in the detail pane. + */ +export function resolveMasterDetailView({ + isNarrow, + hasSelection, + canClearSelection, +}: { + isNarrow: boolean; + hasSelection: boolean; + canClearSelection: boolean; +}): MasterDetailView { + if (!isNarrow) { + return { showMaster: true, showDetail: true, showBackButton: false }; + } + if (hasSelection) { + return { showMaster: false, showDetail: true, showBackButton: canClearSelection }; + } + return { showMaster: true, showDetail: false, showBackButton: false }; +} + +/** + * MasterDetailLayout — an experimental, internal layout helper that composes the + * Master/Detail pattern (a list/master pane alongside a detail pane for the + * selected item) on top of existing Cloudscape layout primitives. + * + * This is a first-cut (v0) building block: it owns the two-column layout and the + * responsive collapse to a single column, while remaining agnostic about the + * concrete master (Table, Cards, ...) and detail content, which are provided by + * the consumer. It is intentionally additive and not part of the public API. + */ +export default function MasterDetailLayout({ + master, + detail, + hasSelection, + detailPlaceholder, + onClearSelection, + masterAriaLabel, + detailAriaLabel, + backLabel = 'Back', + narrowBreakpoint = DEFAULT_NARROW_BREAKPOINT, +}: MasterDetailLayoutProps) { + // Only collapse once we have a positive measurement below the breakpoint. A + // 0-width (unmeasured / SSR / degenerate) container keeps the wide layout, + // which avoids a flash-of-narrow before the first measurement is available. + const [isNarrow, ref] = useContainerQuery( + entry => entry.contentBoxWidth > 0 && entry.contentBoxWidth < narrowBreakpoint, + [narrowBreakpoint] + ); + + const { showMaster, showDetail, showBackButton } = resolveMasterDetailView({ + isNarrow: isNarrow ?? false, + hasSelection, + canClearSelection: !!onClearSelection, + }); + + return ( +
+ {showMaster && ( +
+ {master} +
+ )} + {showDetail && ( +
+ {showBackButton && ( +
+ onClearSelection?.()}> + {backLabel} + +
+ )} + {hasSelection ? detail : detailPlaceholder} +
+ )} +
+ ); +} diff --git a/src/internal/components/master-detail-layout/interfaces.ts b/src/internal/components/master-detail-layout/interfaces.ts new file mode 100644 index 0000000000..c1abf3bd33 --- /dev/null +++ b/src/internal/components/master-detail-layout/interfaces.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +export interface MasterDetailLayoutProps { + /** + * Master pane content — typically a Table or Cards collection acting as the + * list of items the user can select from. + */ + master: React.ReactNode; + + /** + * Detail pane content for the currently selected item. Only shown when + * `hasSelection` is `true`; otherwise `detailPlaceholder` is rendered. + */ + detail: React.ReactNode; + + /** + * Whether an item is currently selected. This drives the responsive + * (single-column) behavior: in the narrow layout the detail pane is shown + * when `true`, and the master (list) pane is shown when `false`. + */ + hasSelection: boolean; + + /** + * Content rendered in the detail pane when nothing is selected (empty state). + */ + detailPlaceholder?: React.ReactNode; + + /** + * Called when the user activates the "back to list" control in the narrow + * layout. Providing this callback renders the control. + */ + onClearSelection?: () => void; + + /** Accessible label for the master (list) region. */ + masterAriaLabel?: string; + + /** Accessible label for the detail region. */ + detailAriaLabel?: string; + + /** Text for the "back to list" control shown in the narrow layout. Defaults to "Back". */ + backLabel?: string; + + /** + * Container width (in px) below which the layout collapses to a single + * column. Defaults to {@link DEFAULT_NARROW_BREAKPOINT}. + */ + narrowBreakpoint?: number; +} + +export interface MasterDetailView { + /** Whether the master (list) pane should render. */ + showMaster: boolean; + /** Whether the detail pane should render. */ + showDetail: boolean; + /** Whether the "back to list" control should render inside the detail pane. */ + showBackButton: boolean; +} diff --git a/src/internal/components/master-detail-layout/styles.scss b/src/internal/components/master-detail-layout/styles.scss new file mode 100644 index 0000000000..479870dbad --- /dev/null +++ b/src/internal/components/master-detail-layout/styles.scss @@ -0,0 +1,33 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use '../../styles' as styles; +@use '../../styles/tokens' as awsui; + +.root { + @include styles.styles-reset; + display: grid; + // Master column has a sensible minimum and takes ~1/3, detail takes the rest. + grid-template-columns: minmax(280px, 1fr) 2fr; + gap: awsui.$space-scaled-l; + inline-size: 100%; + align-items: start; + + &.narrow { + // Collapse to a single column on narrow containers. + grid-template-columns: 1fr; + } +} + +.master { + min-inline-size: 0; +} + +.detail { + min-inline-size: 0; +} + +.back-button { + margin-block-end: awsui.$space-scaled-s; +} diff --git a/src/internal/components/master-detail-layout/test-classes/styles.scss b/src/internal/components/master-detail-layout/test-classes/styles.scss new file mode 100644 index 0000000000..2406832437 --- /dev/null +++ b/src/internal/components/master-detail-layout/test-classes/styles.scss @@ -0,0 +1,11 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.root, +.master, +.detail, +.back-button { + /* used in test-utils */ +}