Skip to content
Merged
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 @@
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);
}
24 changes: 7 additions & 17 deletions projects/packages/premium-analytics/routes/dashboard/stage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GlobalErrorProvider } from '@jetpack-premium-analytics/data';
import { useReportDateFilters } from '@jetpack-premium-analytics/routing';
import { DateFiltersPanel, SectionTabPanel } from '@jetpack-premium-analytics/ui';
import { DateFiltersPanel, SectionHeader, SectionTabPanel } from '@jetpack-premium-analytics/ui';
import { Page, Breadcrumbs } from '@wordpress/admin-ui';
import { Spinner } from '@wordpress/components';
import { store as coreStore } from '@wordpress/core-data';
Expand Down Expand Up @@ -92,10 +92,6 @@ function Dashboard(): JSX.Element {
items={ [ { label: __( 'Analytics', 'jetpack-premium-analytics-pkg' ) } ] }
/>
}
subTitle={ __(
'Track your site performance and visitor insights.',
'jetpack-premium-analytics-pkg'
) }
actions={ <WidgetDashboard.Actions /> }
className={ styles.dashboard }
>
Expand All @@ -104,24 +100,18 @@ function Dashboard(): JSX.Element {
value={ activeSection }
onChange={ setActiveSection }
>
{ /*
* The date filters drive every section, so they render once
* below the section tabs and above the widgets, sharing the
* URL search state across all sections.
*
* The wrapper div is also the responsive-measurement target:
* DateFiltersPanel reads its width to pick mobile/wide layouts
* instead of relying on the viewport.
*/ }
<div ref={ setContainerElement } className={ styles.dateFilters }>
<DateFiltersPanel { ...dateFilters } containerElement={ containerElement } />
</div>
{ sections.map( section => (
<SectionTabPanel
key={ section.slug }
value={ section.slug }
className={ styles.content }
>
<div ref={ setContainerElement } className={ styles.sectionHeader }>

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 wonder if we should make each panel have its own container state?

Right now, all panels share setContainerElement. When switching tabs, the old panel can clear the ref after the new panel sets it, leaving containerElement as null. The filters then measure document.body instead of the actual row. Since the body is much wider, the filters may incorrectly stay in the wide layout.

Screen.Recording.2026-07-30.at.10.49.24.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.

I wonder if we should make each panel have its own container state?

This is a good question. The composition between SectionHeader and the tabs doesn't look very good. I'll take a look.

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.

Confirmed, and it happens on every tab switch. Base UI defers the outgoing panel's unmount by a frame, so the incoming panel sets the ref and the old one nulls it right after. The filters then measure document.body, around 208px wider than the real row, which keeps the presets wide between roughly 600 and 810px.

#50906 removes containerElement altogether and has the panel measure its own root, so the shared ref stops existing. The two PRs already conflict in stage.tsx and that is the resolution. What it does not cover is that SectionHeader's controls slot is a shrink-to-fit flex item, so a self-measuring panel would read its own content instead of the row. Tracked in WOOA7S-1837.

<SectionHeader title={ section.label }>
<DateFiltersPanel { ...dateFilters } containerElement={ containerElement } />

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.

The container used to mean "how much room do the filters have, but now the title also uses part of this container, so the filters may see 700px even though they only have 500px and incorrectly stay in the wide layout. Should the measured slot have a stable allocated width?

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.

Same as before. Those controls will change soon, so I'd like to address that in a follow-up. For instance, this PR #50906

</SectionHeader>
</div>

{ activeSection === section.slug ? (
<>
<WidgetDashboard.NoWidgetsState />
Expand Down
Loading