diff --git a/projects/packages/premium-analytics/changelog/add-pa-section-header b/projects/packages/premium-analytics/changelog/add-pa-section-header new file mode 100644 index 000000000000..666b43e0b5e9 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-pa-section-header @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Dashboard: add a per-section header with the section title and date controls. diff --git a/projects/packages/premium-analytics/packages/ui/src/index.ts b/projects/packages/premium-analytics/packages/ui/src/index.ts index 07e098cfb5b2..b647d125716b 100644 --- a/projects/packages/premium-analytics/packages/ui/src/index.ts +++ b/projects/packages/premium-analytics/packages/ui/src/index.ts @@ -16,3 +16,4 @@ export { type SectionTabPanelProps, type SectionTabsProps, } from './section-tabs/section-tabs'; +export { SectionHeader } from './section-header/section-header'; diff --git a/projects/packages/premium-analytics/packages/ui/src/section-header/__tests__/section-header.test.tsx b/projects/packages/premium-analytics/packages/ui/src/section-header/__tests__/section-header.test.tsx new file mode 100644 index 000000000000..fef6bce594a0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/section-header/__tests__/section-header.test.tsx @@ -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( ); + + expect( screen.getByRole( 'heading', { level: 2 } ) ).toHaveTextContent( 'Traffic' ); + } ); + + it( 'renders the subtitle when one is given', () => { + render( ); + + expect( screen.getByText( SUBTITLE ) ).toBeInTheDocument(); + } ); + + it( 'drops the subtitle once there is nothing to describe', () => { + const { rerender } = render( ); + + rerender( ); + + expect( screen.queryByText( SUBTITLE ) ).not.toBeInTheDocument(); + } ); + + it( 'renders the date controls passed to the slot', () => { + render( + + + + ); + + expect( screen.getByRole( 'button', { name: 'Last 7 days' } ) ).toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/ui/src/section-header/index.ts b/projects/packages/premium-analytics/packages/ui/src/section-header/index.ts new file mode 100644 index 000000000000..c17c37c2a4ac --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/section-header/index.ts @@ -0,0 +1 @@ +export { SectionHeader } from './section-header'; diff --git a/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.tsx b/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.tsx new file mode 100644 index 000000000000..376d4f7486ad --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.tsx @@ -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 ( + + + }> + { title } + + { subtitle ? { subtitle } : null } + + + + { children } + + + ); +} diff --git a/projects/packages/premium-analytics/packages/ui/src/section-header/stories/section-header.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/section-header/stories/section-header.stories.tsx new file mode 100644 index 000000000000..5b92ff341e03 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/section-header/stories/section-header.stories.tsx @@ -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 ( + + ); +} + +/** + * 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 ( + 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 ( +
+ + + +
+ ); +} + +function YearSectionHeaderStory( { title, subtitle }: SectionHeaderStoryProps ) { + const [ container, setContainer ] = useState< HTMLDivElement | null >( null ); + + return ( +
+ + + +
+ ); +} + +/** + * 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 } ) => ( + + ), +}; + +/** + * 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 } ) => ( + + ), +}; + +/** + * 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 } ) => , +}; diff --git a/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.module.scss b/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.module.scss index 8ef23a11b6df..4f7d2997fe03 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.module.scss +++ b/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.module.scss @@ -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; } diff --git a/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.tsx b/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.tsx index ddb860ce7ef6..cb4301b7a4fd 100644 --- a/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/components/dashboard-sections/dashboard-sections.tsx @@ -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( { diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.module.scss b/projects/packages/premium-analytics/routes/dashboard/stage.module.scss index ff1abbdad86d..5ef1034965ab 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.module.scss +++ b/projects/packages/premium-analytics/routes/dashboard/stage.module.scss @@ -12,7 +12,7 @@ .content { 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 @@ -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); } diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 8c02a5a275eb..534c3db44df4 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -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'; @@ -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={ } className={ styles.dashboard } > @@ -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. - */ } -
- -
{ sections.map( section => ( +
+ + + +
+ { activeSection === section.slug ? ( <>