Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Dashboard: add a per-section header with the section title and date controls.
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export {
type SectionTabPanelProps,
type SectionTabsProps,
} from './section-tabs/section-tabs';
export { SectionHeader } from './section-header/section-header';
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import { SectionHeader } from '../section-header';

const SUBTITLE = 'Jul 21 – Jul 27 (7 days)';

describe( 'SectionHeader', () => {
it( 'renders the title as a level-2 heading', () => {
render( <SectionHeader title="Traffic" /> );

expect( screen.getByRole( 'heading', { level: 2 } ) ).toHaveTextContent( 'Traffic' );
} );

it( 'renders the subtitle when one is given', () => {
render( <SectionHeader title="Traffic" subtitle={ SUBTITLE } /> );

expect( screen.getByText( SUBTITLE ) ).toBeInTheDocument();
} );

it( 'drops the subtitle once there is nothing to describe', () => {
const { rerender } = render( <SectionHeader title="Traffic" subtitle={ SUBTITLE } /> );

rerender( <SectionHeader title="Traffic" /> );

expect( screen.queryByText( SUBTITLE ) ).not.toBeInTheDocument();
} );

it( 'renders the date controls passed to the slot', () => {
render(
<SectionHeader title="Traffic">
<button type="button">Last 7 days</button>
</SectionHeader>
);

expect( screen.getByRole( 'button', { name: 'Last 7 days' } ) ).toBeInTheDocument();
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SectionHeader } from './section-header';
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Stack, Text } from '@wordpress/ui';
import { ReactNode } from 'react';

type SectionHeaderProps = {
/**
* The name of the section.
*/
title: string;

/**
* Subtitle describing the active date configuration. Omit it while the
* consumer has nothing to describe.
*/
subtitle?: string;

/**
* Date controls anchored to the end of the header row.
*/
children?: ReactNode;
};

/**
* Two-halves section header: title plus optional subtitle on the left, and a
* slot for the date controls anchored on the right.
*
* @param {SectionHeaderProps} props - The props for the SectionHeader component.
* @return The section header element.
*/
export function SectionHeader( { title, subtitle, children }: SectionHeaderProps ) {
return (
<Stack direction="row" align="flex-start" justify="space-between">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding a gap here? Stack doesn't set one, so a long translated title can end up right up against the date controls.

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 👍 . This will be addressed in a follow-up.

<Stack direction="column" align="flex-start" justify="flex-start">
<Text variant="heading-2xl" render={ <h2 /> }>
{ title }
</Text>
{ subtitle ? <Text variant="body-lg">{ subtitle }</Text> : null }
</Stack>

<Stack direction="row" align="center" justify="flex-end">
{ children }
</Stack>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import {
computePrimaryRange,
type ComparisonPresetId,
type PrimaryPresetId,
type YearSurfacePresetId,
} from '@jetpack-premium-analytics/datetime';
import { useCallback, useRef, useState } from 'react';
import { DateFiltersPanel } from '../../date-filters-panel';
import { DateYearFilter } from '../../date-year-filter';
import { SectionHeader } from '../section-header';
import type { DateRange } from '../../date-filters-panel/date-filters-panel';
import type { Meta, StoryObj } from '@storybook/react';

const meta: Meta< typeof SectionHeader > = {
title: 'Packages/Premium Analytics/UI/SectionHeader',
component: SectionHeader,
tags: [ 'autodocs' ],
parameters: {
docs: {
description: {
component:
'Two-halves header for an analytics surface. The left half anchors the ' +
'instance: a **title** naming the section plus a **subtitle** describing ' +
'the active date configuration.\n\n' +
'The right half is a slot: the consumer passes the date controls as ' +
'`children`, keeps the date state, and derives the subtitle from the ' +
'*applied* range, so the header stays purely presentational.',
},
},
},
argTypes: {
children: { control: false },
},
};
export default meta;

type Story = StoryObj< typeof SectionHeader >;

const STORYBOOK_TIMEZONE = 'America/New_York';

type PrimaryFilterState = {
range: DateRange;
presetId: PrimaryPresetId;
};

/**
* Committed/staged seed mirroring the dashboard default: Last 30 days.
*
* @return The initial primary filter state.
*/
function buildInitialPrimaryState(): PrimaryFilterState {
const range = computePrimaryRange( 'last-30-days', STORYBOOK_TIMEZONE );

return {
presetId: 'last-30-days',
range: { from: range?.from, to: range?.to },
};
}

/**
* Rolling date controls for the slot, wired like the dashboard: staged primary
* edits committed on Apply. A trimmed copy of the DateFiltersPanel story
* harness.
*
* @param props - Harness props.
* @param props.containerElement - Measured row element for responsive layout.
* @return The wired date filters panel.
*/
function RollingDateControls( { containerElement }: { containerElement: HTMLElement | null } ) {
const initial = buildInitialPrimaryState();

const [ committed, setCommitted ] = useState( initial );
const [ staged, setStaged ] = useState( initial );
const stagedRef = useRef( staged );
stagedRef.current = staged;

const [ comparisonPresetId, setComparisonPresetId ] = useState< ComparisonPresetId | undefined >(
undefined
);

const handleChange = useCallback( ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => {
const next: PrimaryFilterState = {
range: nextRange ?? stagedRef.current.range,
presetId: nextPresetId ?? stagedRef.current.presetId,
};

stagedRef.current = next;
setStaged( next );
}, [] );

const handleApply = useCallback( () => {
setCommitted( stagedRef.current );
}, [] );

const handleCancel = useCallback( () => {
stagedRef.current = committed;
setStaged( committed );
}, [ committed ] );

const handleComparisonChange = useCallback(
( _range: DateRange | undefined, nextPresetId?: ComparisonPresetId ) => {
setComparisonPresetId( nextPresetId );
},
[]
);

const canApply =
staged.range.from !== committed.range.from ||
staged.range.to !== committed.range.to ||
staged.presetId !== committed.presetId;

return (
<DateFiltersPanel
presetId={ staged.presetId }
range={ staged.range }
appliedPresetId={ committed.presetId }
appliedRange={ committed.range }
comparisonPresetId={ comparisonPresetId }
onChange={ handleChange }
onComparisonChange={ handleComparisonChange }
onApply={ handleApply }
onCancel={ handleCancel }
canApply={ canApply }
timeZone={ STORYBOOK_TIMEZONE }
containerElement={ containerElement }
/>
);
}

/**
* Year-surface controls for the slot: all time plus calendar years, no
* comparison, as the Insights instance specifies.
*
* @param props - Harness props.
* @param props.containerElement - Measured row element for responsive layout.
* @return The wired year filter.
*/
function YearDateControls( { containerElement }: { containerElement: HTMLElement | null } ) {
const [ presetId, setPresetId ] = useState< PrimaryPresetId >( 'all-time' as PrimaryPresetId );

return (
<DateYearFilter
value={ presetId }
onSelect={ ( _range, nextPresetId: YearSurfacePresetId ) => setPresetId( nextPresetId ) }
timeZone={ STORYBOOK_TIMEZONE }
containerElement={ containerElement }
/>
);
}

type SectionHeaderStoryProps = {
title: string;
subtitle: string;
};

function RollingSectionHeaderStory( { title, subtitle }: SectionHeaderStoryProps ) {
const [ container, setContainer ] = useState< HTMLDivElement | null >( null );

return (
<div ref={ setContainer }>
<SectionHeader title={ title } subtitle={ subtitle }>
<RollingDateControls containerElement={ container } />
</SectionHeader>
</div>
);
}

function YearSectionHeaderStory( { title, subtitle }: SectionHeaderStoryProps ) {
const [ container, setContainer ] = useState< HTMLDivElement | null >( null );

return (
<div ref={ setContainer }>
<SectionHeader title={ title } subtitle={ subtitle }>
<YearDateControls containerElement={ container } />
</SectionHeader>
</div>
);
}

/**
* The **Traffic-like** instance: rolling presets, custom range, and comparison
* in the slot.
*
* The subtitle is a static arg here; in product it derives from the *applied*
* preset/range and, once the interval control lands, the active interval.
*/
export const Default: Story = {
args: {
title: 'Site traffic',
subtitle: 'Last 30 days',
},
render: ( { title, subtitle } ) => (
<RollingSectionHeaderStory title={ title } subtitle={ subtitle } />
),
};

/**
* The **Insights-like** instance: the year surface (all time plus calendar
* years) in the slot.
*
* Per the design's instances table, this surface carries *no comparison
* control*.
*/
export const YearSurface: Story = {
args: {
title: 'Insights',
subtitle: 'All time',
},
render: ( { title, subtitle } ) => (
<YearSectionHeaderStory title={ title } subtitle={ subtitle } />
),
};

/**
* The left half on its own: nothing in the controls slot.
*/
export const WithoutControls: Story = {
args: {
title: 'Site traffic',
subtitle: 'Last 30 days',
},
render: ( { title, subtitle } ) => <SectionHeader title={ title } subtitle={ subtitle } />,
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
flex-direction: column;
flex: 1 1 auto;
min-block-size: 0;

// Own stacking context, so in-panel layers (the sticky section header,
// widget focus rings) never compete with the page header above.
position: relative;
z-index: 0;
}

.tabList {
padding-inline: var(--wpds-dimension-padding-2xl);
z-index: 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,7 @@ type DashboardSectionsProps = {
* Tabs are keyed by the section `slug` (the URL-facing identifier the active
* section state uses), not the namespaced `id`.
*
* @param props - Component props.
* @param props.sections - The sections to render, in order.
* @param props.value - The currently active section ID.
* @param props.onChange - Called with the new section ID when the user selects a different section.
* @param props.children - Section panel content.
* @param {DashboardSectionsProps} props - The props for the DashboardSections component.
* @return The section tab bar element.
*/
export function DashboardSections( {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
.content {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that in mobile view, or on a tall screen, the sticky header may scroll away once we pass the first screen of widgets.

Screen.Recording.2026-07-30.at.10.46.01.AM.mov

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, same comment as the previous ones. Let's address it in a follow-up. I don't want to introduce too many changes in this PR

flex: 1 1 auto;
min-block-size: 0;
overflow-y: auto;

// Reserve paint space for the widget host's outer focus outline.
padding-block-start: calc(var(--wpds-border-width-focus) + 2px);
// Match the horizontal padding of the page header and the section tabs so the
Expand All @@ -21,10 +21,8 @@
padding-inline: var(--wpds-dimension-padding-2xl);
}

.dateFilters {
// Sits below the section tabs and above the widgets. The tab list already
// adds a bottom margin, so only pad below here to keep the spacing even.
// Inline padding matches the tabs and widget grid so it lines up.
.sectionHeader {
// Separates the header row from the widget grid below. The inline gutter
// comes from the panel, which already pads to match the tabs.
padding-block-end: var(--wpds-dimension-gap-lg);
padding-inline: var(--wpds-dimension-padding-2xl);
}
Loading
Loading