diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39be62eeb6a7..8f2900069e6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3829,12 +3829,21 @@ 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 '@wordpress/boot': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/components': + specifier: 33.1.0 + version: 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': + specifier: 7.46.0 + version: 7.46.0(react@18.3.1) '@wordpress/data': specifier: 10.46.0 version: 10.46.0(react@18.3.1) @@ -3844,9 +3853,18 @@ importers: '@wordpress/icons': specifier: ^13.0.0 version: 13.1.0(react@18.3.1) + '@wordpress/private-apis': + specifier: 1.46.0 + version: 1.46.0 '@wordpress/route': specifier: 0.12.0 version: 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/ui': + specifier: 0.13.0 + version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: + specifier: 2.1.1 + version: 2.1.1 date-fns: specifier: 4.1.0 version: 4.1.0 @@ -3866,9 +3884,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.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.46.0)(@wordpress/route@0.12.0(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 @@ -23985,7 +24006,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(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.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.46.0)(@wordpress/route@0.12.0(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 @@ -24005,6 +24026,7 @@ snapshots: sass-embedded: 1.97.3 optionalDependencies: '@wordpress/boot': 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/private-apis': 1.46.0 '@wordpress/route': 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@babel/core' 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 6b5bdb16906e..cf5c8fdf69ba 100644 --- a/projects/packages/premium-analytics/eslint.config.mjs +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -27,5 +27,21 @@ export default defineConfig( 'jsdoc/require-returns': 'off', 'jsdoc/check-indentation': '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', + }, } ); diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index e50feb5cb962..4e1ba3ad66c7 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -30,12 +30,18 @@ }, "dependencies": { "@automattic/number-formatters": "workspace:*", + "@automattic/ui": "1.0.2", "@date-fns/tz": "1.4.1", "@wordpress/boot": "0.13.0", + "@wordpress/components": "33.1.0", + "@wordpress/compose": "7.46.0", "@wordpress/data": "10.46.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", + "@wordpress/private-apis": "1.46.0", "@wordpress/route": "0.12.0", + "@wordpress/ui": "0.13.0", + "clsx": "2.1.1", "date-fns": "4.1.0", "react": "18.3.1", "react-dom": "18.3.1" @@ -44,6 +50,7 @@ "@babel/core": "7.29.0", "@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", "browserslist": "4.28.2" } 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..1aae2e2cda00 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/package.json @@ -0,0 +1,24 @@ +{ + "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" + } +} 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..29fea4bf18a3 --- /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: next-admin--date-comparison-dropdown; + transition: none !important; + } +} + +/* ensure it's above the canvas/stage during the transition */ +::view-transition-group(next-admin--date-comparison-dropdown) { + z-index: 3000; +} + +/* no animation for the snapshot (avoid "flashing") */ +::view-transition-new(next-admin--date-comparison-dropdown), +::view-transition-old(next-admin--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 ( + + + { __( 'No comparison', 'jetpack-premium-analytics' ) } + + } + /> + + + + + { __( 'No comparison', 'jetpack-premium-analytics' ) } + + + + + + { __( 'Comparison to past', 'jetpack-premium-analytics' ) } + + + + + + ); + } + + 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 ( + + + { label } + + } + /> + + { hasPresets && ( + { + /* + * Type assertion is safe here because: + * 1. presets is ComparisonDateRangePreset[] (strongly typed) + * 2. DateRangePresets picks id from our presets array + * 3. Therefore id must be ComparisonPresetId + */ + onPresetChange( id as ComparisonPresetId ); + } } + onClear={ onClear } + /> + ) } + + + ); +} 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-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..aa17958cb048 --- /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: next-admin--date-range-popover; + } +} + +/* ensure it's above the canvas/stage during the transition */ +::view-transition-group(next-admin--date-range-popover) { + z-index: 3000; +} + +/* no animation for the snapshot (avoid "flashing") */ +::view-transition-new(next-admin--date-range-popover), +::view-transition-old(next-admin--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-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/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 ] ); +}