diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 344a9a69455b..c9d8671c1478 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3865,6 +3865,9 @@ importers:
'@automattic/number-formatters':
specifier: workspace:*
version: link:../../js-packages/number-formatters
+ '@automattic/ui':
+ specifier: 1.0.2
+ version: 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@date-fns/tz':
specifier: 1.4.1
version: 1.4.1
@@ -3877,6 +3880,12 @@ importers:
'@wordpress/boot':
specifier: 0.14.1
version: 0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/components':
+ specifier: 35.0.0
+ version: 35.0.0(@date-fns/tz@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/compose':
+ specifier: 8.1.0
+ version: 8.1.0(react@18.3.1)
'@wordpress/core-data':
specifier: 7.48.0
version: 7.48.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3892,12 +3901,21 @@ importers:
'@wordpress/primitives':
specifier: 4.48.0
version: 4.48.0(react@18.3.1)
+ '@wordpress/private-apis':
+ specifier: 1.48.0
+ version: 1.48.0
'@wordpress/route':
specifier: 0.13.1
version: 0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/ui':
+ specifier: 0.13.0
+ version: 0.13.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@wordpress/url':
specifier: 4.48.0
version: 4.48.0
+ clsx:
+ specifier: 2.1.1
+ version: 2.1.1
date-fns:
specifier: 4.1.0
version: 4.1.0
@@ -3929,12 +3947,12 @@ importers:
'@typescript/native-preview':
specifier: 7.0.0-dev.20260225.1
version: 7.0.0-dev.20260225.1
+ '@wordpress/base-styles':
+ specifier: 8.0.0
+ version: 8.0.0
'@wordpress/build':
specifier: 0.14.0
- version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)
- '@wordpress/ui':
- specifier: 0.13.0
- version: 0.13.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.48.0)(@wordpress/route@0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)
browserslist:
specifier: 4.28.2
version: 4.28.2
@@ -24741,7 +24759,7 @@ snapshots:
'@wordpress/browserslist-config@6.48.0': {}
- '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)':
+ '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.48.0)(@wordpress/route@0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)':
dependencies:
'@emotion/babel-plugin': 11.13.5
'@wordpress/style-runtime': 0.2.0
@@ -24761,6 +24779,7 @@ snapshots:
sass-embedded: 1.97.3
optionalDependencies:
'@wordpress/boot': 0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/private-apis': 1.48.0
'@wordpress/route': 0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
transitivePeerDependencies:
- '@babel/core'
diff --git a/projects/js-packages/storybook/changelog/add-premium-analytics-internal-package-alias b/projects/js-packages/storybook/changelog/add-premium-analytics-internal-package-alias
new file mode 100644
index 000000000000..3dcc264e505d
--- /dev/null
+++ b/projects/js-packages/storybook/changelog/add-premium-analytics-internal-package-alias
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Add a Vite alias resolving `@jetpack-premium-analytics/*` internal package imports so Premium Analytics ui package stories build.
diff --git a/projects/js-packages/storybook/storybook/main.js b/projects/js-packages/storybook/storybook/main.js
index 874e107e53a4..07a5219a9b75 100644
--- a/projects/js-packages/storybook/storybook/main.js
+++ b/projects/js-packages/storybook/storybook/main.js
@@ -143,6 +143,14 @@ const sbconfig = {
alias: {
...config.resolve?.alias,
+ // Premium Analytics internal packages. At build time wp-build maps
+ // `@jetpack-premium-analytics/
` to `packages/`; mirror that here
+ // (each package's `main` points at its TS source).
+ '@jetpack-premium-analytics': path.join(
+ __dirname,
+ '../../../packages/premium-analytics/packages'
+ ),
+
// Boost-specific aliases
$lib: path.join( __dirname, '../../../plugins/boost/app/assets/src/js/lib' ),
$features: path.join( __dirname, '../../../plugins/boost/app/assets/src/js/features' ),
diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1318-integrate-components-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1318-integrate-components-package-into-analytics
new file mode 100644
index 000000000000..777a39c9cea4
--- /dev/null
+++ b/projects/packages/premium-analytics/changelog/wooa7s-1318-integrate-components-package-into-analytics
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Port the components package (date range/comparison filter UI components and SCSS) from next-woocommerce-analytics as the internal `ui` package.
diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs
index 367451888528..d26a13974722 100644
--- a/projects/packages/premium-analytics/eslint.config.mjs
+++ b/projects/packages/premium-analytics/eslint.config.mjs
@@ -48,6 +48,22 @@ export default defineConfig(
'import/no-extraneous-dependencies': 'off',
},
},
+ {
+ // First UI package in the port: also soften JSDoc rules for the ui
+ // package and allow the upstream inline-handler JSX style. Temporary —
+ // tighten these up in a follow-up alongside datetime/formatters.
+ files: [ 'packages/ui/**' ],
+ rules: {
+ 'jsdoc/require-jsdoc': 'off',
+ 'jsdoc/require-description': 'off',
+ 'jsdoc/require-param': 'off',
+ 'jsdoc/require-param-description': 'off',
+ 'jsdoc/require-returns': 'off',
+ 'jsdoc/check-indentation': 'off',
+ 'jsdoc/escape-inline-tags': 'off',
+ 'react/jsx-no-bind': 'off',
+ },
+ },
{
// The routing port also imports `react` directly (the staged-search
// hook), flagged as extraneous because the internal package's deps are
diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json
index 711e8245bd78..f0d30fddddb6 100644
--- a/projects/packages/premium-analytics/package.json
+++ b/projects/packages/premium-analytics/package.json
@@ -32,17 +32,23 @@
},
"dependencies": {
"@automattic/number-formatters": "workspace:*",
+ "@automattic/ui": "1.0.2",
"@date-fns/tz": "1.4.1",
"@tanstack/react-query": "5.90.8",
"@wordpress/api-fetch": "7.48.0",
"@wordpress/boot": "0.14.1",
+ "@wordpress/components": "35.0.0",
+ "@wordpress/compose": "8.1.0",
"@wordpress/core-data": "7.48.0",
"@wordpress/data": "10.48.0",
"@wordpress/i18n": "^6.9.0",
"@wordpress/icons": "^13.0.0",
"@wordpress/primitives": "4.48.0",
+ "@wordpress/private-apis": "1.48.0",
"@wordpress/route": "0.13.1",
+ "@wordpress/ui": "0.13.0",
"@wordpress/url": "4.48.0",
+ "clsx": "2.1.1",
"date-fns": "4.1.0",
"react": "18.3.1",
"react-dom": "18.3.1"
@@ -55,8 +61,8 @@
"@testing-library/dom": "10.4.1",
"@types/jest": "30.0.0",
"@typescript/native-preview": "7.0.0-dev.20260225.1",
+ "@wordpress/base-styles": "8.0.0",
"@wordpress/build": "0.14.0",
- "@wordpress/ui": "0.13.0",
"browserslist": "4.28.2",
"jest": "30.4.2",
"storybook": "10.3.6",
diff --git a/projects/packages/premium-analytics/packages/ui/package.json b/projects/packages/premium-analytics/packages/ui/package.json
new file mode 100644
index 000000000000..1a0ef18366fe
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@automattic/jetpack-premium-analytics-ui",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "sideEffects": [
+ "*.scss"
+ ],
+ "dependencies": {
+ "@automattic/ui": "1.0.2",
+ "@jetpack-premium-analytics/datetime": "workspace:*",
+ "@jetpack-premium-analytics/formatters": "workspace:*",
+ "@wordpress/components": "33.1.0",
+ "@wordpress/compose": "7.46.0",
+ "@wordpress/i18n": "^6.9.0",
+ "@wordpress/icons": "^13.0.0",
+ "@wordpress/private-apis": "1.46.0",
+ "@wordpress/ui": "0.13.0",
+ "clsx": "2.1.1",
+ "react": "18.3.1"
+ },
+ "devDependencies": {
+ "@storybook/react": "10.3.6",
+ "date-fns": "4.1.0"
+ }
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.scss b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.scss
new file mode 100644
index 000000000000..5ad8a7aa8ec5
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.scss
@@ -0,0 +1,36 @@
+.date-comparison-dropdown {
+
+ &__button {
+ background-color: var(--wpds-color-bg-surface-neutral-strong);
+ }
+}
+
+.date-filters-panel-button {
+ background-color: var(--wpds-color-bg-surface-neutral-strong);
+}
+
+.date-comparison-dropdown__popover {
+ width: 235px;
+}
+
+/* disable animation for the date range popover */
+/* stylelint-disable property-no-unknown, selector-pseudo-element-no-unknown */
+@media not ( prefers-reduced-motion: reduce ) {
+
+ .date-comparison-dropdown__popover {
+ view-transition-name: jp-premium-analytics--date-comparison-dropdown;
+ transition: none !important;
+ }
+}
+
+/* ensure it's above the canvas/stage during the transition */
+::view-transition-group(jp-premium-analytics--date-comparison-dropdown) {
+ z-index: 3000;
+}
+
+/* no animation for the snapshot (avoid "flashing") */
+::view-transition-new(jp-premium-analytics--date-comparison-dropdown),
+::view-transition-old(jp-premium-analytics--date-comparison-dropdown) {
+ animation: none;
+}
+/* stylelint-enable property-no-unknown, selector-pseudo-element-no-unknown */
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.tsx b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.tsx
new file mode 100644
index 000000000000..34f39700b781
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.tsx
@@ -0,0 +1,168 @@
+/**
+ * External dependencies
+ */
+import { formatDateRange } from '@jetpack-premium-analytics/formatters';
+import { privateApis as componentsPrivateApis } from '@wordpress/components';
+import { sprintf, __ } from '@wordpress/i18n';
+import { Button } from '@wordpress/ui';
+import { useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import { DateRangePresets } from '../date-range-presets';
+import { unlock } from '../lock/unlock';
+import type { ComparisonDateRangePreset } from '../use-comparison-date-presets';
+import type {
+ ComparisonPresetId,
+ DateRangePreset,
+ PrimaryPresetId,
+} from '@jetpack-premium-analytics/datetime';
+import './date-comparison-dropdown.scss';
+
+const { Menu } = unlock( componentsPrivateApis );
+
+type DateComparisonDropdownProps = {
+ /**
+ * Available comparison presets (e.g., previous-period, previous-month)
+ */
+ presets: ComparisonDateRangePreset[];
+ /**
+ * Whether comparison is enabled
+ */
+ enabled: boolean;
+ /**
+ * Currently selected comparison preset ID
+ */
+ presetId?: ComparisonPresetId;
+ /**
+ * Whether to remove "Compare to:" prefix from button label
+ */
+ removeCompareToPrefix?: boolean;
+ /**
+ * Callback when comparison is enabled
+ */
+ onEnable: () => void;
+ /**
+ * Callback when a comparison preset is selected
+ */
+ onPresetChange: ( id: ComparisonPresetId ) => void;
+ /**
+ * Callback when comparison is cleared
+ */
+ onClear: () => void;
+};
+
+export function DateComparisonDropdown( {
+ presets,
+ enabled,
+ presetId,
+ removeCompareToPrefix = false,
+ onEnable,
+ onPresetChange,
+ onClear,
+}: DateComparisonDropdownProps ) {
+ const selectedPreset = useMemo(
+ () => ( presetId ? presets.find( p => p.id === presetId ) : undefined ),
+ [ presets, presetId ]
+ );
+
+ const comparisonRange = selectedPreset?.range;
+ const hasValidPreset = !! comparisonRange;
+ const hasPresets = presets.length > 0;
+
+ if ( ! enabled ) {
+ return (
+
+ );
+ }
+
+ let label: string = __( 'Select comparison', 'jetpack-premium-analytics' );
+ if ( hasValidPreset ) {
+ if ( removeCompareToPrefix ) {
+ label = formatDateRange( comparisonRange );
+ } else {
+ label = sprintf(
+ // translators: %s is the comparison range label
+ __( 'Compare to: %s', 'jetpack-premium-analytics' ),
+ formatDateRange( comparisonRange )
+ );
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/index.ts
new file mode 100644
index 000000000000..89ca07f36f91
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/index.ts
@@ -0,0 +1 @@
+export { DateComparisonDropdown } from './date-comparison-dropdown';
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/stories/date-comparison-dropdown.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/stories/date-comparison-dropdown.stories.tsx
new file mode 100644
index 000000000000..ba8bc33340e3
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/stories/date-comparison-dropdown.stories.tsx
@@ -0,0 +1,96 @@
+import { subDays, startOfDay, endOfDay } from 'date-fns';
+import { useState } from 'react';
+import { useComparisonDatePresets } from '../../use-comparison-date-presets';
+import { DateComparisonDropdown } from '../date-comparison-dropdown';
+import type { DateRange } from '../../date-range-popover';
+import type { ComparisonPresetId } from '@jetpack-premium-analytics/datetime';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const meta: Meta< typeof DateComparisonDropdown > = {
+ title: 'Packages/Premium Analytics/UI/DateComparisonDropdown',
+ component: DateComparisonDropdown,
+ tags: [ 'autodocs' ],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'A dropdown component for selecting date comparison ranges. ' +
+ 'Supports enabling/disabling comparison and selecting from preset comparison periods.',
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj< typeof DateComparisonDropdown >;
+
+const today = new Date();
+const defaultRange: DateRange = {
+ from: startOfDay( subDays( today, 30 ) ),
+ to: endOfDay( subDays( today, 1 ) ),
+};
+
+function DateComparisonDropdownWithState( {
+ initialEnabled = true,
+ initialPresetId = 'previous-period',
+ removeCompareToPrefix = false,
+}: {
+ initialEnabled?: boolean;
+ initialPresetId?: ComparisonPresetId;
+ removeCompareToPrefix?: boolean;
+} ) {
+ const [ enabled, setEnabled ] = useState( initialEnabled );
+ const [ presetId, setPresetId ] = useState< ComparisonPresetId | undefined >(
+ initialEnabled ? initialPresetId : undefined
+ );
+
+ const presets = useComparisonDatePresets( defaultRange );
+
+ return (
+ {
+ setEnabled( true );
+ setPresetId( 'previous-period' );
+ } }
+ onPresetChange={ setPresetId }
+ onClear={ () => {
+ setEnabled( false );
+ setPresetId( undefined );
+ } }
+ />
+ );
+}
+
+/**
+ * Default state with comparison enabled and "Previous period" selected.
+ */
+export const Default: Story = {
+ render: () => ,
+};
+
+/**
+ * Comparison disabled - shows "No comparison" button.
+ * Clicking opens a menu to enable comparison.
+ */
+export const Disabled: Story = {
+ render: () => ,
+};
+
+/**
+ * With "Previous month" preset selected.
+ */
+export const PreviousMonthSelected: Story = {
+ render: () => ,
+};
+
+/**
+ * Without the "Compare:" prefix - just shows the date range.
+ */
+export const WithoutPrefix: Story = {
+ render: () => ,
+};
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx
new file mode 100644
index 000000000000..dd9f6a3fc189
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx
@@ -0,0 +1,229 @@
+/**
+ * External dependencies
+ */
+import {
+ isComparisonPresetId,
+ isPrimaryPreset,
+ type ComparisonPresetId,
+ type PrimaryPresetId,
+} from '@jetpack-premium-analytics/datetime';
+import { BaseControl } from '@wordpress/components';
+import { Stack } from '@wordpress/ui';
+import { useMemo, useCallback } from 'react';
+/**
+ * Internal dependencies
+ */
+import { DateComparisonDropdown } from '../date-comparison-dropdown';
+import { DateRangePopover } from '../date-range-popover';
+import { useComparisonDatePresets } from '../use-comparison-date-presets';
+
+type DateRangePopoverProps = Parameters< typeof DateRangePopover >[ 0 ];
+
+export type DateRange = DateRangePopoverProps[ 'range' ];
+
+export type DateFiltersPanelProps = {
+ /**
+ * The current date range preset ID (e.g., 'last-7-days', 'last-30-days').
+ */
+ presetId?: PrimaryPresetId;
+
+ /**
+ * The current primary date range.
+ */
+ range: DateRange;
+
+ /**
+ * The current comparison preset ID (e.g., 'previous-period', 'previous-month').
+ */
+ comparisonPresetId?: ComparisonPresetId;
+
+ /**
+ * Callback when the primary date range changes.
+ */
+ onChange: DateRangePopoverProps[ 'onChange' ];
+
+ /**
+ * Callback when the comparison date range changes.
+ * Receives the calculated comparison range and the preset ID used.
+ */
+ onComparisonChange: ( range: DateRange | undefined, presetId?: ComparisonPresetId ) => void;
+
+ /**
+ * Props for the date range popover.
+ */
+ rangeControlProps?: Omit< Parameters< typeof BaseControl >[ 0 ], 'children' >;
+
+ /**
+ * Props for the date comparison dropdown.
+ */
+ comparisonControlProps?: Omit< Parameters< typeof BaseControl >[ 0 ], 'children' >;
+
+ /**
+ * Callback when the primary date range is applied.
+ */
+ onApply: DateRangePopoverProps[ 'onApply' ];
+
+ /**
+ * Callback when the primary date range is canceled.
+ */
+ onCancel: DateRangePopoverProps[ 'onCancel' ];
+
+ /**
+ * Whether the primary date range can be applied.
+ */
+ canApply?: boolean;
+
+ /**
+ * IANA timezone string (e.g., 'America/New_York', 'Europe/London').
+ * Required for proper date/time handling.
+ */
+ timeZone: string;
+
+ /**
+ * Optional external container element for responsive calculations.
+ * When provided, the DateRangePopover will measure this container's width
+ * instead of its own wrapper to determine mobile/wide layouts.
+ */
+ containerElement?: HTMLElement | null;
+};
+
+/**
+ * DateFiltersPanel - Manages date range selection and comparison controls
+ *
+ * This component serves as the container for date filtering functionality,
+ * managing both the primary date range selection and the comparison date range.
+ * It owns the comparison state and delegates to child components for UI.
+ */
+export function DateFiltersPanel( {
+ presetId,
+ range,
+ comparisonPresetId,
+ onChange,
+ onComparisonChange,
+ rangeControlProps = {
+ label: null,
+ help: null,
+ },
+ comparisonControlProps = {
+ label: null,
+ help: null,
+ },
+ onApply,
+ onCancel,
+ canApply = true,
+ timeZone,
+ containerElement,
+}: DateFiltersPanelProps ) {
+ /**
+ * Validate and normalize the primary preset ID.
+ * Only accepts built-in preset IDs (including 'custom').
+ * Invalid/unknown values are treated as undefined, which allows
+ * DateRangePopover to handle them gracefully (falls back to custom).
+ */
+ const validatedPresetId = useMemo( () => {
+ if ( ! presetId ) {
+ return undefined;
+ }
+ // Only accept known built-in presets
+ // Unknown/garbage values from URL are rejected to prevent UI inconsistency
+ return isPrimaryPreset( presetId ) ? presetId : undefined;
+ }, [ presetId ] );
+
+ // Validate and normalize the comparison preset ID
+ const validatedComparisonPresetId = useMemo( () => {
+ return isComparisonPresetId( comparisonPresetId ) ? comparisonPresetId : undefined;
+ }, [ comparisonPresetId ] );
+
+ // Derive comparison enabled state directly from validated prop
+ const comparisonEnabled = !! validatedComparisonPresetId;
+
+ // Get available presets for the current range
+ const presets = useComparisonDatePresets( range );
+
+ /**
+ * Determines the default preset ID to use when comparison is enabled.
+ * Priority order:
+ * 1. 'previous-period'
+ * 2. 'previous-month'
+ * 3. First available preset
+ */
+ const defaultPresetId = useMemo( () => {
+ return (
+ presets.find( p => p.id === 'previous-period' )?.id ??
+ presets.find( p => p.id === 'previous-month' )?.id ??
+ presets[ 0 ]?.id
+ );
+ }, [ presets ] );
+
+ /**
+ * Currently selected comparison preset,
+ * based on the validated stored preset ID, or the default preset.
+ * Returns undefined if no preset is selected
+ * or if the ID doesn't match any available preset.
+ */
+ const preset = useMemo( () => {
+ const id = validatedComparisonPresetId ?? defaultPresetId;
+ return id ? presets.find( p => p.id === id ) : undefined;
+ }, [ presets, validatedComparisonPresetId, defaultPresetId ] );
+
+ const presetChange = useCallback(
+ ( id: ComparisonPresetId ) => {
+ const nextPreset = presets.find( p => p.id === id );
+ onComparisonChange( nextPreset?.range, id );
+ },
+ [ onComparisonChange, presets ]
+ );
+
+ /**
+ * Handles clearing the comparison completely.
+ * Clears the selected preset and notifies parent.
+ */
+ const clearComparison = useCallback( () => {
+ onComparisonChange( undefined, undefined );
+ }, [ onComparisonChange ] );
+
+ const handleEnable = useCallback( () => {
+ // Use validated ID with fallback to default
+ const presetIdToUse = validatedComparisonPresetId ?? defaultPresetId;
+ if ( preset?.range && presetIdToUse ) {
+ onComparisonChange( preset.range, presetIdToUse );
+ }
+ }, [ onComparisonChange, preset, validatedComparisonPresetId, defaultPresetId ] );
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/index.ts
new file mode 100644
index 000000000000..9a1d3322329e
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/index.ts
@@ -0,0 +1 @@
+export { DateFiltersPanel } from './date-filters-panel';
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.scss b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.scss
new file mode 100644
index 000000000000..f0ebd4c84d75
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.scss
@@ -0,0 +1,38 @@
+.input-date-control {
+ flex: 1;
+ font-size: var(--wpds-typography-font-size-sm);
+
+ @supports selector( &::-webkit-calendar-picker-indicator ) {
+
+ input[type="date"] {
+ // Removes extra spaces for the calendar icon.
+ width: fit-content;
+ padding-right: 0;
+
+ appearance: none;
+ -webkit-appearance: none;
+ background: none;
+
+ font-size: var(--wpds-typography-font-size-md);
+
+ // Removes the calendar icon.
+ &::-webkit-calendar-picker-indicator {
+ display: none;
+ }
+ }
+ }
+
+ @supports not selector( &::-webkit-calendar-picker-indicator ) {
+
+ input[type="date"] {
+ padding: 0; // We'll control input's inner spacing manually
+
+ min-width: fit-content; // Prevent extra space on smaller screens
+
+ // Use flex to center the input's content horizontally
+ display: flex;
+ width: calc(100% - 20px); // Take almost all the space
+ margin-inline: auto; // Keep things centered
+ }
+ }
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx
new file mode 100644
index 000000000000..562fe3f07f98
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx
@@ -0,0 +1,103 @@
+/**
+ * External dependencies
+ */
+import { createTZDateFromParts } from '@jetpack-premium-analytics/datetime';
+import { formatDate } from '@jetpack-premium-analytics/formatters';
+import { __ } from '@wordpress/i18n';
+import { Field, Input, Stack } from '@wordpress/ui';
+import { useCallback, useEffect, useState } from 'react';
+/**
+ * Internal dependencies
+ */
+import { DateRangePopover } from '../date-range-popover/date-range-filter';
+import './date-range-input.scss';
+
+type DateRangeInputProps = Pick<
+ Parameters< typeof DateRangePopover >[ 0 ],
+ 'range' | 'onChange'
+> & {
+ timeZone: string;
+};
+
+type DateInputProps = Pick< DateRangeInputProps, 'timeZone' > & {
+ label: string;
+ date?: Date;
+ onChange: ( date?: Date ) => void;
+};
+
+const formatToString = ( date?: Date ) => ( date ? formatDate( date, 'iso' ) : '' );
+
+function parseFromString( dateString: string, timeZone: string ) {
+ const [ year, month, day ] = dateString.split( '-' ).map( x => Number( x ) );
+
+ const parsedDate = createTZDateFromParts( [ year, month - 1, day ], timeZone );
+
+ return ! isNaN( parsedDate.getTime() ) ? parsedDate : undefined;
+}
+
+function DateInput( { label, date, onChange, timeZone }: DateInputProps ) {
+ const [ value, setValue ] = useState( formatToString( date ) );
+
+ useEffect( () => {
+ setValue( formatToString( date ) );
+ }, [ date ] );
+
+ const onInputChange = useCallback(
+ ( event: React.ChangeEvent< HTMLInputElement > ) => {
+ const newValue = event.target.value;
+ setValue( newValue );
+
+ const newDate = parseFromString( newValue, timeZone );
+
+ // Call onChange only when the date is complete and reasonable, to avoid unwanted updates.
+ // Also avoids parseFromString auto-filling partial input (e.g. "20" → "1920").
+ if ( newDate && newDate.getFullYear() > 2000 ) {
+ onChange( newDate );
+ }
+ },
+ [ onChange, timeZone ]
+ );
+
+ const onClick = useCallback( ( e: React.MouseEvent ) => {
+ // Prevents the date input from opening the browser date picker,
+ // as we want to use a custom date picker elsewhere.
+ e.preventDefault();
+ }, [] );
+
+ return (
+
+ { label }
+
+
+ );
+}
+
+export function DateRangeInput( { range, onChange, timeZone }: DateRangeInputProps ) {
+ const { from, to } = range;
+
+ return (
+
+ {
+ if ( nextFrom && to && nextFrom <= to ) {
+ onChange( { from: nextFrom, to } );
+ }
+ } }
+ />
+
+ {
+ if ( nextTo && from && from <= nextTo ) {
+ onChange( { from, to: nextTo } );
+ }
+ } }
+ />
+
+ );
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-input/index.ts
new file mode 100644
index 000000000000..d42bdcd634fe
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/index.ts
@@ -0,0 +1 @@
+export { DateRangeInput } from './date-range-input';
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.scss b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.scss
new file mode 100644
index 000000000000..7dfaaac4eed1
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.scss
@@ -0,0 +1,106 @@
+@use "@wordpress/base-styles/variables" as vars;
+@use "@wordpress/base-styles/colors" as colors;
+
+.date-range-popover-content {
+ --wca-popover-padding: var(--wpds-dimension-padding-lg);
+ --wca-popover-border-color: #{colors.$gray-300};
+ --wca-popover-border-width: #{vars.$border-width};
+
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: 1fr auto;
+
+ // Grid lines: background = line color, gap = line width
+ background-color: var(--wca-popover-border-color);
+ column-gap: var(--wca-popover-border-width);
+ row-gap: var(--wca-popover-border-width);
+
+ .date-range-calendar {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ // Mobile layout: override grid to use flex column
+ &--mobile {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-gap-lg);
+ padding: var(--wca-popover-padding);
+ background-color: #fff;
+
+ .date-range-calendar {
+ --a8c-calendar-button-width: 32px;
+ --a8c-calendar-button-height: 32px;
+ }
+
+ .date-range-popover-actions {
+ padding: 0;
+ padding-block-start: var(--wpds-dimension-gap-sm);
+ }
+ }
+}
+
+.date-range-presets-wrapper {
+ grid-row: 1;
+ display: grid;
+ grid-template-columns: minmax(0, max-content) 1fr;
+ background-color: #fff;
+ padding-bottom: var(--wpds-dimension-gap-sm);
+ width: calc(60 * var(--wpds-dimension-base));
+ padding-right: var(--wpds-dimension-padding-sm);
+}
+
+.date-filters-panel-button {
+ background-color: #fff; // ToDo: handle this upstream.
+}
+
+.date-range-calendar-wrapper {
+ --wca-calendar-button-width: 32px;
+ --wca-calendar-button-height: 32px;
+ --wca-calendar-button-gap: 1rem; // consistent with automattic/ui style
+ --wca-calendar-padding: var(--wca-popover-padding);
+ --wca-calendar-width: calc(var(--wca-calendar-button-width) * 7 + var(--wca-calendar-padding) * 2);
+ --wca-calendar-width-wide: calc(var(--wca-calendar-button-width) * 14 + var(--wca-calendar-padding) * 2 + var(--wca-calendar-button-gap));
+
+ grid-row: 1;
+ padding: var(--wca-calendar-padding);
+ width: var(--wca-calendar-width);
+ background-color: #fff;
+
+ .date-range-calendar {
+ --a8c-calendar-button-width: var(--wca-calendar-button-width);
+ --a8c-calendar-button-height: var(--wca-calendar-button-height);
+ }
+
+ &__wide {
+ width: var(--wca-calendar-width-wide);
+ }
+}
+
+.date-range-popover-actions {
+ grid-column: 1 / -1;
+ padding: calc(var(--wca-popover-padding) / 2) var(--wca-popover-padding);
+ background-color: #fff;
+}
+
+/* disable animation for the date range popover */
+/* stylelint-disable property-no-unknown, selector-pseudo-element-no-unknown */
+@media not ( prefers-reduced-motion: reduce ) {
+
+ .date-filters-panel__popover {
+ view-transition-name: jp-premium-analytics--date-range-popover;
+ }
+}
+
+/* ensure it's above the canvas/stage during the transition */
+::view-transition-group(jp-premium-analytics--date-range-popover) {
+ z-index: 3000;
+}
+
+/* no animation for the snapshot (avoid "flashing") */
+::view-transition-new(jp-premium-analytics--date-range-popover),
+::view-transition-old(jp-premium-analytics--date-range-popover) {
+ animation: none;
+}
+/* stylelint-enable property-no-unknown, selector-pseudo-element-no-unknown */
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx
new file mode 100644
index 000000000000..926074092897
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx
@@ -0,0 +1,373 @@
+/**
+ * External dependencies
+ */
+import { DateRangeCalendar } from '@automattic/ui';
+import {
+ getPresetLabel,
+ getDefaultDateRangePresets,
+ PRESET_CUSTOM,
+ type PrimaryPresetId,
+ type DateRangePreset,
+} from '@jetpack-premium-analytics/datetime';
+import { formatDateRange } from '@jetpack-premium-analytics/formatters';
+import {
+ Dropdown,
+ SelectControl,
+ privateApis as componentsPrivateApis,
+} from '@wordpress/components';
+import { useResizeObserver } from '@wordpress/compose';
+import { __ } from '@wordpress/i18n';
+import { calendar } from '@wordpress/icons';
+import { Badge, Button, Stack } from '@wordpress/ui';
+import clsx from 'clsx';
+import { useState, useCallback, useMemo, useEffect } from 'react';
+import '@automattic/ui/style.css';
+/**
+ * Internal dependencies
+ */
+import { DateRangeInput } from '../date-range-input';
+import { DateRangePresets } from '../date-range-presets';
+import { unlock } from '../lock/unlock';
+import './date-range-filter.scss';
+
+const { Menu } = unlock( componentsPrivateApis );
+
+/**
+ * Threshold width (in pixels) below which we consider the layout "mobile".
+ * This is based on the container width, not the viewport.
+ */
+const MOBILE_CONTAINER_WIDTH_THRESHOLD = 480;
+
+/**
+ * Date range type from @automattic/ui.
+ * Represents a range with `from` and `to` Date objects.
+ */
+export type DateRange = NonNullable< Parameters< typeof DateRangeCalendar >[ 0 ][ 'selected' ] >;
+
+/**
+ * Props for DateRangePopoverContent component.
+ */
+type DateRangePopoverContentProps = {
+ /**
+ * Currently selected preset identifier
+ */
+ presetId?: PrimaryPresetId;
+
+ /**
+ * The selected date range
+ */
+ range: DateRange;
+
+ /**
+ * Callback when range or preset changes
+ */
+ onChange: ( range?: DateRange, preset?: PrimaryPresetId ) => void;
+
+ /**
+ * Callback when user applies the selection
+ */
+ onApply: () => void;
+
+ /**
+ * Callback when user cancels the selection
+ */
+ onCancel: () => void;
+
+ /**
+ * Whether the Apply button should be enabled
+ */
+ canApply: boolean;
+
+ /**
+ * Whether to show wide screen layout (2 months)
+ */
+ isWideScreen?: boolean;
+
+ /**
+ * Whether to show mobile layout (dropdown presets instead of sidebar)
+ */
+ isMobile?: boolean;
+
+ /**
+ * IANA timezone string (e.g., 'America/New_York', 'Europe/London').
+ * Required for proper date/time handling.
+ */
+ timeZone: string;
+};
+
+/**
+ * Props for DateRangePresetsDropdown component.
+ */
+type DateRangePresetsDropdownProps = {
+ value: PrimaryPresetId | null;
+ onRangeChange: ( range: DateRange, id: PrimaryPresetId ) => void;
+ presets?: DateRangePreset[];
+ timeZone: string;
+};
+
+function getDisplayedMonth( range: DateRange ): Date {
+ return range?.from ?? new Date();
+}
+
+/**
+ * Action buttons for the date range popover (Cancel/Apply).
+ */
+function DateRangePopoverActions( {
+ onCancel,
+ onApply,
+ canApply,
+}: Pick< DateRangePopoverContentProps, 'onCancel' | 'onApply' | 'canApply' > ) {
+ return (
+
+
+
+
+ );
+}
+
+/**
+ * Dropdown version of DateRangePresets for mobile layout.
+ * Displays presets as a SelectControl instead of a menu list.
+ */
+function DateRangePresetsDropdown( {
+ value,
+ onRangeChange,
+ presets: presetsProp,
+ timeZone,
+}: DateRangePresetsDropdownProps ) {
+ const defaultPresets = useMemo(
+ () => ( presetsProp ? [] : getDefaultDateRangePresets( timeZone ) ),
+ [ presetsProp, timeZone ]
+ );
+ const presets = presetsProp || defaultPresets;
+
+ const options = useMemo(
+ () => [
+ ...presets.map( ( { id, label } ) => ( {
+ value: id,
+ label,
+ } ) ),
+ {
+ value: PRESET_CUSTOM,
+ label: __( 'Custom range', 'jetpack-premium-analytics' ),
+ },
+ ],
+ [ presets ]
+ );
+
+ const handleChange = useCallback(
+ ( selectedValue: string ) => {
+ const preset = presets.find( p => p.id === selectedValue );
+ if ( preset ) {
+ onRangeChange( preset.range, preset.id );
+ }
+ },
+ [ presets, onRangeChange ]
+ );
+
+ return (
+
+ );
+}
+
+/**
+ * Content of the DateRangePopover, extracted for Storybook visualization.
+ * This component is exported for internal use only (stories, testing).
+ */
+export function DateRangePopoverContent( {
+ presetId,
+ range,
+ onChange,
+ onApply,
+ onCancel,
+ canApply,
+ isWideScreen = false,
+ isMobile = false,
+ timeZone,
+}: DateRangePopoverContentProps ) {
+ const [ displayedMonth, setDisplayedMonth ] = useState( getDisplayedMonth( range ) );
+
+ const handleChange = ( nextRange?: DateRange, nextPrimaryPresetId?: PrimaryPresetId ) => {
+ if ( nextRange ) {
+ setDisplayedMonth( getDisplayedMonth( nextRange ) );
+ }
+
+ // If nextPrimaryPresetId is undefined, the user manually changed the dates
+ // (via calendar or input fields), so we switch to PRESET_CUSTOM
+ const effectivePrimaryPresetId = nextPrimaryPresetId ?? PRESET_CUSTOM;
+
+ onChange( nextRange, effectivePrimaryPresetId );
+ };
+
+ // Mobile layout: single column with dropdown presets
+ if ( isMobile ) {
+ return (
+
+
+
+
+
+ handleChange( nextRange ) }
+ numberOfMonths={ 1 }
+ month={ displayedMonth }
+ onMonthChange={ setDisplayedMonth }
+ timeZone={ timeZone }
+ />
+
+
+
+ );
+ }
+
+ // Desktop layout: grid with sidebar presets
+ return (
+
+
+
+
+
+
+
+
+ handleChange( nextRange ) }
+ numberOfMonths={ isWideScreen ? 2 : 1 }
+ month={ displayedMonth }
+ onMonthChange={ setDisplayedMonth }
+ timeZone={ timeZone }
+ />
+
+
+
+
+ );
+}
+
+type DateRangePopoverProps = Omit< DateRangePopoverContentProps, 'isWideScreen' | 'isMobile' > & {
+ /**
+ * Optional external container element for responsive calculations.
+ * When provided, the component will measure this container's width
+ * instead of its own wrapper to determine mobile/wide layouts.
+ */
+ containerElement?: HTMLElement | null;
+};
+
+/**
+ * Threshold width (in pixels) for showing 2 months in calendar.
+ * Based on CSS: --wca-calendar-width-wide (~500px for 2 months + presets sidebar)
+ */
+const WIDE_CONTAINER_THRESHOLD = 780;
+
+export function DateRangePopover( {
+ presetId,
+ range,
+ onChange,
+ onApply,
+ onCancel,
+ canApply,
+ timeZone,
+ containerElement,
+}: DateRangePopoverProps ) {
+ const [ containerWidth, setContainerWidth ] = useState< number | null >( null );
+
+ // Callback to update container width
+ const handleResize = useCallback( ( entries: ResizeObserverEntry[] ) => {
+ const entry = entries[ 0 ];
+ if ( entry ) {
+ setContainerWidth( entry.contentRect.width );
+ }
+ }, [] );
+
+ // ResizeObserver for the reference container
+ const setObserverRef = useResizeObserver< HTMLElement >( handleResize );
+
+ // Attach observer to containerElement if provided, otherwise use document.body
+ useEffect( () => {
+ const element = containerElement ?? document.body;
+ setObserverRef( element );
+ }, [ containerElement, setObserverRef ] );
+
+ // Determine layout based on container width
+ const isMobile = containerWidth !== null && containerWidth < MOBILE_CONTAINER_WIDTH_THRESHOLD;
+
+ const isWideScreen = containerWidth !== null && containerWidth >= WIDE_CONTAINER_THRESHOLD;
+
+ const presetLabel = getPresetLabel( presetId );
+
+ return (
+ (
+
+ ) }
+ renderContent={ ( { onClose } ) => (
+ {
+ onApply();
+ onClose();
+ } }
+ onCancel={ () => {
+ onCancel();
+ onClose();
+ } }
+ canApply={ canApply }
+ isWideScreen={ isWideScreen }
+ isMobile={ isMobile }
+ timeZone={ timeZone }
+ />
+ ) }
+ />
+ );
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts
new file mode 100644
index 000000000000..9d4c1f786baf
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts
@@ -0,0 +1,2 @@
+export { DateRangePopover, DateRangePopoverContent } from './date-range-filter';
+export type { DateRange } from './date-range-filter';
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx
new file mode 100644
index 000000000000..8c6ef8640763
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx
@@ -0,0 +1,173 @@
+import { subDays, startOfDay, endOfDay } from 'date-fns';
+import { useState } from 'react';
+import { DateRangePopover, DateRangePopoverContent } from '../date-range-filter';
+import type { DateRange } from '../date-range-filter';
+import type { PrimaryPresetId } from '@jetpack-premium-analytics/datetime';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const meta: Meta< typeof DateRangePopover > = {
+ title: 'Packages/Premium Analytics/UI/DateRangePopover',
+ component: DateRangePopover,
+ tags: [ 'autodocs' ],
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+
+type Story = StoryObj< typeof DateRangePopover >;
+
+const today = new Date();
+const defaultRange: DateRange = {
+ from: startOfDay( subDays( today, 7 ) ),
+ to: endOfDay( subDays( today, 1 ) ),
+};
+
+// Default timezone for Storybook - avoids dependency on WordPress stores
+const STORYBOOK_TIMEZONE = 'America/New_York';
+
+function DateRangePopoverWithState() {
+ const [ range, setRange ] = useState< DateRange >( defaultRange );
+ const [ presetId, setPrimaryPresetId ] = useState< PrimaryPresetId >( 'last-7-days' );
+ const [ pendingRange, setPendingRange ] = useState< DateRange >( defaultRange );
+ const [ pendingPrimaryPresetId, setPendingPrimaryPresetId ] =
+ useState< PrimaryPresetId >( 'last-7-days' );
+
+ const handleChange = ( nextRange?: DateRange, nextPrimaryPresetId?: PrimaryPresetId ) => {
+ if ( nextRange ) {
+ setPendingRange( nextRange );
+ }
+ if ( nextPrimaryPresetId ) {
+ setPendingPrimaryPresetId( nextPrimaryPresetId );
+ }
+ };
+
+ const handleApply = () => {
+ setRange( pendingRange );
+ setPrimaryPresetId( pendingPrimaryPresetId );
+ };
+
+ const handleCancel = () => {
+ setPendingRange( range );
+ setPendingPrimaryPresetId( presetId );
+ };
+
+ const canApply = pendingRange.from !== range.from || pendingRange.to !== range.to;
+
+ return (
+
+ );
+}
+
+export const Default: Story = {
+ render: () => ,
+};
+
+function DateRangePopoverCustomPreset() {
+ const [ range, setRange ] = useState< DateRange >( {
+ from: startOfDay( subDays( today, 14 ) ),
+ to: endOfDay( subDays( today, 3 ) ),
+ } );
+
+ return (
+ nextRange && setRange( nextRange ) }
+ onApply={ () => {} }
+ onCancel={ () => {} }
+ canApply={ true }
+ timeZone={ STORYBOOK_TIMEZONE }
+ />
+ );
+}
+
+/**
+ * `Custom` preset selected.
+ */
+export const CustomPreset: Story = {
+ render: () => ,
+};
+
+function DateRangePopoverTodayPreset() {
+ const todayRange: DateRange = {
+ from: startOfDay( today ),
+ to: endOfDay( today ),
+ };
+ const [ range, setRange ] = useState< DateRange >( todayRange );
+
+ return (
+ nextRange && setRange( nextRange ) }
+ onApply={ () => {} }
+ onCancel={ () => {} }
+ canApply={ false }
+ timeZone={ STORYBOOK_TIMEZONE }
+ />
+ );
+}
+
+/**
+ * `Today` preset selected.
+ */
+export const TodayPreset: Story = {
+ render: () => ,
+};
+
+/**
+ * Interactive DateRangePopoverContent with state management.
+ */
+function PopoverContentWithState( { isWideScreen = false } ) {
+ const [ range, setRange ] = useState< DateRange >( defaultRange );
+ const [ presetId, setPrimaryPresetId ] = useState< PrimaryPresetId >( 'last-7-days' );
+
+ const handleChange = ( nextRange?: DateRange, nextPrimaryPresetId?: PrimaryPresetId ) => {
+ if ( nextRange ) {
+ setRange( nextRange );
+ }
+ if ( nextPrimaryPresetId ) {
+ setPrimaryPresetId( nextPrimaryPresetId );
+ }
+ };
+
+ return (
+ {} }
+ onCancel={ () => {} }
+ canApply={ true }
+ isWideScreen={ isWideScreen }
+ timeZone={ STORYBOOK_TIMEZONE }
+ />
+ );
+}
+
+/**
+ * Interactive DateRangePopoverContent with state management.
+ */
+export const PopoverContent: Story = {
+ render: () => ,
+};
+
+/**
+ * Interactive DateRangePopoverContent with state management.
+ */
+export const PopoverContentWide: Story = {
+ render: () => ,
+};
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.scss b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.scss
new file mode 100644
index 000000000000..446ea4a41439
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.scss
@@ -0,0 +1,29 @@
+@use "@wordpress/base-styles/variables" as vars;
+@use "@wordpress/base-styles/colors" as colors;
+
+.date-range-presets {
+ max-width: 240px;
+}
+
+.date-range-presets,
+.date-range-presets__custom-group {
+
+ .date-range-presets__item {
+ min-height: var(--wpds-typography-line-height-2xl);
+ }
+}
+
+.date-range-presets__custom-group {
+ // Custom button acts as a label, not an interactive element.
+ // Override disabled styles to show selection state visually.
+ .date-range-presets__custom {
+
+ &[aria-disabled="true"] {
+ color: var(--wpds-color-fg-content-neutral-weak);
+ }
+
+ &[aria-checked="true"] {
+ color: var(--wpds-color-fg-content-neutral);
+ }
+ }
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.tsx
new file mode 100644
index 000000000000..f27ac51d264c
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.tsx
@@ -0,0 +1,139 @@
+/**
+ * External dependencies
+ */
+import {
+ PRESET_CUSTOM,
+ getDefaultDateRangePresets,
+ type PrimaryPresetId,
+ type DateRangePreset,
+} from '@jetpack-premium-analytics/datetime';
+import { privateApis as componentsPrivateApis } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import { DateRangePopover } from '../date-range-popover/date-range-filter';
+import { unlock } from '../lock/unlock';
+import './date-range-presets.scss';
+
+const { Menu } = unlock( componentsPrivateApis );
+
+type DateRange = Parameters< typeof DateRangePopover >[ 0 ][ 'range' ];
+
+/**
+ * Props for the DateRangePresets component.
+ */
+type DateRangePresetsProps = {
+ /**
+ * Callback fired when a preset is selected
+ */
+ onRangeChange: ( range: DateRange, id: PrimaryPresetId ) => void;
+
+ /**
+ * Currently selected preset ID, or null if none
+ */
+ value: PrimaryPresetId | null;
+
+ /**
+ * IANA timezone string (e.g., 'America/New_York').
+ * Required when using default presets. Optional if explicit presets are provided.
+ */
+ timeZone?: string;
+
+ /**
+ * Custom presets to display instead of defaults
+ */
+ presets?: DateRangePreset[];
+
+ /**
+ * Whether to show the custom date option
+ */
+ supportCustom?: boolean;
+
+ /**
+ * Optional callback to clear/remove comparison.
+ * When provided, shows a "No comparison" option.
+ */
+ onClear?: () => void;
+
+ /**
+ * Whether clicking a preset item should close the parent popover.
+ * Defaults to undefined (Ariakit default: checkbox items stay open).
+ */
+ hideOnClick?: boolean;
+};
+
+export function DateRangePresets( {
+ onRangeChange,
+ value,
+ timeZone,
+ presets: presetsProp,
+ onClear,
+ hideOnClick,
+}: DateRangePresetsProps ) {
+ const defaultPresets = useMemo( () => {
+ if ( presetsProp ) {
+ return [];
+ }
+
+ if ( ! timeZone ) {
+ throw new Error(
+ 'DateRangePresets: `timeZone` is required when `presets` are not provided.'
+ );
+ }
+
+ return getDefaultDateRangePresets( timeZone );
+ }, [ presetsProp, timeZone ] );
+
+ const presets = useMemo( () => presetsProp || defaultPresets, [ presetsProp, defaultPresets ] );
+
+ return (
+ <>
+
+ { presets.map( ( { id, label, range: presetRange } ) => (
+ onRangeChange( presetRange, id ) }
+ hideOnClick={ hideOnClick }
+ >
+ { label }
+
+ ) ) }
+
+
+
+
+
+
+ { __( 'Custom', 'jetpack-premium-analytics' ) }
+
+
+ { onClear && (
+
+ { __( 'No comparison', 'jetpack-premium-analytics' ) }
+
+ ) }
+
+ >
+ );
+}
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-presets/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/index.ts
new file mode 100644
index 000000000000..ed2d27e2e31b
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/index.ts
@@ -0,0 +1,29 @@
+export { DateRangePresets } from './date-range-presets';
+
+/**
+ * Re-export types, constants, and guards from datetime
+ * so existing consumers of this barrel continue to work.
+ */
+export {
+ getDefaultDateRangePresets,
+ getPresetLabel,
+ isSelectablePreset,
+ isPrimaryPreset,
+ // Preset constants
+ PRESET_TODAY,
+ PRESET_YESTERDAY,
+ PRESET_LAST_7_DAYS,
+ PRESET_LAST_30_DAYS,
+ PRESET_LAST_90_DAYS,
+ PRESET_LAST_365_DAYS,
+ PRESET_LAST_MONTH,
+ PRESET_LAST_12_MONTHS,
+ PRESET_LAST_YEAR,
+ PRESET_CUSTOM,
+} from '@jetpack-premium-analytics/datetime';
+
+export type {
+ PrimaryPresetId,
+ SelectablePresetId,
+ DateRangePreset,
+} from '@jetpack-premium-analytics/datetime';
diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-presets/stories/date-range-presets.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/stories/date-range-presets.stories.tsx
new file mode 100644
index 000000000000..70d1725174b1
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/stories/date-range-presets.stories.tsx
@@ -0,0 +1,103 @@
+import { privateApis as componentsPrivateApis } from '@wordpress/components';
+import { subDays, startOfDay, endOfDay } from 'date-fns';
+import { useState } from 'react';
+import { unlock } from '../../lock/unlock';
+import { DateRangePresets } from '../date-range-presets';
+import type { DateRange } from '../../date-range-popover';
+import type { PrimaryPresetId } from '@jetpack-premium-analytics/datetime';
+import type { Meta, StoryObj } from '@storybook/react';
+
+const { Menu } = unlock( componentsPrivateApis );
+
+/**
+ * Timezone used in stories for consistent date calculations.
+ */
+const STORY_TIMEZONE = 'America/New_York';
+
+const meta: Meta< typeof DateRangePresets > = {
+ title: 'Packages/Premium Analytics/UI/DateRangePresets',
+ component: DateRangePresets,
+ tags: [ 'autodocs' ],
+ decorators: [
+ Story => (
+ // Menu.Group must be wrapped in a Menu to work correctly in Storybook.
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj< typeof DateRangePresets >;
+
+const today = new Date();
+
+function DateRangePresetsWithState( {
+ initialPrimaryPresetId = 'last-7-days',
+}: {
+ initialPrimaryPresetId?: PrimaryPresetId;
+} ) {
+ const [ presetId, setPrimaryPresetId ] = useState< PrimaryPresetId >( initialPrimaryPresetId );
+ const [ , setRange ] = useState< DateRange >( {
+ from: startOfDay( subDays( today, 7 ) ),
+ to: endOfDay( subDays( today, 1 ) ),
+ } );
+
+ const handleChange = ( nextRange: DateRange, nextPrimaryPresetId: PrimaryPresetId ) => {
+ setRange( nextRange );
+ setPrimaryPresetId( nextPrimaryPresetId );
+ };
+
+ return (
+
+ );
+}
+
+export const Default: Story = {
+ render: () => ,
+};
+
+/**
+ * `Today` preset selected.
+ */
+export const TodaySelected: Story = {
+ render: () => ,
+};
+
+/**
+ * `Custom` preset selected.
+ */
+export const CustomSelected: Story = {
+ render: () => ,
+};
+
+function DateRangePresetsNoSelection() {
+ const [ presetId, setPrimaryPresetId ] = useState< PrimaryPresetId | null >( null );
+ const [ , setRange ] = useState< DateRange | null >( null );
+
+ const handleChange = ( nextRange: DateRange, nextPrimaryPresetId: PrimaryPresetId ) => {
+ setRange( nextRange );
+ setPrimaryPresetId( nextPrimaryPresetId );
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * No preset selected.
+ */
+export const NoSelection: Story = {
+ render: () => ,
+};
diff --git a/projects/packages/premium-analytics/packages/ui/src/index.ts b/projects/packages/premium-analytics/packages/ui/src/index.ts
new file mode 100644
index 000000000000..9a1d3322329e
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/index.ts
@@ -0,0 +1 @@
+export { DateFiltersPanel } from './date-filters-panel';
diff --git a/projects/packages/premium-analytics/packages/ui/src/lock/unlock.ts b/projects/packages/premium-analytics/packages/ui/src/lock/unlock.ts
new file mode 100644
index 000000000000..ca799e25b34c
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/lock/unlock.ts
@@ -0,0 +1,22 @@
+/**
+ * Local unlock helper for reaching the private `Menu` component that
+ * `@wordpress/components` exposes through `@wordpress/private-apis`.
+ *
+ * Upstream reached `Menu` via `@automattic/admin-toolkit`'s `unlock`, which is
+ * not available in this monorepo. This mirrors existing Jetpack precedent that
+ * opts in to the private APIs directly:
+ *
+ * - `projects/packages/jetpack-mu-wpcom/src/common/utils.ts` (`getUnlock()`)
+ * - `projects/js-packages/charts/src/stories/unlock.ts`
+ *
+ * The opt-in module name only needs to be an allow-listed core module; the
+ * returned `unlock` reads private data bound to any object, so it resolves the
+ * private APIs locked onto `@wordpress/components`' `privateApis`.
+ */
+
+import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
+
+export const { unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules(
+ 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
+ '@wordpress/components'
+);
diff --git a/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/index.ts b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/index.ts
new file mode 100644
index 000000000000..945c2ce54278
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/index.ts
@@ -0,0 +1,2 @@
+export { useComparisonDatePresets } from './use-comparison-date-presets';
+export type { ComparisonDateRangePreset } from './use-comparison-date-presets';
diff --git a/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts
new file mode 100644
index 000000000000..1a018083db1c
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import {
+ getComparisonRangeFromPreset,
+ getComparisonPresetConfigs,
+ type ComparisonPresetId,
+} from '@jetpack-premium-analytics/datetime';
+import { useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import type { DateRange } from '../date-range-popover/date-range-filter';
+
+/**
+ * A comparison-specific date range preset.
+ * Similar to DateRangePreset but with a strongly-typed ComparisonPresetId.
+ */
+export type ComparisonDateRangePreset = {
+ id: ComparisonPresetId;
+ label: string;
+ range: DateRange;
+};
+
+/**
+ * Custom hook that generates comparison date presets
+ * based on a reference date range.
+ *
+ * @param referenceRange - The primary date range to compare against
+ * @return Array of comparison presets with strongly-typed IDs
+ */
+export function useComparisonDatePresets( referenceRange: DateRange ): ComparisonDateRangePreset[] {
+ return useMemo( () => {
+ if ( ! referenceRange.from || ! referenceRange.to ) {
+ return [];
+ }
+
+ return getComparisonPresetConfigs()
+ .map( ( { id, label } ) => {
+ const range = getComparisonRangeFromPreset( referenceRange, id );
+ return range ? { id, label, range } : null;
+ } )
+ .filter( ( preset ): preset is ComparisonDateRangePreset => preset !== null );
+ }, [ referenceRange ] );
+}