From cb79c173ca43898d7dff3a671f01b8e940181dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 29 Jul 2026 08:34:37 +0100 Subject: [PATCH 01/16] add translated date bar stories russian, dutch and german through real language packs at four container widths --- ...dd-WOOA7S-1817-translated-date-bar-stories | 4 + .../stories/date-filters-panel.stories.tsx | 206 +++++++++++++++++- 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 projects/packages/premium-analytics/changelog/add-WOOA7S-1817-translated-date-bar-stories diff --git a/projects/packages/premium-analytics/changelog/add-WOOA7S-1817-translated-date-bar-stories b/projects/packages/premium-analytics/changelog/add-WOOA7S-1817-translated-date-bar-stories new file mode 100644 index 000000000000..ee32e1ce48a3 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-WOOA7S-1817-translated-date-bar-stories @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add translated DateFiltersPanel stories (Russian, Dutch, German) that render the date bar at several container widths, so the layout is reviewed against real language packs rather than English only. diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx index b3aec7b2d92f..0896be5b2601 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx @@ -3,6 +3,7 @@ import { type ComparisonPresetId, type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; +import { setLocaleData, resetLocaleData } from '@wordpress/i18n'; import { endOfDay, subDays, startOfDay } from 'date-fns'; import { useCallback, useRef, useState } from 'react'; import { DateFiltersPanel } from '../date-filters-panel'; @@ -149,7 +150,8 @@ function DateFiltersPanelStory( { ref={ setContainerElement } style={ { width: containerWidth, - maxWidth: '960px', + // An explicit width is a measurement, so it must not be clamped. + maxWidth: containerWidth === '100%' ? '960px' : undefined, } } > , }; + +/* + * Translated stories. + * + * The panel is laid out for English, where every preset label is short. These + * stories run it through real translations so the layout is reviewed against + * the strings it will actually hold. + * + * Every translation below is taken from a shipped WordPress.org language pack, + * never invented, so the widths are what users see rather than a guess: + * + * - Presets and `Custom` come from the Jetpack plugin's own pack. + * - `Previous period` / `Previous year` come from WooCommerce, which already + * ships them. Jetpack does not. + * - `All time` reuses the translation of Jetpack Stats' `All-time`. Note the + * hyphen: this package introduced a near-duplicate msgid, so at runtime it + * gets no translation at all and falls back to English. + * - `Previous month` and `No comparison` are translated nowhere, so they stay + * English here too. That is today's real behavior, and it means these stories + * understate the comparison menu's eventual width. + * + * Locales were picked by measuring all 15 that translate the full preset set, + * not by intuition. German is only 7th (1.24x English); Russian is the widest + * by a wide margin, and CJK is narrower than English. + */ + +type LocaleFixture = { + /** Human-facing name for the story description. */ + name: string; + /** Measured width of the four preset pills, in CSS px at 13px system font. */ + presetGroupWidth: number; + /** Preset group width relative to English. */ + ratio: number; + /** Jed-shaped locale data for `setLocaleData`. */ + translations: Record< string, string >; +}; + +const TEXT_DOMAIN = 'jetpack-premium-analytics-pkg'; + +/** Widest of the 15 measured locales, at 1.49x English. */ +const RUSSIAN: LocaleFixture = { + name: 'Russian', + presetGroupWidth: 572, + ratio: 1.49, + translations: { + 'Last 24 hours': 'Последние 24 часа', + 'Last 7 days': 'Последние 7 дней', + 'Last 30 days': 'Последние 30 дней', + 'Last 12 months': 'Последние 12 месяцев', + Custom: 'Произвольно', + 'All time': 'Всё время', + 'Previous period': 'Предыдущий период', + 'Previous year': 'Предыдущий год', + }, +}; + +/** Widest Latin-script locale, at 1.37x English. */ +const DUTCH: LocaleFixture = { + name: 'Dutch', + presetGroupWidth: 526, + ratio: 1.37, + translations: { + 'Last 24 hours': 'Laatste 24 uur', + 'Last 7 days': 'Afgelopen 7 dagen', + 'Last 30 days': 'Afgelopen 30 dagen', + 'Last 12 months': 'Afgelopen 12 maanden', + Custom: 'Aangepast', + 'All time': 'Aller tijden', + 'Previous period': 'Vorige periode', + 'Previous year': 'Vorig jaar', + }, +}; + +/* + * The locale everyone expects to be worst, which measures 7th. Kept as the + * control, and because it holds the widest single Latin pill + * ("Die letzten 24 Stunden", 139px) plus the non-breaking space that German + * and French packs use between a number and its unit. + */ +const GERMAN: LocaleFixture = { + name: 'German', + presetGroupWidth: 478, + ratio: 1.24, + translations: { + 'Last 24 hours': 'Die letzten 24 Stunden', + 'Last 7 days': 'Letzte 7 Tage', + 'Last 30 days': 'Letzte 30 Tage', + 'Last 12 months': 'Letzte 12 Monate', + Custom: 'Individuell', + 'All time': 'Gesamte Zeit', + 'Previous period': 'Vorherige Periode', + 'Previous year': 'Vorjahr', + }, +}; + +/** + * Install a fixture's translations for the duration of one story. + * + * `setLocaleData` writes to the global i18n singleton, so the returned cleanup + * matters: without it the locale would leak into whichever story is opened + * next. These stories are also kept off the autodocs page, which renders every + * sibling at once and would otherwise show them all in the last locale set. + * + * @param fixture - The locale to install. + * @return Storybook `beforeEach` handler. + */ +function withLocale( fixture: LocaleFixture ) { + return () => { + setLocaleData( + Object.fromEntries( + Object.entries( fixture.translations ).map( ( [ msgid, msgstr ] ) => [ msgid, [ msgstr ] ] ) + ), + TEXT_DOMAIN + ); + + // Data first, domain second: passing the domain alone would install it + // as the locale data and leave every label untranslated. + return () => resetLocaleData( undefined, TEXT_DOMAIN ); + }; +} + +/* + * Container widths worth reviewing. 600 is the panel's own compact threshold + * (`MOBILE_CONTAINER_WIDTH_THRESHOLD`), and 782 is wp-admin's mobile + * breakpoint. The English bar already needs about 635px once the design's + * arrows and interval control are added, so 600 is under-budget before any + * translation is involved. + */ +const LADDER_WIDTHS = [ 960, 782, 600, 360 ]; + +/** + * One locale's bar rendered at each reference width, so the point where it stops + * fitting is visible rather than asserted. + * + * Each rung is annotated with whether the four preset pills alone clear the + * container. That is a floor, not the whole bar: the custom trigger, comparison + * control, and the arrows and interval button the design adds all come out of + * the same line. + * + * @param props - Component props. + * @param props.fixture - The locale to render. + */ +function WidthLadder( { fixture }: { fixture: LocaleFixture } ) { + return ( +
+
+ { `${ fixture.name }: preset pills measure ${ fixture.presetGroupWidth }px, ` + + `${ fixture.ratio }x English (385px).` } +
+ { LADDER_WIDTHS.map( width => ( +
+
+ { `${ width }px container — presets alone ` + + `${ + fixture.presetGroupWidth > width + ? `overflow by ${ fixture.presetGroupWidth - width }px` + : `leave ${ width - fixture.presetGroupWidth }px for everything else` + }` } +
+ +
+ ) ) } +
+ ); +} + +/** + * Russian, the widest of the 15 measured locales. Its preset pills alone need + * about 572px against English's 385px. + */ +export const TranslatedRussian: Story = { + tags: [ '!autodocs' ], + beforeEach: withLocale( RUSSIAN ), + render: () => , +}; + +/** + * Dutch, the widest Latin-script locale. Compounds like "Afgelopen 12 maanden" + * carry the cost here. + */ +export const TranslatedDutch: Story = { + tags: [ '!autodocs' ], + beforeEach: withLocale( DUTCH ), + render: () => , +}; + +/** + * German, as a control. It holds the widest single Latin pill but measures only + * 1.24x English overall, so it is not the worst case the layout must survive. + */ +export const TranslatedGerman: Story = { + tags: [ '!autodocs' ], + beforeEach: withLocale( GERMAN ), + render: () => , +}; From cef1721b8ca265a63a807a5771322911227230e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 29 Jul 2026 09:09:04 +0100 Subject: [PATCH 02/16] add abbreviated labels for the surface presets translated separately with context, not truncations of the full label --- .../__tests__/surface-preset-labels.test.ts | 44 +++++++++++++++++++ .../packages/datetime/src/presets/primary.ts | 32 +++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 projects/packages/premium-analytics/packages/datetime/src/__tests__/surface-preset-labels.test.ts diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/surface-preset-labels.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/surface-preset-labels.test.ts new file mode 100644 index 000000000000..d0b87987bf1a --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/surface-preset-labels.test.ts @@ -0,0 +1,44 @@ +/** + * Internal dependencies + */ +import { getDefaultDateRangePresets, getQuickSurfacePresets } from '../presets'; +import { QUICK_SURFACE_PRESETS } from '../presets/types'; + +const TIME_ZONE = 'America/New_York'; + +describe( 'surface preset short labels', () => { + it( 'gives every quick surface preset a short label', () => { + const presets = getQuickSurfacePresets( TIME_ZONE ); + + expect( presets ).toHaveLength( QUICK_SURFACE_PRESETS.length ); + + for ( const preset of presets ) { + expect( preset.shortLabel ).toBeTruthy(); + } + } ); + + it( 'keeps the short label shorter than the full one', () => { + for ( const { label, shortLabel } of getQuickSurfacePresets( TIME_ZONE ) ) { + expect( shortLabel!.length ).toBeLessThan( label.length ); + } + } ); + + /* + * The abbreviations exist for the pill row, and only the quick surface + * presets render there. A short label on any other preset would never be + * shown, so it would be dead weight the translators still have to carry. + */ + it( 'leaves presets outside the surface without one', () => { + const surface = new Set< string >( QUICK_SURFACE_PRESETS ); + + const offSurface = getDefaultDateRangePresets( TIME_ZONE ).filter( + preset => ! surface.has( preset.id ) + ); + + expect( offSurface.length ).toBeGreaterThan( 0 ); + + for ( const preset of offSurface ) { + expect( preset.shortLabel ).toBeUndefined(); + } + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts index 6e10893fdc90..451ec9d356f4 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; import { startOfDay, endOfDay, @@ -67,6 +67,16 @@ export const DEFAULT_YEAR_SURFACE_COUNT = 6; type PresetDefinition = { id: SelectablePresetId; getLabel: () => string; + /** + * Short label for the surface pills when the full one does not fit. Only the + * quick surface presets carry one, since they are the only presets that + * render as pills in a fixed-width row. + * + * Translated separately rather than derived from the full label: these are + * not truncations, and the English forms are initials that most languages + * write differently. + */ + getShortLabel?: () => string; getRange: ( ctx: DateContext ) => Required< DateRange >; }; @@ -94,6 +104,9 @@ export const PRESET_DEFINITIONS: ReadonlyArray< PresetDefinition > = [ { id: PRESET_LAST_24_HOURS, getLabel: () => __( 'Last 24 hours', 'jetpack-premium-analytics-pkg' ), + getShortLabel: () => + /* translators: abbreviation for "Last 24 hours". Shown in a segmented control too narrow for the full label, so keep it as short as the language allows. */ + _x( '24H', 'short date range preset', 'jetpack-premium-analytics-pkg' ), getRange: ( { now } ) => ( { from: subHours( now, 24 ), to: now, @@ -102,6 +115,9 @@ export const PRESET_DEFINITIONS: ReadonlyArray< PresetDefinition > = [ { id: PRESET_LAST_7_DAYS, getLabel: () => __( 'Last 7 days', 'jetpack-premium-analytics-pkg' ), + getShortLabel: () => + /* translators: abbreviation for "Last 7 days". Shown in a segmented control too narrow for the full label, so keep it as short as the language allows. */ + _x( '7D', 'short date range preset', 'jetpack-premium-analytics-pkg' ), getRange: ( { initOfToday, endOfYesterday } ) => ( { from: subDays( initOfToday, 7 ), to: endOfYesterday, @@ -110,6 +126,9 @@ export const PRESET_DEFINITIONS: ReadonlyArray< PresetDefinition > = [ { id: PRESET_LAST_30_DAYS, getLabel: () => __( 'Last 30 days', 'jetpack-premium-analytics-pkg' ), + getShortLabel: () => + /* translators: abbreviation for "Last 30 days". Shown in a segmented control too narrow for the full label, so keep it as short as the language allows. */ + _x( '30D', 'short date range preset', 'jetpack-premium-analytics-pkg' ), getRange: ( { initOfToday, endOfYesterday } ) => ( { from: subDays( initOfToday, 30 ), to: endOfYesterday, @@ -142,6 +161,9 @@ export const PRESET_DEFINITIONS: ReadonlyArray< PresetDefinition > = [ { id: PRESET_LAST_12_MONTHS, getLabel: () => __( 'Last 12 months', 'jetpack-premium-analytics-pkg' ), + getShortLabel: () => + /* translators: abbreviation for "Last 12 months". Shown in a segmented control too narrow for the full label, so keep it as short as the language allows. */ + _x( '12M', 'short date range preset', 'jetpack-premium-analytics-pkg' ), getRange: ( { initOfToday, endOfYesterday } ) => ( { from: subMonths( initOfToday, 12 ), to: endOfYesterday, @@ -221,6 +243,11 @@ function buildDateContext( timeZone: string ): DateContext { export type DateRangePreset< TId extends ComputablePresetId = SelectablePresetId > = { id: TId; label: string; + /** + * Abbreviated label, present only on presets that render as surface pills. + * Consumers that never run out of room can ignore it. + */ + shortLabel?: string; range: Required< DateRange >; }; @@ -282,9 +309,10 @@ function computeAllTimeRange( startYear: number, ctx: DateContext ): Required< D export function getDefaultDateRangePresets( timeZone: string ): DateRangePreset[] { const ctx = buildDateContext( timeZone ); - return PRESET_DEFINITIONS.map( ( { id, getLabel, getRange } ) => ( { + return PRESET_DEFINITIONS.map( ( { id, getLabel, getShortLabel, getRange } ) => ( { id, label: getLabel(), + shortLabel: getShortLabel?.(), range: getRange( ctx ), } ) ); } From 9f0d087fc1e4347babf01094356402959db36c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 29 Jul 2026 10:10:18 +0100 Subject: [PATCH 03/16] derive the preset label mode from a measured probe replaces the fixed 600px compact threshold, which was wrong for every language --- .../date-filters-panel/date-filters-panel.tsx | 96 ++++++++++- .../date-filters-panel/preset-row-probe.scss | 34 ++++ .../date-filters-panel/preset-row-probe.tsx | 162 ++++++++++++++++++ .../date-range-filter/date-range-filter.scss | 12 ++ .../date-range-filter/date-range-filter.tsx | 17 +- .../stories/date-range-filter.stories.tsx | 9 +- .../packages/ui/src/date-range-layout.ts | 23 ++- .../ui/src/date-range-popover/index.ts | 8 + .../date-range-quick-presets.test.tsx | 7 +- .../date-range-quick-presets.tsx | 36 +++- 10 files changed, 377 insertions(+), 27 deletions(-) create mode 100644 projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss create mode 100644 projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx 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 index 8cb71fe7efd8..cc682577180c 100644 --- 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 @@ -2,13 +2,16 @@ * External dependencies */ import { + getQuickSurfacePresets, isComparisonPresetId, isPrimaryPreset, type ComparisonPresetId, type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; +import { formatDateRange } from '@jetpack-premium-analytics/formatters'; import { BaseControl } from '@wordpress/components'; import { useResizeObserver } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; import { Stack } from '@wordpress/ui'; import clsx from 'clsx'; import { useMemo, useCallback, useState, useEffect } from 'react'; @@ -17,11 +20,14 @@ import { useMemo, useCallback, useState, useEffect } from 'react'; */ import { DateComparisonDropdown } from '../date-comparison-dropdown'; import { DateRangeFilter } from '../date-range-filter'; +import { WIDE_CALENDAR_CONTAINER_THRESHOLD, type PresetLabelMode } from '../date-range-layout'; import { - MOBILE_CONTAINER_WIDTH_THRESHOLD, - WIDE_CALENDAR_CONTAINER_THRESHOLD, -} from '../date-range-layout'; + getCommittedCustomRange, + getCustomTriggerLabel, + getCustomTriggerState, +} from '../date-range-popover'; import { useComparisonDatePresets } from '../use-comparison-date-presets'; +import { PresetRowProbe, type PresetRowWidths } from './preset-row-probe'; import './date-filters-panel.scss'; @@ -206,6 +212,7 @@ export function DateFiltersPanel( { * and `isWideScreen` is forwarded only because the calendar needs it. */ const [ containerWidth, setContainerWidth ] = useState< number | null >( null ); + const [ rootElement, setRootElement ] = useState< HTMLElement | null >( null ); const handleResize = useCallback( ( entries: ResizeObserverEntry[] ) => { const entry = entries[ 0 ]; @@ -216,21 +223,96 @@ export function DateFiltersPanel( { const setObserverRef = useResizeObserver< HTMLElement >( handleResize ); + /* + * Measure this panel's own root. Consumers used to wrap the panel in a div + * and hand it back through `containerElement`, which put the mechanism in + * every caller's hands for no gain: all ten of them passed their immediate + * wrapper, so the number was the panel's own width either way. The prop + * stays as an override for a caller that genuinely needs to size against a + * different element, and falls back to the root when absent. + */ useEffect( () => { - setObserverRef( containerElement ?? document.body ); - }, [ containerElement, setObserverRef ] ); + setObserverRef( containerElement ?? rootElement ?? document.body ); + }, [ containerElement, rootElement, setObserverRef ] ); + + /* + * The exact string the custom trigger is showing, derived through the same + * helpers the trigger itself uses. The probe measures the trigger alongside + * the pills because they share one group, and "Custom" versus a formatted + * range differs by enough width to move the boundary. + */ + const customTriggerLabel = useMemo( () => { + const committedRange = appliedRange ?? range; + + return getCustomTriggerLabel( { + triggerState: getCustomTriggerState( { + presetId: validatedPresetId, + appliedPresetId: validatedAppliedPresetId, + canApply, + isOpen: isPrimaryPickerOpen, + } ), + range, + committedRange, + rememberedCustomRange: getCommittedCustomRange( validatedAppliedPresetId, appliedRange ), + customLabel: __( 'Custom', 'jetpack-premium-analytics-pkg' ), + formatRange: formatDateRange, + } ); + }, [ + appliedRange, + canApply, + isPrimaryPickerOpen, + range, + validatedAppliedPresetId, + validatedPresetId, + ] ); + + // Labels only, for the probe. The ranges are recomputed at selection time by + // the pills themselves, so the memo going stale here costs nothing. + const surfacePresets = useMemo( () => getQuickSurfacePresets( timeZone ), [ timeZone ] ); + + const [ rowWidths, setRowWidths ] = useState< PresetRowWidths | null >( null ); + const handleProbeMeasure = useCallback( ( widths: PresetRowWidths ) => { + setRowWidths( widths ); + }, [] ); + + /* + * Pick the longest labels that still fit. Both candidate widths come from the + * probe, so the answer follows the active locale's own strings instead of a + * breakpoint chosen for English. + * + * Before the first measurement, hold `full`: it is what the panel rendered + * before this existed, and a wrong guess corrects itself on the next frame. + */ + const labelMode: PresetLabelMode = useMemo( () => { + if ( containerWidth === null || rowWidths === null ) { + return 'full'; + } - const isCompact = containerWidth !== null && containerWidth < MOBILE_CONTAINER_WIDTH_THRESHOLD; + if ( containerWidth >= rowWidths.full ) { + return 'full'; + } + + return containerWidth >= rowWidths.abbreviated ? 'abbreviated' : 'select'; + }, [ containerWidth, rowWidths ] ); + + const isCompact = labelMode === 'select'; const isWideScreen = containerWidth !== null && containerWidth >= WIDE_CALENDAR_CONTAINER_THRESHOLD; return ( + + diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss new file mode 100644 index 000000000000..42d6dc46087c --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss @@ -0,0 +1,34 @@ +// Off-screen measuring rig for the preset row. It must reproduce the real +// group's box model closely enough to be measured in its place, while +// contributing nothing to the layout it informs. +.preset-row-probe { + position: absolute; + // Out of view without `display: none` or `visibility: hidden`, either of + // which would stop the browser laying it out and leave every width at 0. + top: 0; + left: 0; + visibility: hidden; + pointer-events: none; + // Never grow the scrollable area of whatever contains the panel. + overflow: hidden; + width: 0; + height: 0; + + &__row { + display: flex; + align-items: center; + // The measurement is "how wide does this row want to be", so it must not + // be constrained by an ancestor or allowed to wrap. + flex-wrap: nowrap; + gap: 0; + width: max-content; + + // Match the real group's frame: the border contributes to the width the + // group occupies in the row, so leaving it out under-measures by 2px. + border: var(--wpds-border-width-xs) solid transparent; + + > * + * { + border-inline-start: var(--wpds-border-width-xs) solid transparent; + } + } +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx new file mode 100644 index 000000000000..8289ab8ace28 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import { chevronDown } from '@wordpress/icons'; +import { Button, Icon } from '@wordpress/ui'; +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +/** + * Internal dependencies + */ +import './preset-row-probe.scss'; + +/** + * Natural width of the preset row in each label form, in CSS pixels. + */ +export type PresetRowWidths = { + full: number; + abbreviated: number; +}; + +export type PresetRowProbeProps = { + /** + * Labels for the pills, in display order. Both forms are measured, so a + * preset without a short form contributes its full label to both rows. + */ + presets: ReadonlyArray< { id: string; label: string; shortLabel?: string } >; + + /** + * The custom-range trigger's current label. It sits in the same group as the + * pills and is unaffected by the label mode, but it still competes for the + * row, so it has to be measured. Pass the label the real trigger is showing: + * "Custom" is much narrower than a formatted range like "Jul 13-26, 2026", + * and measuring the wrong one moves the boundary by ~40px. + */ + customTriggerLabel: string; + + /** + * Reports both natural widths whenever they change. + */ + onMeasure: ( widths: PresetRowWidths ) => void; +}; + +/** + * Measures what the preset row would need at each label length. + * + * Rendered off-screen rather than derived arithmetically, because the answer + * depends on the font actually resolved for the active locale's script, the + * button padding tokens, and the group's own borders. Reproducing that in JS + * would be a second implementation of the same layout, free to drift from the + * first. + * + * Two properties make this safe to feed back into the layout it measures: + * + * 1. It renders both forms unconditionally, so its widths never depend on which + * mode is currently active. Measuring the live row instead would oscillate: + * shortening the labels shrinks the row, which makes the full labels look + * like they fit, which restores them, which overflows again. + * 2. It is `position: absolute` and `inert`, so it contributes no layout of its + * own and cannot be reached by pointer, focus, or assistive tech. + * + * @param props - Component props. + * @param props.presets - Pill labels in display order. + * @param props.customTriggerLabel - The custom trigger's current label. + * @param props.onMeasure - Receives both natural widths. + */ +export function PresetRowProbe( { presets, customTriggerLabel, onMeasure }: PresetRowProbeProps ) { + const fullRef = useRef< HTMLDivElement >( null ); + const abbreviatedRef = useRef< HTMLDivElement >( null ); + + /* + * Keep the last reported pair so a re-render with identical widths does not + * push a new object at the consumer and restart its effects. + */ + const [ reported, setReported ] = useState< PresetRowWidths | null >( null ); + + const measure = useCallback( () => { + const full = fullRef.current?.getBoundingClientRect().width ?? 0; + const abbreviated = abbreviatedRef.current?.getBoundingClientRect().width ?? 0; + + if ( ! full || ! abbreviated ) { + return; + } + + // Sub-pixel jitter from fractional layout would otherwise churn the mode. + const next = { full: Math.ceil( full ), abbreviated: Math.ceil( abbreviated ) }; + + if ( reported?.full === next.full && reported?.abbreviated === next.abbreviated ) { + return; + } + + setReported( next ); + onMeasure( next ); + }, [ onMeasure, reported ] ); + + /* + * Measure after layout rather than on an interval or a resize: the probe is + * `max-content`, so its width only moves when the labels or the typography + * do, and both arrive as a render. + */ + useLayoutEffect( () => { + measure(); + }, [ measure, presets, customTriggerLabel ] ); + + /* + * Web fonts land after first paint and change the metrics under us. Nothing + * in the dashboard ships a web font today, so this is insurance rather than + * a fix, and it is cheap: one promise, one remeasure. + */ + useLayoutEffect( () => { + if ( ! document.fonts?.ready ) { + return; + } + + let cancelled = false; + document.fonts.ready.then( () => { + if ( ! cancelled ) { + measure(); + } + } ); + + return () => { + cancelled = true; + }; + }, [ measure ] ); + + const row = ( abbreviated: boolean ) => ( + <> + { presets.map( preset => ( + + ) ) } + + + ); + + return ( + // @ts-expect-error -- `inert` is valid HTML but missing from this React version's types. + + ); +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.scss b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.scss index 19cbb3808094..9e20d1c96841 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.scss +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.scss @@ -4,4 +4,16 @@ .date-range-filter__group { @include preset-surface.group; + + // The label mode shortens the labels, then collapses them into a select, + // before the row runs out of room, so wrapping is no longer this group's + // fallback. Turning it off also retires an artifact it left behind: the + // mixin draws its hairline dividers with `> * + *`, which knows nothing + // about row breaks, so a wrapped row's first pill painted an inline-start + // border on top of the group's own frame. + + // Scoped here rather than folded into the mixin: the year surface shares it + // and has no abbreviated form to fall back on yet, so wrapping is still its + // only escape from a row that overflows. + flex-wrap: nowrap; } diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx index f511bac4c33d..61bd93c2ac46 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx @@ -10,6 +10,7 @@ import { useCallback, useMemo } from 'react'; */ import { DateRangePopover } from '../date-range-popover'; import { DateRangeQuickPresets, getSurfacePresetId } from '../date-range-quick-presets'; +import type { PresetLabelMode } from '../date-range-layout'; import type { DateRange } from '../date-range-popover'; import './date-range-filter.scss'; @@ -20,10 +21,13 @@ export type DateRangeFilterProps = Omit< 'isCompact' | 'isWideScreen' | 'triggerAsCompositeItem' > & { /** - * Compact (mobile) layout: render the presets as a select and the custom - * trigger as a bordered button. Owned and measured by `DateFiltersPanel`. + * How much room the preset labels have. Owned and measured by + * `DateFiltersPanel`; this component only routes it. + * + * `select` is the old compact layout: presets become a select and the custom + * trigger a bordered button. */ - isCompact?: boolean; + labelMode?: PresetLabelMode; /** * Wide layout: let the calendar popover show two months. Owned and measured @@ -47,9 +51,12 @@ export function DateRangeFilter( { canApply, timeZone, onOpenChange, - isCompact = false, + labelMode = 'full', isWideScreen = false, }: DateRangeFilterProps ) { + // Everything below the presets only distinguishes "collapsed" from "not". + const isCompact = labelMode === 'select'; + const surfacePresetId = useMemo( () => getSurfacePresetId( appliedPresetId ?? presetId ), [ appliedPresetId, presetId ] @@ -68,7 +75,7 @@ export function DateRangeFilter( { value={ surfacePresetId } onSelect={ handlePresetSelect } timeZone={ timeZone } - isCompact={ isCompact } + labelMode={ labelMode } /> ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/stories/date-range-filter.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/stories/date-range-filter.stories.tsx index fe7fbec8565d..cf2ceb15d900 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/stories/date-range-filter.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/stories/date-range-filter.stories.tsx @@ -2,6 +2,7 @@ import { computePrimaryRange, type PrimaryPresetId } from '@jetpack-premium-anal import { subDays, startOfDay, endOfDay } from 'date-fns'; import { useRef, useState } from 'react'; import { DateRangeFilter } from '../date-range-filter'; +import type { PresetLabelMode } from '../../date-range-layout'; import type { DateRange } from '../../date-range-popover'; import type { Meta, StoryObj } from '@storybook/react'; @@ -55,11 +56,11 @@ function buildInitialState( initialPreset: PrimaryPresetId ): FilterState { function DateRangeFilterWithState( { initialPreset = 'last-30-days' as PrimaryPresetId, - isCompact = false, + labelMode = 'full' as PresetLabelMode, isWideScreen = false, }: { initialPreset?: PrimaryPresetId; - isCompact?: boolean; + labelMode?: PresetLabelMode; isWideScreen?: boolean; } ) { const initialState = buildInitialState( initialPreset ); @@ -104,7 +105,7 @@ function DateRangeFilterWithState( { onCancel={ handleCancel } canApply={ canApply } timeZone={ STORYBOOK_TIMEZONE } - isCompact={ isCompact } + labelMode={ labelMode } isWideScreen={ isWideScreen } /> ); @@ -127,7 +128,7 @@ export const CustomRange: Story = { export const Compact: Story = { render: () => (
- +
), }; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts index a72a566ce638..c51626d5903c 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts @@ -1,10 +1,31 @@ +/** + * How the date-range presets render for the width they have been given. + * + * Picked by measuring the group's own content rather than comparing the + * container against fixed breakpoints: the same row needs 470px in English and + * 694px in Russian, so any hardcoded boundary is wrong for some language. See + * WOOA7S-1817 for the measurements. + */ +export type PresetLabelMode = + /** Full labels ("Last 7 days"), while they fit. */ + | 'full' + /** Abbreviated labels ("7D"), while those fit. */ + | 'abbreviated' + /** A select, once even the abbreviated pills do not fit. */ + | 'select'; + /** * Threshold width (in pixels) below which preset controls collapse to a select. - * Based on the container width, not the viewport. + * + * @deprecated Superseded by the measured `PresetLabelMode`. Still read while the + * remaining consumers migrate. */ export const MOBILE_CONTAINER_WIDTH_THRESHOLD = 600; /** * Threshold width (in pixels) for showing two months in the custom-range calendar. + * + * Unlike the preset labels, this one stays a constant: the calendar's content is + * two month grids whose width does not follow the locale's string lengths. */ export const WIDE_CALENDAR_CONTAINER_THRESHOLD = 560; 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 index 9d4c1f786baf..2937cbb3e27d 100644 --- 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 @@ -1,2 +1,10 @@ export { DateRangePopover, DateRangePopoverContent } from './date-range-filter'; export type { DateRange } from './date-range-filter'; +/* + * The custom trigger's label has a second reader: the panel's measuring probe + * needs the exact string the trigger is showing, since "Custom" and a formatted + * range differ by enough width to move the label-mode boundary. Exported so both + * derive it from one implementation instead of two. + */ +export { getCustomTriggerLabel, getCustomTriggerState } from './get-custom-trigger-state'; +export { getCommittedCustomRange } from './last-custom-range'; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx index 1bd1abc58332..28d4fd92efb3 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx @@ -57,7 +57,12 @@ describe( 'DateRangeQuickPresets', () => { it( 'renders every preset in a select when compact', () => { render( - + ); expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/date-range-quick-presets.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/date-range-quick-presets.tsx index ce260de70a4f..f9b49331bdf5 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/date-range-quick-presets.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/date-range-quick-presets.tsx @@ -15,6 +15,7 @@ import { useCallback, useMemo } from 'react'; /** * Internal dependencies */ +import type { PresetLabelMode } from '../date-range-layout'; import type { DateRange } from '../date-range-popover'; import './date-range-quick-presets.scss'; @@ -35,19 +36,31 @@ type DateRangeQuickPresetsProps = { timeZone: string; /** - * When true, presets render as a select instead of composite pills. + * How much room the labels have. Measured and owned by `DateFiltersPanel`; + * this component never measures. */ - isCompact?: boolean; + labelMode?: PresetLabelMode; }; export function DateRangeQuickPresets( { value, onSelect, timeZone, - isCompact = false, + labelMode = 'full', }: DateRangeQuickPresetsProps ) { const presets = useMemo( () => getQuickSurfacePresets( timeZone ), [ timeZone ] ); + /* + * The whole group switches together. A row mixing "Last 24 hours" with "7D" + * reads as a rendering fault rather than as a deliberate abbreviation, so no + * pill picks its own form. + */ + const pillLabel = useCallback( + ( preset: { label: string; shortLabel?: string } ) => + labelMode === 'abbreviated' ? preset.shortLabel ?? preset.label : preset.label, + [ labelMode ] + ); + const items = useMemo( () => presets.map( ( { id, label } ) => ( { @@ -79,7 +92,7 @@ export function DateRangeQuickPresets( { [ onSelect, presets, timeZone ] ); - if ( isCompact ) { + if ( labelMode === 'select' ) { return ( - { presets.map( ( { id, label } ) => ( + { presets.map( preset => ( selectPreset( id ) } + aria-pressed={ value === preset.id } + /* + * Abbreviated pills lose the wording that named the period, so + * carry the full label for anyone not reading the glyphs. + */ + aria-label={ labelMode === 'abbreviated' ? preset.label : undefined } + onClick={ () => selectPreset( preset.id ) } /> } > - { label } + { pillLabel( preset ) } ) ) } From c101e9a69271fb0bdc8a96f3a8b22d58cde550cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 29 Jul 2026 10:46:26 +0100 Subject: [PATCH 04/16] truncate the custom trigger label instead of wrapping it own element so the ellipsis can engage; adds a 280px story rung --- .../date-filters-panel/date-filters-panel.tsx | 31 +++------- .../date-filters-panel/preset-row-probe.tsx | 59 ++++++------------- .../stories/date-filters-panel.stories.tsx | 28 ++++----- .../date-range-popover/date-range-filter.scss | 14 +++++ .../date-range-popover/date-range-filter.tsx | 3 +- 5 files changed, 55 insertions(+), 80 deletions(-) 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 index cc682577180c..75af464fde5f 100644 --- 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 @@ -223,24 +223,14 @@ export function DateFiltersPanel( { const setObserverRef = useResizeObserver< HTMLElement >( handleResize ); - /* - * Measure this panel's own root. Consumers used to wrap the panel in a div - * and hand it back through `containerElement`, which put the mechanism in - * every caller's hands for no gain: all ten of them passed their immediate - * wrapper, so the number was the panel's own width either way. The prop - * stays as an override for a caller that genuinely needs to size against a - * different element, and falls back to the root when absent. - */ + // Measure this panel's own root; `containerElement` is an override for a + // caller that needs to size against something else. useEffect( () => { setObserverRef( containerElement ?? rootElement ?? document.body ); }, [ containerElement, rootElement, setObserverRef ] ); - /* - * The exact string the custom trigger is showing, derived through the same - * helpers the trigger itself uses. The probe measures the trigger alongside - * the pills because they share one group, and "Custom" versus a formatted - * range differs by enough width to move the boundary. - */ + // Derived through the same helpers the trigger uses, so the probe measures + // the string the trigger is actually showing. const customTriggerLabel = useMemo( () => { const committedRange = appliedRange ?? range; @@ -266,8 +256,8 @@ export function DateFiltersPanel( { validatedPresetId, ] ); - // Labels only, for the probe. The ranges are recomputed at selection time by - // the pills themselves, so the memo going stale here costs nothing. + // Labels only. The pills recompute their own ranges at selection time, so a + // stale memo here costs nothing. const surfacePresets = useMemo( () => getQuickSurfacePresets( timeZone ), [ timeZone ] ); const [ rowWidths, setRowWidths ] = useState< PresetRowWidths | null >( null ); @@ -276,12 +266,9 @@ export function DateFiltersPanel( { }, [] ); /* - * Pick the longest labels that still fit. Both candidate widths come from the - * probe, so the answer follows the active locale's own strings instead of a - * breakpoint chosen for English. - * - * Before the first measurement, hold `full`: it is what the panel rendered - * before this existed, and a wrong guess corrects itself on the next frame. + * The longest labels that still fit. Both candidates come from the probe, so + * the boundary follows the active locale rather than a breakpoint picked for + * English. Holds `full` until measured; a wrong guess corrects next frame. */ const labelMode: PresetLabelMode = useMemo( () => { if ( containerWidth === null || rowWidths === null ) { diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx index 8289ab8ace28..454efec9c4c3 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx @@ -18,44 +18,29 @@ export type PresetRowWidths = { }; export type PresetRowProbeProps = { - /** - * Labels for the pills, in display order. Both forms are measured, so a - * preset without a short form contributes its full label to both rows. - */ + /** Pill labels in display order. A preset with no short form reuses its full label. */ presets: ReadonlyArray< { id: string; label: string; shortLabel?: string } >; /** - * The custom-range trigger's current label. It sits in the same group as the - * pills and is unaffected by the label mode, but it still competes for the - * row, so it has to be measured. Pass the label the real trigger is showing: - * "Custom" is much narrower than a formatted range like "Jul 13-26, 2026", - * and measuring the wrong one moves the boundary by ~40px. + * The label the real trigger is showing. It shares the group with the pills, + * and "Custom" versus a formatted range differs by ~40px. */ customTriggerLabel: string; - /** - * Reports both natural widths whenever they change. - */ + /** Reports both natural widths whenever they change. */ onMeasure: ( widths: PresetRowWidths ) => void; }; /** - * Measures what the preset row would need at each label length. + * Measures what the preset row needs at each label length. * - * Rendered off-screen rather than derived arithmetically, because the answer - * depends on the font actually resolved for the active locale's script, the - * button padding tokens, and the group's own borders. Reproducing that in JS - * would be a second implementation of the same layout, free to drift from the - * first. + * Measured in the DOM rather than computed: the width depends on the font + * resolved for the locale's script, the button padding tokens, and the group's + * borders. * - * Two properties make this safe to feed back into the layout it measures: - * - * 1. It renders both forms unconditionally, so its widths never depend on which - * mode is currently active. Measuring the live row instead would oscillate: - * shortening the labels shrinks the row, which makes the full labels look - * like they fit, which restores them, which overflows again. - * 2. It is `position: absolute` and `inert`, so it contributes no layout of its - * own and cannot be reached by pointer, focus, or assistive tech. + * Renders both forms unconditionally, so its widths never depend on the active + * mode. Measuring the live row would oscillate instead: shortening the labels + * shrinks the row, the full labels then look like they fit, and back again. * * @param props - Component props. * @param props.presets - Pill labels in display order. @@ -66,10 +51,7 @@ export function PresetRowProbe( { presets, customTriggerLabel, onMeasure }: Pres const fullRef = useRef< HTMLDivElement >( null ); const abbreviatedRef = useRef< HTMLDivElement >( null ); - /* - * Keep the last reported pair so a re-render with identical widths does not - * push a new object at the consumer and restart its effects. - */ + // Last reported pair, so identical widths don't push a new object downstream. const [ reported, setReported ] = useState< PresetRowWidths | null >( null ); const measure = useCallback( () => { @@ -91,20 +73,14 @@ export function PresetRowProbe( { presets, customTriggerLabel, onMeasure }: Pres onMeasure( next ); }, [ onMeasure, reported ] ); - /* - * Measure after layout rather than on an interval or a resize: the probe is - * `max-content`, so its width only moves when the labels or the typography - * do, and both arrive as a render. - */ + // The probe is `max-content`, so its width only moves when the labels or the + // typography do, and both arrive as a render. useLayoutEffect( () => { measure(); }, [ measure, presets, customTriggerLabel ] ); - /* - * Web fonts land after first paint and change the metrics under us. Nothing - * in the dashboard ships a web font today, so this is insurance rather than - * a fix, and it is cheap: one promise, one remeasure. - */ + // Web fonts land after first paint and shift the metrics. Insurance: nothing + // in the dashboard ships one today. useLayoutEffect( () => { if ( ! document.fonts?.ready ) { return; @@ -142,7 +118,8 @@ export function PresetRowProbe( { presets, customTriggerLabel, onMeasure }: Pres tone="neutral" tabIndex={ -1 } > - { customTriggerLabel } + { /* Mirrors the real trigger's markup so both measure the same box. */ } + { customTriggerLabel } diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx index 0896be5b2601..ae8474908619 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx @@ -300,10 +300,9 @@ const GERMAN: LocaleFixture = { /** * Install a fixture's translations for the duration of one story. * - * `setLocaleData` writes to the global i18n singleton, so the returned cleanup - * matters: without it the locale would leak into whichever story is opened - * next. These stories are also kept off the autodocs page, which renders every - * sibling at once and would otherwise show them all in the last locale set. + * `setLocaleData` writes to the global i18n singleton, hence the cleanup: the + * locale would otherwise leak into the next story opened. Same reason these are + * off the autodocs page, which renders every sibling at once. * * @param fixture - The locale to install. * @return Storybook `beforeEach` handler. @@ -324,22 +323,19 @@ function withLocale( fixture: LocaleFixture ) { } /* - * Container widths worth reviewing. 600 is the panel's own compact threshold - * (`MOBILE_CONTAINER_WIDTH_THRESHOLD`), and 782 is wp-admin's mobile - * breakpoint. The English bar already needs about 635px once the design's - * arrows and interval control are added, so 600 is under-budget before any - * translation is involved. + * Widths worth reviewing: 782 is wp-admin's mobile breakpoint, 600 was the old + * fixed compact threshold, and 280 is narrow enough to squeeze the custom + * trigger's own label. */ -const LADDER_WIDTHS = [ 960, 782, 600, 360 ]; +const LADDER_WIDTHS = [ 960, 782, 600, 360, 280 ]; /** - * One locale's bar rendered at each reference width, so the point where it stops - * fitting is visible rather than asserted. + * One locale's bar at each reference width, so the point where it stops fitting + * is visible rather than asserted. * - * Each rung is annotated with whether the four preset pills alone clear the - * container. That is a floor, not the whole bar: the custom trigger, comparison - * control, and the arrows and interval button the design adds all come out of - * the same line. + * Rungs are annotated against the four preset pills alone. That is a floor: the + * custom trigger, the comparison control, and the arrows and interval button the + * design adds all come out of the same line. * * @param props - Component props. * @param props.fixture - The locale to render. 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 index 235beef90a4a..d7d4fb889d59 100644 --- 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 @@ -53,6 +53,20 @@ flex: 0 0 auto; } +// Below the select threshold the trigger shares a narrow row with the preset +// select, and a long label has nowhere to go. Without this it wrapped mid-word +// over three lines ("Прои / зволь / но") and stretched the control vertically. +// Truncating is the interim answer; the label is still readable from the +// popover it opens. +.date-filters-panel-button__label { + overflow: hidden; + // Needed for the ellipsis: a flex item will not shrink past its content + // otherwise, so `overflow` would never engage. + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + .date-range-calendar-wrapper { --wca-calendar-button-width: 32px; --wca-calendar-button-height: 32px; 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 index b3ae841a1df3..397044e7a883 100644 --- 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 @@ -323,7 +323,8 @@ export function DateRangePopover( { id="date-range-popover-button" data-state={ triggerState } > - { triggerLabel } + { /* Own element so a label too wide for the trigger can ellipsize. */ } + { triggerLabel } ); From 08a63e0362df1c6963652e53e1dcec99def1edd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 29 Jul 2026 11:15:31 +0100 Subject: [PATCH 05/16] add a resizable rung to the translated stories the fixed widths cannot show whether a boundary settles or flickers --- .../stories/date-filters-panel.stories.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx index ae8474908619..738899322174 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx @@ -228,6 +228,13 @@ export const CompactLayout: Story = { * by a wide margin, and CJK is narrower than English. */ +/* + * Everything from here down quotes translated strings, so a spell checker has + * nothing useful to say about it. + * + * cspell:disable + */ + type LocaleFixture = { /** Human-facing name for the story description. */ name: string; @@ -367,6 +374,35 @@ function WidthLadder( { fixture }: { fixture: LocaleFixture } ) { ) ) } + + { /* + * Dragging across a boundary is the part the fixed rungs cannot show: + * whether the mode settles in one state or flickers between two. Both + * transitions are in reach here, full to abbreviated first and the + * select further down, and where they land differs per locale. + */ } +
+
+ Resize the container to see the effect on the layout +
+ +
); } From 833e1a1b96d1616b4ec16d5bc181acd244e9bdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 29 Jul 2026 12:11:39 +0100 Subject: [PATCH 06/16] let the date panel own its responsive measurement drops containerElement from 12 call sites; every one passed its own wrapper --- .../report-params-field.tsx | 15 +-------------- .../use-report-date-filters.tsx | 6 ++---- .../date-filters-panel/date-filters-panel.tsx | 19 +++++++------------ .../stories/date-filters-panel.stories.tsx | 4 ---- .../date-report-params-field.tsx | 15 +-------------- .../routes/dashboard/stage.tsx | 11 ++--------- .../routes/post-detail/stage.tsx | 13 +++---------- .../routes/reports/authors/page.tsx | 11 ++--------- .../routes/reports/clicks/page.tsx | 11 ++--------- .../routes/reports/downloads/page.tsx | 11 ++--------- .../routes/reports/posts/page.tsx | 11 ++--------- .../routes/reports/referrers/page.tsx | 11 ++--------- .../routes/reports/search-terms/page.tsx | 11 ++--------- .../routes/reports/utm/page.tsx | 9 ++------- .../routes/reports/videos/page.tsx | 11 ++--------- 15 files changed, 32 insertions(+), 137 deletions(-) diff --git a/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx b/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx index f9dabfef736f..a8196a6883bd 100644 --- a/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx +++ b/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx @@ -16,7 +16,7 @@ import { deriveComparisonRange, encodeDateToSearchParam } from '@jetpack-premium import { DateFiltersPanel } from '@jetpack-premium-analytics/ui'; import { Stack } from '@wordpress/ui'; import { endOfDay } from 'date-fns'; -import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { getStoreInfo } from '../helpers/store-info'; import type { DataFormControlProps } from '@wordpress/dataviews'; @@ -127,18 +127,6 @@ export function ReportParamsField( { setStagedReportParams( attributes?.reportParams ); }, [ setStagedReportParams, attributes ] ); - /* - * Get the dashboard layout surface for responsive calculations. - * This is a temporary workaround until @automattic/dashboard exposes - * a Context provider. See WOOA7S-1008 for the upstream solution. - */ - const [ containerElement, setContainerElement ] = useState< HTMLElement | null >( null ); - - useEffect( () => { - const node = document.querySelector< HTMLElement >( '.next-admin-layout__surface' ); - setContainerElement( node ); - }, [] ); - return ( ); diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx index 4a0c4336a7b3..16487c97d819 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx @@ -19,8 +19,7 @@ import type { } from '@jetpack-premium-analytics/datetime'; /** - * The values and callbacks that drive `DateFiltersPanel`, minus the - * `containerElement` ref which the consuming page owns. + * The values and callbacks that drive `DateFiltersPanel`. */ type PickerRange = { from: Date | undefined; to: Date | undefined }; @@ -70,8 +69,7 @@ function toPickerRange( from?: string, to?: string ) { * * Edits are staged locally and committed atomically on Apply (or immediately * for comparison changes), so widgets re-fetch only on commit. The hook returns - * everything `DateFiltersPanel` needs except the responsive-measurement - * `containerElement`, which the page owns. Shared by every analytics page that + * everything `DateFiltersPanel` needs. Shared by every analytics page that * mounts the panel so the staged-search behavior stays identical across them. * * @param from - The route path the search params are bound to (e.g. `/`). 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 index 75af464fde5f..90b3b1ab26b5 100644 --- 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 @@ -106,13 +106,6 @@ export type DateFiltersPanelProps = { * Required for proper date/time handling. */ timeZone: string; - - /** - * Optional external container element for responsive calculations. - * When provided, the date-range filter will measure this container's width - * instead of its own wrapper to determine compact/wide layouts. - */ - containerElement?: HTMLElement | null; }; /** @@ -142,7 +135,6 @@ export function DateFiltersPanel( { onCancel, canApply = true, timeZone, - containerElement, }: DateFiltersPanelProps ) { /** * Validate and normalize the primary preset ID. @@ -223,11 +215,14 @@ export function DateFiltersPanel( { const setObserverRef = useResizeObserver< HTMLElement >( handleResize ); - // Measure this panel's own root; `containerElement` is an override for a - // caller that needs to size against something else. + /* + * Measure this panel's own root. Callers used to wrap the panel and hand the + * wrapper back as a prop; all ten passed their immediate wrapper, so the + * number was the panel's own width and the ceremony bought nothing. + */ useEffect( () => { - setObserverRef( containerElement ?? rootElement ?? document.body ); - }, [ containerElement, rootElement, setObserverRef ] ); + setObserverRef( rootElement ?? document.body ); + }, [ rootElement, setObserverRef ] ); // Derived through the same helpers the trigger uses, so the probe measures // the string the trigger is actually showing. diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx index 738899322174..82d8af56f08c 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx @@ -96,8 +96,6 @@ function DateFiltersPanelStory( { withComparison ? initialComparisonPreset : undefined ); - const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); - const handlePrimaryChange = useCallback( ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => { const nextPrimary: PrimaryFilterState = { @@ -147,7 +145,6 @@ function DateFiltersPanelStory( { return (
); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/date-report-params-field.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/date-report-params-field.tsx index 2cc63fad2139..eb66c8e02743 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/date-report-params-field.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/date-report-params-field.tsx @@ -16,7 +16,7 @@ import { deriveComparisonRange, encodeDateToSearchParam } from '@jetpack-premium import { DateFiltersPanel } from '@jetpack-premium-analytics/ui'; import { Stack } from '@wordpress/ui'; import { endOfDay } from 'date-fns'; -import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { getStoreInfo } from '../../helpers/store-info'; import type { DataFormControlProps } from '@wordpress/dataviews'; @@ -118,18 +118,6 @@ export function ReportParamsField( { setStagedReportParams( attributes?.reportParams ); }, [ setStagedReportParams, attributes ] ); - /* - * Get the dashboard layout surface for responsive calculations. - * This is a temporary workaround until @automattic/dashboard exposes - * a Context provider. See WOOA7S-1008 for the upstream solution. - */ - const [ containerElement, setContainerElement ] = useState< HTMLElement | null >( null ); - - useEffect( () => { - const node = document.querySelector< HTMLElement >( '.next-admin-layout__surface' ); - setContainerElement( node ); - }, [] ); - return ( ); diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 8c02a5a275eb..d918a8095fdd 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -59,9 +59,6 @@ function Dashboard(): JSX.Element { */ const dateFilters = useReportDateFilters( '/' ); - // Container element for the date filters panel responsive layout. - const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); - // The sections carry each section's default layout, so until the entity // resolves the layout above is transiently empty. WidgetDashboard treats an // empty layout as "no widgets" and force-opens edit mode, so it must not @@ -108,13 +105,9 @@ function Dashboard(): JSX.Element { * The date filters drive every section, so they render once * below the section tabs and above the widgets, sharing the * URL search state across all sections. - * - * The wrapper div is also the responsive-measurement target: - * DateFiltersPanel reads its width to pick mobile/wide layouts - * instead of relying on the viewport. */ } -
- +
+
{ sections.map( section => ( ( null ); - return ( - +
+
diff --git a/projects/packages/premium-analytics/routes/reports/authors/page.tsx b/projects/packages/premium-analytics/routes/reports/authors/page.tsx index f64727b09cea..79c9c91c4d9b 100644 --- a/projects/packages/premium-analytics/routes/reports/authors/page.tsx +++ b/projects/packages/premium-analytics/routes/reports/authors/page.tsx @@ -12,7 +12,7 @@ import { useReportRetry, } from '@jetpack-premium-analytics/widgets-toolkit'; import { Breadcrumbs } from '@wordpress/admin-ui'; -import { useMemo, useState } from '@wordpress/element'; +import { useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useSearch } from '@wordpress/route'; /** @@ -74,7 +74,6 @@ function AuthorsReport(): JSX.Element { const dateFilters = useReportDateFilters( ROUTE_FROM ); const dashboardLink = useDashboardLink(); - const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); return ( } > - - -
- } - > + }> { /* * Replace the row-count-based table state when either request fails, * so cached rows are not shown as current and an initial failure does diff --git a/projects/packages/premium-analytics/routes/reports/clicks/page.tsx b/projects/packages/premium-analytics/routes/reports/clicks/page.tsx index 5cff05cbad85..bd84815680ff 100644 --- a/projects/packages/premium-analytics/routes/reports/clicks/page.tsx +++ b/projects/packages/premium-analytics/routes/reports/clicks/page.tsx @@ -18,7 +18,7 @@ import { useReportRetry, } from '@jetpack-premium-analytics/widgets-toolkit'; import { Breadcrumbs } from '@wordpress/admin-ui'; -import { useCallback, useMemo, useState } from '@wordpress/element'; +import { useCallback, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useNavigate, useSearch } from '@wordpress/route'; /** @@ -146,7 +146,6 @@ function ClicksReport(): JSX.Element { const dateFilters = useReportDateFilters( ROUTE_FROM ); const dashboardLink = useDashboardLink(); - const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); return ( } > - - -
- } - > + }> { records.isError ? ( ( null ); return ( } > - - -
- } - > + }> { records.isError ? ( ( null ); - /* * Keyed by tab so the table's internal view state (sort, search, page) * resets when the records set changes. @@ -234,11 +231,7 @@ function PostsReport(): JSX.Element { > } - filters={ -
- -
- } + filters={ } > { records.isError ? ( ( null ); return ( } > - - - - } - > + }> { records.isError ? ( ( null ); return ( } > - - - - } - > + }> { records.isError ? ( getUtmFields( activeTab ), [ activeTab ] ); const dateFilters = useReportDateFilters( ROUTE_FROM ); const dashboardLink = useDashboardLink(); - const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); return ( } - filters={ -
- -
- } + filters={ } > { records.isError ? ( ( null ); return ( - - - - } - > + }> { records.isError ? ( Date: Wed, 29 Jul 2026 12:42:00 +0100 Subject: [PATCH 07/16] cover the label mode decision with tests extracts it from the panel; jsdom has no layout, so the probe stays visual --- .../add-WOOA7S-1816-responsive-preset-labels | 4 ++ .../src/__tests__/date-range-layout.test.ts | 65 +++++++++++++++++++ .../date-filters-panel/date-filters-panel.tsx | 30 ++++----- .../date-filters-panel/preset-row-probe.tsx | 9 +-- .../packages/ui/src/date-range-layout.ts | 35 ++++++++-- .../date-range-quick-presets.test.tsx | 33 ++++++++++ 6 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/add-WOOA7S-1816-responsive-preset-labels create mode 100644 projects/packages/premium-analytics/packages/ui/src/__tests__/date-range-layout.test.ts diff --git a/projects/packages/premium-analytics/changelog/add-WOOA7S-1816-responsive-preset-labels b/projects/packages/premium-analytics/changelog/add-WOOA7S-1816-responsive-preset-labels new file mode 100644 index 000000000000..4e046cbac671 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-WOOA7S-1816-responsive-preset-labels @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Date range presets now shorten their labels before collapsing into a select, and the switch is measured from the active language's own labels instead of a fixed width. The panel also measures itself, so callers no longer pass a container element. diff --git a/projects/packages/premium-analytics/packages/ui/src/__tests__/date-range-layout.test.ts b/projects/packages/premium-analytics/packages/ui/src/__tests__/date-range-layout.test.ts new file mode 100644 index 000000000000..c6be5db0b3ac --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/__tests__/date-range-layout.test.ts @@ -0,0 +1,65 @@ +/** + * Internal dependencies + */ +import { resolvePresetLabelMode } from '../date-range-layout'; + +// Roughly the Russian measurements from WOOA7S-1817: the locale with the widest +// gap between the two forms, so the middle tier is a real range and not a sliver. +const WIDTHS = { full: 694, abbreviated: 316 }; + +describe( 'resolvePresetLabelMode', () => { + it( 'holds full labels until both measurements arrive', () => { + expect( resolvePresetLabelMode( null, WIDTHS ) ).toBe( 'full' ); + expect( resolvePresetLabelMode( 200, null ) ).toBe( 'full' ); + expect( resolvePresetLabelMode( null, null ) ).toBe( 'full' ); + } ); + + it( 'keeps full labels while they fit', () => { + expect( resolvePresetLabelMode( 1200, WIDTHS ) ).toBe( 'full' ); + expect( resolvePresetLabelMode( WIDTHS.full, WIDTHS ) ).toBe( 'full' ); + } ); + + it( 'abbreviates as soon as the full row no longer fits', () => { + expect( resolvePresetLabelMode( WIDTHS.full - 1, WIDTHS ) ).toBe( 'abbreviated' ); + expect( resolvePresetLabelMode( 500, WIDTHS ) ).toBe( 'abbreviated' ); + expect( resolvePresetLabelMode( WIDTHS.abbreviated, WIDTHS ) ).toBe( 'abbreviated' ); + } ); + + it( 'collapses to the select only once the abbreviated row overflows too', () => { + expect( resolvePresetLabelMode( WIDTHS.abbreviated - 1, WIDTHS ) ).toBe( 'select' ); + expect( resolvePresetLabelMode( 0, WIDTHS ) ).toBe( 'select' ); + } ); + + /* + * Each tier has to be reachable at some width. A rule that skipped the middle + * one would still satisfy the boundary assertions above while dropping + * straight from full labels to a select, which is the behavior this replaced. + */ + it( 'reaches every mode across the width range', () => { + const seen = new Set( + Array.from( { length: 1200 }, ( _, width ) => resolvePresetLabelMode( width, WIDTHS ) ) + ); + + expect( seen ).toEqual( new Set( [ 'full', 'abbreviated', 'select' ] ) ); + } ); + + /* + * Widths come from measuring live DOM, so they are not guaranteed ordered. + * The rule must stay monotonic regardless: never widen the labels as the + * container narrows. + */ + it( 'never upgrades the labels as the container shrinks', () => { + const rank = { select: 0, abbreviated: 1, full: 2 } as const; + const degenerate = { full: 300, abbreviated: 500 }; + + for ( const widths of [ WIDTHS, degenerate ] ) { + let previous = rank[ resolvePresetLabelMode( 1200, widths ) ]; + + for ( let width = 1200; width >= 0; width -= 1 ) { + const current = rank[ resolvePresetLabelMode( width, widths ) ]; + expect( current ).toBeLessThanOrEqual( previous ); + previous = current; + } + } + } ); +} ); 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 index 90b3b1ab26b5..906016754121 100644 --- 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 @@ -20,14 +20,18 @@ import { useMemo, useCallback, useState, useEffect } from 'react'; */ import { DateComparisonDropdown } from '../date-comparison-dropdown'; import { DateRangeFilter } from '../date-range-filter'; -import { WIDE_CALENDAR_CONTAINER_THRESHOLD, type PresetLabelMode } from '../date-range-layout'; +import { + resolvePresetLabelMode, + WIDE_CALENDAR_CONTAINER_THRESHOLD, + type PresetRowWidths, +} from '../date-range-layout'; import { getCommittedCustomRange, getCustomTriggerLabel, getCustomTriggerState, } from '../date-range-popover'; import { useComparisonDatePresets } from '../use-comparison-date-presets'; -import { PresetRowProbe, type PresetRowWidths } from './preset-row-probe'; +import { PresetRowProbe } from './preset-row-probe'; import './date-filters-panel.scss'; @@ -260,22 +264,12 @@ export function DateFiltersPanel( { setRowWidths( widths ); }, [] ); - /* - * The longest labels that still fit. Both candidates come from the probe, so - * the boundary follows the active locale rather than a breakpoint picked for - * English. Holds `full` until measured; a wrong guess corrects next frame. - */ - const labelMode: PresetLabelMode = useMemo( () => { - if ( containerWidth === null || rowWidths === null ) { - return 'full'; - } - - if ( containerWidth >= rowWidths.full ) { - return 'full'; - } - - return containerWidth >= rowWidths.abbreviated ? 'abbreviated' : 'select'; - }, [ containerWidth, rowWidths ] ); + // Both candidates come from the probe, so the boundary follows the active + // locale rather than a breakpoint picked for English. + const labelMode = useMemo( + () => resolvePresetLabelMode( containerWidth, rowWidths ), + [ containerWidth, rowWidths ] + ); const isCompact = labelMode === 'select'; const isWideScreen = diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx index 454efec9c4c3..ca452c83e7f8 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx @@ -7,16 +7,9 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react'; /** * Internal dependencies */ +import type { PresetRowWidths } from '../date-range-layout'; import './preset-row-probe.scss'; -/** - * Natural width of the preset row in each label form, in CSS pixels. - */ -export type PresetRowWidths = { - full: number; - abbreviated: number; -}; - export type PresetRowProbeProps = { /** Pill labels in display order. A preset with no short form reuses its full label. */ presets: ReadonlyArray< { id: string; label: string; shortLabel?: string } >; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts index c51626d5903c..9a27d1c65378 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-layout.ts @@ -15,12 +15,39 @@ export type PresetLabelMode = | 'select'; /** - * Threshold width (in pixels) below which preset controls collapse to a select. + * Natural width of the preset row in each label form, in CSS pixels. + */ +export type PresetRowWidths = { + full: number; + abbreviated: number; +}; + +/** + * Longest label form that fits the width available. * - * @deprecated Superseded by the measured `PresetLabelMode`. Still read while the - * remaining consumers migrate. + * Falls back to `full` until both measurements are in: it is what the panel + * showed before any of this existed, and a wrong guess corrects on the next + * frame. Boundaries are inclusive, so a form that exactly fills the row counts + * as fitting. + * + * @param availableWidth - Measured width of the panel, or null before first measure. + * @param rowWidths - Natural widths from the probe, or null before first measure. + * @return The label form to render. */ -export const MOBILE_CONTAINER_WIDTH_THRESHOLD = 600; +export function resolvePresetLabelMode( + availableWidth: number | null, + rowWidths: PresetRowWidths | null +): PresetLabelMode { + if ( availableWidth === null || rowWidths === null ) { + return 'full'; + } + + if ( availableWidth >= rowWidths.full ) { + return 'full'; + } + + return availableWidth >= rowWidths.abbreviated ? 'abbreviated' : 'select'; +} /** * Threshold width (in pixels) for showing two months in the custom-range calendar. diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx index 28d4fd92efb3..49ba448728c4 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx @@ -68,4 +68,37 @@ describe( 'DateRangeQuickPresets', () => { expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 ); expect( screen.getByRole( 'combobox', { name: 'Period' } ) ).toBeInTheDocument(); } ); + + it( 'shortens the visible labels when abbreviated', () => { + renderPresets( { labelMode: 'abbreviated' } ); + + expect( screen.getAllByRole( 'button' ).map( button => button.textContent ) ).toEqual( [ + '24H', + '7D', + '30D', + '12M', + ] ); + } ); + + /* + * The abbreviations drop the wording that named the period, so the accessible + * name has to keep it. Querying by the full name is the assertion: it only + * resolves through the aria-label, since the visible text is "7D". + */ + it( 'keeps the full label as the accessible name when abbreviated', () => { + renderPresets( { labelMode: 'abbreviated', value: 'last-7-days' } ); + + expect( screen.getByRole( 'button', { name: 'Last 7 days' } ) ).toHaveAttribute( + 'aria-pressed', + 'true' + ); + } ); + + it( 'leaves the accessible name alone when labels are full', () => { + renderPresets( { labelMode: 'full' } ); + + expect( screen.getByRole( 'button', { name: 'Last 7 days' } ) ).not.toHaveAttribute( + 'aria-label' + ); + } ); } ); From 3b755b4a90f6f8de289cc806908ece8cbdffcfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 29 Jul 2026 18:31:02 +0100 Subject: [PATCH 08/16] match the spec's Last 24H abbreviation tightens the short-label docblock while in there --- .../packages/datetime/src/presets/primary.ts | 12 ++++-------- .../__tests__/date-range-quick-presets.test.tsx | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts index 451ec9d356f4..d5c33ce43297 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts @@ -68,13 +68,9 @@ type PresetDefinition = { id: SelectablePresetId; getLabel: () => string; /** - * Short label for the surface pills when the full one does not fit. Only the - * quick surface presets carry one, since they are the only presets that - * render as pills in a fixed-width row. - * - * Translated separately rather than derived from the full label: these are - * not truncations, and the English forms are initials that most languages - * write differently. + * Short label for the surface pills. Only the quick surface presets carry + * one: they are the only presets rendered in a fixed-width row. Translated + * separately rather than truncated, since the English forms are initials. */ getShortLabel?: () => string; getRange: ( ctx: DateContext ) => Required< DateRange >; @@ -106,7 +102,7 @@ export const PRESET_DEFINITIONS: ReadonlyArray< PresetDefinition > = [ getLabel: () => __( 'Last 24 hours', 'jetpack-premium-analytics-pkg' ), getShortLabel: () => /* translators: abbreviation for "Last 24 hours". Shown in a segmented control too narrow for the full label, so keep it as short as the language allows. */ - _x( '24H', 'short date range preset', 'jetpack-premium-analytics-pkg' ), + _x( 'Last 24H', 'short date range preset', 'jetpack-premium-analytics-pkg' ), getRange: ( { now } ) => ( { from: subHours( now, 24 ), to: now, diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx index 49ba448728c4..2d2f920294d5 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-quick-presets/__tests__/date-range-quick-presets.test.tsx @@ -73,7 +73,7 @@ describe( 'DateRangeQuickPresets', () => { renderPresets( { labelMode: 'abbreviated' } ); expect( screen.getAllByRole( 'button' ).map( button => button.textContent ) ).toEqual( [ - '24H', + 'Last 24H', '7D', '30D', '12M', From 7b6fb374ba3a34dee4be6bef521289d1fd647b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 31 Jul 2026 10:48:45 +0100 Subject: [PATCH 09/16] rename the compact story to what it now shows 360px lands on the abbreviated tier, not the select --- .../stories/date-filters-panel.stories.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx index 82d8af56f08c..f1a90d7a45c5 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx @@ -193,9 +193,11 @@ export const CustomRangeWithComparison: Story = { }; /** - * Narrow container — quick presets collapse to the compact select, like mobile layouts. + * Narrow container: the presets shorten to their abbreviated form rather than + * collapsing to the select, which only fires once even the abbreviated row stops + * fitting. In English that is well below this width. */ -export const CompactLayout: Story = { +export const AbbreviatedLabels: Story = { render: () => , }; From dcb769d911efbdd0ba0cd373412e45c60c0fad07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 31 Jul 2026 16:42:36 +0100 Subject: [PATCH 10/16] cut the panel's work on every resize frame memoizes the probe, floors the measured width, drops a redundant measure pass --- .../date-filters-panel/date-filters-panel.tsx | 6 +++- .../date-filters-panel/preset-row-probe.scss | 4 +-- .../date-filters-panel/preset-row-probe.tsx | 29 +++++++++++++------ 3 files changed, 27 insertions(+), 12 deletions(-) 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 index 906016754121..74eef30d63c8 100644 --- 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 @@ -213,7 +213,11 @@ export function DateFiltersPanel( { const handleResize = useCallback( ( entries: ResizeObserverEntry[] ) => { const entry = entries[ 0 ]; if ( entry ) { - setContainerWidth( entry.contentRect.width ); + // Floored, and floored rather than rounded to stay on the conservative + // side of the probe's `Math.ceil`. Both consumers are step functions, so + // the fractional part never changes the output while a raw float would + // re-render the panel on every frame of a drag. + setContainerWidth( Math.floor( entry.contentRect.width ) ); } }, [] ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss index 42d6dc46087c..33592f02affa 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.scss @@ -3,8 +3,8 @@ // contributing nothing to the layout it informs. .preset-row-probe { position: absolute; - // Out of view without `display: none` or `visibility: hidden`, either of - // which would stop the browser laying it out and leave every width at 0. + // Hidden with `visibility`, never `display: none`: the latter takes the rig + // out of layout entirely and every measured width comes back 0. top: 0; left: 0; visibility: hidden; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx index ca452c83e7f8..c4ae621d2dbc 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/preset-row-probe.tsx @@ -3,7 +3,7 @@ */ import { chevronDown } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; -import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { memo, useCallback, useLayoutEffect, useRef } from 'react'; /** * Internal dependencies */ @@ -35,17 +35,25 @@ export type PresetRowProbeProps = { * mode. Measuring the live row would oscillate instead: shortening the labels * shrinks the row, the full labels then look like they fit, and back again. * - * @param props - Component props. - * @param props.presets - Pill labels in display order. - * @param props.customTriggerLabel - The custom trigger's current label. - * @param props.onMeasure - Receives both natural widths. + * Exported memoized: the panel re-renders on every step of a resize, and this + * output only moves when the labels do. + * + * @param {PresetRowProbeProps} props - The props for the PresetRowProbe component. + * @return The preset row probe element. */ -export function PresetRowProbe( { presets, customTriggerLabel, onMeasure }: PresetRowProbeProps ) { +function PresetRowProbeComponent( { + presets, + customTriggerLabel, + onMeasure, +}: PresetRowProbeProps ) { const fullRef = useRef< HTMLDivElement >( null ); const abbreviatedRef = useRef< HTMLDivElement >( null ); // Last reported pair, so identical widths don't push a new object downstream. - const [ reported, setReported ] = useState< PresetRowWidths | null >( null ); + // A ref rather than state: holding it in state would change `measure`'s + // identity on every measurement, re-firing the effect below and costing a + // second pair of layout reads to reach the same answer. + const reportedRef = useRef< PresetRowWidths | null >( null ); const measure = useCallback( () => { const full = fullRef.current?.getBoundingClientRect().width ?? 0; @@ -57,14 +65,15 @@ export function PresetRowProbe( { presets, customTriggerLabel, onMeasure }: Pres // Sub-pixel jitter from fractional layout would otherwise churn the mode. const next = { full: Math.ceil( full ), abbreviated: Math.ceil( abbreviated ) }; + const reported = reportedRef.current; if ( reported?.full === next.full && reported?.abbreviated === next.abbreviated ) { return; } - setReported( next ); + reportedRef.current = next; onMeasure( next ); - }, [ onMeasure, reported ] ); + }, [ onMeasure ] ); // The probe is `max-content`, so its width only moves when the labels or the // typography do, and both arrive as a render. @@ -130,3 +139,5 @@ export function PresetRowProbe( { presets, customTriggerLabel, onMeasure }: Pres ); } + +export const PresetRowProbe = memo( PresetRowProbeComponent ); From 5bc905446011fcf5a4fcc5316934ac67f0f06b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 31 Jul 2026 16:43:24 +0100 Subject: [PATCH 11/16] let the section header size the date controls a shrink-to-fit slot made the panel measure its content and never shorten --- .../date-filters-panel.scss | 10 ++++++++ .../section-header/section-header.module.scss | 25 +++++++++++++++++++ .../ui/src/section-header/section-header.tsx | 3 ++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 projects/packages/premium-analytics/packages/ui/src/section-header/section-header.module.scss diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss index 283e6cb8e7fb..d2fa1697a494 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss @@ -1,5 +1,15 @@ .date-filters-panel { + /* + * The responsive layout comes from this element's own measured width, so it + * has to be sized by its container and never by its content. As a flex item + * that means filling the slot: a shrink-to-fit panel measures the width it + * wants, concludes that everything fits, and never steps down from the full + * labels. Inert in a block parent, where it already spans its container. + */ + flex: 1 1 0%; + min-inline-size: 0; + &__primary, &__comparison { flex: 0 1 auto; diff --git a/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.module.scss b/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.module.scss new file mode 100644 index 000000000000..32d7737e96af --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.module.scss @@ -0,0 +1,25 @@ +/* + * The controls slot takes whatever width the title leaves, so its occupant is + * sized by the header rather than by its own content. + * + * This matters because the date controls measure their own box to pick a + * responsive layout. In a shrink-to-fit slot they measure the width they want + * instead of the width they have, always conclude that everything fits, and + * overflow the header still showing the full labels. The report pages hand the + * same panel a full-row box for the same reason: see `.filtersContent` in + * `report-page-layout.module.scss`. + */ +.controls { + flex: 1 1 0%; + min-inline-size: 0; + + /* + * The panel fills the slot, so the header's end-anchoring has to happen + * inside it rather than through the slot's own `justify`. Scoped to the + * panel: the other occupant of this slot is the year filter, a bordered + * group that has to keep its natural width. + */ + :global(.date-filters-panel) { + justify-content: flex-end; + } +} diff --git a/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.tsx b/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.tsx index 376d4f7486ad..c7419ef0621a 100644 --- a/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/section-header/section-header.tsx @@ -1,5 +1,6 @@ import { Stack, Text } from '@wordpress/ui'; import { ReactNode } from 'react'; +import styles from './section-header.module.scss'; type SectionHeaderProps = { /** @@ -36,7 +37,7 @@ export function SectionHeader( { title, subtitle, children }: SectionHeaderProps { subtitle ? { subtitle } : null }
- + { children } From 0675e7785553055fc1a817f9ff5206f23a4701aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 3 Aug 2026 08:33:00 +0100 Subject: [PATCH 12/16] label the custom trigger "Custom" while idle the remembered range desynced the probe, which only ever sees props --- .../date-filters-panel/date-filters-panel.tsx | 7 +--- .../get-custom-trigger-state.test.ts | 38 +++++++++++++------ .../date-range-popover/date-range-filter.tsx | 1 - .../get-custom-trigger-state.ts | 15 ++++---- .../ui/src/date-range-popover/index.ts | 4 +- 5 files changed, 38 insertions(+), 27 deletions(-) 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 index 74eef30d63c8..85ee9d2ae641 100644 --- 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 @@ -25,11 +25,7 @@ import { WIDE_CALENDAR_CONTAINER_THRESHOLD, type PresetRowWidths, } from '../date-range-layout'; -import { - getCommittedCustomRange, - getCustomTriggerLabel, - getCustomTriggerState, -} from '../date-range-popover'; +import { getCustomTriggerLabel, getCustomTriggerState } from '../date-range-popover'; import { useComparisonDatePresets } from '../use-comparison-date-presets'; import { PresetRowProbe } from './preset-row-probe'; @@ -246,7 +242,6 @@ export function DateFiltersPanel( { } ), range, committedRange, - rememberedCustomRange: getCommittedCustomRange( validatedAppliedPresetId, appliedRange ), customLabel: __( 'Custom', 'jetpack-premium-analytics-pkg' ), formatRange: formatDateRange, } ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts index 253060ec9a63..ab226795911c 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts @@ -73,33 +73,49 @@ describe( 'getCustomTriggerLabel', () => { const formatRange = ( { from, to }: { from?: Date; to?: Date } ) => `${ from?.toISOString() ?? '' }–${ to?.toISOString() ?? '' }`; - const rememberedCustomRange = { + const customRange = { from: new Date( '2026-07-05T00:00:00.000Z' ), to: new Date( '2026-07-10T23:59:59.000Z' ), }; - it( 'shows the remembered custom range while idle on a preset', () => { + const formatted = '2026-07-05T00:00:00.000Z–2026-07-10T23:59:59.000Z'; + + // Idle is "a preset is driving the range". Echoing a custom range there reads + // as if it were applied, and it also desynced the measuring probe, which has + // only the props to work from and so always resolved this case to the label. + it( 'names itself while idle, whatever custom range ran before', () => { expect( getCustomTriggerLabel( { triggerState: 'idle', - range: rememberedCustomRange, - committedRange: rememberedCustomRange, - rememberedCustomRange, + range: customRange, + committedRange: customRange, customLabel, formatRange, } ) - ).toBe( '2026-07-05T00:00:00.000Z–2026-07-10T23:59:59.000Z' ); + ).toBe( customLabel ); } ); - it( 'falls back to the custom label when nothing is remembered', () => { + it( 'shows the staged range while the popover is open', () => { expect( getCustomTriggerLabel( { - triggerState: 'idle', - range: rememberedCustomRange, - committedRange: rememberedCustomRange, + triggerState: 'staged', + range: customRange, + committedRange: {}, customLabel, formatRange, } ) - ).toBe( customLabel ); + ).toBe( formatted ); + } ); + + it( 'shows the committed range once a custom range is applied', () => { + expect( + getCustomTriggerLabel( { + triggerState: 'applied', + range: {}, + committedRange: customRange, + customLabel, + formatRange, + } ) + ).toBe( formatted ); } ); } ); 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 index 397044e7a883..8ecedef10af6 100644 --- 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 @@ -302,7 +302,6 @@ export function DateRangePopover( { triggerState, range, committedRange, - rememberedCustomRange, customLabel: __( 'Custom', 'jetpack-premium-analytics-pkg' ), formatRange: formatDateRange, } ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts index e3df319d7a70..d911ef73c049 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts @@ -66,13 +66,19 @@ type GetCustomTriggerLabelArgs = { triggerState: CustomTriggerState; range: TriggerDateRange; committedRange: TriggerDateRange; - rememberedCustomRange?: TriggerDateRange | null; customLabel: string; formatRange: ( range: TriggerDateRange ) => string; }; /** - * Derives the custom trigger label from visual state and remembered ranges. + * Derives the custom trigger label from the trigger's visual state. + * + * Idle means a preset is driving the range, so the trigger names itself instead + * of echoing a range that is not in effect. The last custom range is still + * restored when the popover reopens. + * + * Every argument is derived from props, which is what keeps the measuring probe + * and the trigger on the same string. * * @param {GetCustomTriggerLabelArgs} args - Trigger state, staged/applied ranges, and formatters. * @return The trigger button label. @@ -81,7 +87,6 @@ export function getCustomTriggerLabel( { triggerState, range, committedRange, - rememberedCustomRange, customLabel, formatRange, }: GetCustomTriggerLabelArgs ): string { @@ -93,9 +98,5 @@ export function getCustomTriggerLabel( { return formatRange( committedRange ); } - if ( rememberedCustomRange?.from && rememberedCustomRange.to ) { - return formatRange( rememberedCustomRange ); - } - return customLabel; } 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 index 2937cbb3e27d..dfb9bd7f49f0 100644 --- 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 @@ -4,7 +4,7 @@ export type { DateRange } from './date-range-filter'; * The custom trigger's label has a second reader: the panel's measuring probe * needs the exact string the trigger is showing, since "Custom" and a formatted * range differ by enough width to move the label-mode boundary. Exported so both - * derive it from one implementation instead of two. + * derive it from one implementation, and taking only props so neither side can + * feed it state the other cannot see. */ export { getCustomTriggerLabel, getCustomTriggerState } from './get-custom-trigger-state'; -export { getCommittedCustomRange } from './last-custom-range'; From e2eb661954d79419136377c1f085fb48f16ae253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 3 Aug 2026 09:12:37 +0100 Subject: [PATCH 13/16] restore the remembered range on the custom trigger intentional: it is the way back to the last custom range --- .../date-filters-panel/date-filters-panel.tsx | 7 +++- .../get-custom-trigger-state.test.ts | 38 ++++++------------- .../date-range-popover/date-range-filter.tsx | 1 + .../get-custom-trigger-state.ts | 15 ++++---- .../ui/src/date-range-popover/index.ts | 4 +- 5 files changed, 27 insertions(+), 38 deletions(-) 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 index 85ee9d2ae641..74eef30d63c8 100644 --- 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 @@ -25,7 +25,11 @@ import { WIDE_CALENDAR_CONTAINER_THRESHOLD, type PresetRowWidths, } from '../date-range-layout'; -import { getCustomTriggerLabel, getCustomTriggerState } from '../date-range-popover'; +import { + getCommittedCustomRange, + getCustomTriggerLabel, + getCustomTriggerState, +} from '../date-range-popover'; import { useComparisonDatePresets } from '../use-comparison-date-presets'; import { PresetRowProbe } from './preset-row-probe'; @@ -242,6 +246,7 @@ export function DateFiltersPanel( { } ), range, committedRange, + rememberedCustomRange: getCommittedCustomRange( validatedAppliedPresetId, appliedRange ), customLabel: __( 'Custom', 'jetpack-premium-analytics-pkg' ), formatRange: formatDateRange, } ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts index ab226795911c..253060ec9a63 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/get-custom-trigger-state.test.ts @@ -73,49 +73,33 @@ describe( 'getCustomTriggerLabel', () => { const formatRange = ( { from, to }: { from?: Date; to?: Date } ) => `${ from?.toISOString() ?? '' }–${ to?.toISOString() ?? '' }`; - const customRange = { + const rememberedCustomRange = { from: new Date( '2026-07-05T00:00:00.000Z' ), to: new Date( '2026-07-10T23:59:59.000Z' ), }; - const formatted = '2026-07-05T00:00:00.000Z–2026-07-10T23:59:59.000Z'; - - // Idle is "a preset is driving the range". Echoing a custom range there reads - // as if it were applied, and it also desynced the measuring probe, which has - // only the props to work from and so always resolved this case to the label. - it( 'names itself while idle, whatever custom range ran before', () => { + it( 'shows the remembered custom range while idle on a preset', () => { expect( getCustomTriggerLabel( { triggerState: 'idle', - range: customRange, - committedRange: customRange, + range: rememberedCustomRange, + committedRange: rememberedCustomRange, + rememberedCustomRange, customLabel, formatRange, } ) - ).toBe( customLabel ); + ).toBe( '2026-07-05T00:00:00.000Z–2026-07-10T23:59:59.000Z' ); } ); - it( 'shows the staged range while the popover is open', () => { + it( 'falls back to the custom label when nothing is remembered', () => { expect( getCustomTriggerLabel( { - triggerState: 'staged', - range: customRange, - committedRange: {}, - customLabel, - formatRange, - } ) - ).toBe( formatted ); - } ); - - it( 'shows the committed range once a custom range is applied', () => { - expect( - getCustomTriggerLabel( { - triggerState: 'applied', - range: {}, - committedRange: customRange, + triggerState: 'idle', + range: rememberedCustomRange, + committedRange: rememberedCustomRange, customLabel, formatRange, } ) - ).toBe( formatted ); + ).toBe( customLabel ); } ); } ); 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 index 8ecedef10af6..397044e7a883 100644 --- 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 @@ -302,6 +302,7 @@ export function DateRangePopover( { triggerState, range, committedRange, + rememberedCustomRange, customLabel: __( 'Custom', 'jetpack-premium-analytics-pkg' ), formatRange: formatDateRange, } ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts index d911ef73c049..e3df319d7a70 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/get-custom-trigger-state.ts @@ -66,19 +66,13 @@ type GetCustomTriggerLabelArgs = { triggerState: CustomTriggerState; range: TriggerDateRange; committedRange: TriggerDateRange; + rememberedCustomRange?: TriggerDateRange | null; customLabel: string; formatRange: ( range: TriggerDateRange ) => string; }; /** - * Derives the custom trigger label from the trigger's visual state. - * - * Idle means a preset is driving the range, so the trigger names itself instead - * of echoing a range that is not in effect. The last custom range is still - * restored when the popover reopens. - * - * Every argument is derived from props, which is what keeps the measuring probe - * and the trigger on the same string. + * Derives the custom trigger label from visual state and remembered ranges. * * @param {GetCustomTriggerLabelArgs} args - Trigger state, staged/applied ranges, and formatters. * @return The trigger button label. @@ -87,6 +81,7 @@ export function getCustomTriggerLabel( { triggerState, range, committedRange, + rememberedCustomRange, customLabel, formatRange, }: GetCustomTriggerLabelArgs ): string { @@ -98,5 +93,9 @@ export function getCustomTriggerLabel( { return formatRange( committedRange ); } + if ( rememberedCustomRange?.from && rememberedCustomRange.to ) { + return formatRange( rememberedCustomRange ); + } + return customLabel; } 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 index dfb9bd7f49f0..2937cbb3e27d 100644 --- 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 @@ -4,7 +4,7 @@ export type { DateRange } from './date-range-filter'; * The custom trigger's label has a second reader: the panel's measuring probe * needs the exact string the trigger is showing, since "Custom" and a formatted * range differ by enough width to move the label-mode boundary. Exported so both - * derive it from one implementation, and taking only props so neither side can - * feed it state the other cannot see. + * derive it from one implementation instead of two. */ export { getCustomTriggerLabel, getCustomTriggerState } from './get-custom-trigger-state'; +export { getCommittedCustomRange } from './last-custom-range'; From 52337a9e6da4e58b55e712d69899972a095f3513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 3 Aug 2026 09:23:30 +0100 Subject: [PATCH 14/16] own the remembered custom range in the panel the probe reproduces the trigger's label, so it cannot be popover state --- .../date-filters-panel/date-filters-panel.tsx | 29 ++++++++++++++++- .../date-range-filter/date-range-filter.tsx | 2 ++ .../__tests__/date-range-popover.test.tsx | 24 ++++++++++++++ .../date-range-popover/date-range-filter.tsx | 31 +++++++++---------- .../ui/src/date-range-popover/index.ts | 6 ++++ 5 files changed, 74 insertions(+), 18 deletions(-) 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 index 74eef30d63c8..d28eb13e882d 100644 --- 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 @@ -29,6 +29,7 @@ import { getCommittedCustomRange, getCustomTriggerLabel, getCustomTriggerState, + type RememberedCustomRange, } from '../date-range-popover'; import { useComparisonDatePresets } from '../use-comparison-date-presets'; import { PresetRowProbe } from './preset-row-probe'; @@ -232,6 +233,30 @@ export function DateFiltersPanel( { setObserverRef( rootElement ?? document.body ); }, [ rootElement, setObserverRef ] ); + /* + * The last applied custom range, remembered so the trigger can offer the way + * back to it while a preset is driving the range. + * + * Owned here rather than inside the popover because it is an input to the + * trigger's label, and the probe below has to reproduce that label exactly. + * Held downstream it would be invisible to the probe, which would measure + * "Custom" against a trigger showing a formatted range and under-measure the + * row by the difference between the two. + * + * Only ever set: a preset selection clears the committed custom range, and + * the point of remembering is to survive exactly that. + */ + const [ rememberedCustomRange, setRememberedCustomRange ] = + useState< RememberedCustomRange | null >( null ); + + useEffect( () => { + const committedCustomRange = getCommittedCustomRange( validatedAppliedPresetId, appliedRange ); + + if ( committedCustomRange ) { + setRememberedCustomRange( committedCustomRange ); + } + }, [ validatedAppliedPresetId, appliedRange ] ); + // Derived through the same helpers the trigger uses, so the probe measures // the string the trigger is actually showing. const customTriggerLabel = useMemo( () => { @@ -246,7 +271,7 @@ export function DateFiltersPanel( { } ), range, committedRange, - rememberedCustomRange: getCommittedCustomRange( validatedAppliedPresetId, appliedRange ), + rememberedCustomRange, customLabel: __( 'Custom', 'jetpack-premium-analytics-pkg' ), formatRange: formatDateRange, } ); @@ -255,6 +280,7 @@ export function DateFiltersPanel( { canApply, isPrimaryPickerOpen, range, + rememberedCustomRange, validatedAppliedPresetId, validatedPresetId, ] ); @@ -311,6 +337,7 @@ export function DateFiltersPanel( { timeZone={ timeZone } labelMode={ labelMode } isWideScreen={ isWideScreen } + rememberedCustomRange={ rememberedCustomRange } onOpenChange={ setIsPrimaryPickerOpen } /> diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx index 61bd93c2ac46..3d2abff6bc4b 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-filter/date-range-filter.tsx @@ -51,6 +51,7 @@ export function DateRangeFilter( { canApply, timeZone, onOpenChange, + rememberedCustomRange, labelMode = 'full', isWideScreen = false, }: DateRangeFilterProps ) { @@ -92,6 +93,7 @@ export function DateRangeFilter( { timeZone={ timeZone } isWideScreen={ isWideScreen } onOpenChange={ onOpenChange } + rememberedCustomRange={ rememberedCustomRange } triggerAsCompositeItem={ ! isCompact } isCompact={ isCompact } /> diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/date-range-popover.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/date-range-popover.test.tsx index c841717b59ca..f076e5c6427b 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/date-range-popover.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/date-range-popover.test.tsx @@ -68,4 +68,28 @@ describe( 'DateRangePopover', () => { expect( onCancel ).toHaveBeenCalledTimes( 1 ); expect( onApply ).not.toHaveBeenCalled(); } ); + + /* + * The default props apply a rolling preset, so the trigger is idle and the + * remembered range is the only thing that can label it. The range has to + * arrive as a prop: the panel's measuring probe reproduces this label from + * the panel's own props, so anything the popover remembered privately would + * be invisible to the probe and the two would measure different strings. + */ + it( 'labels the trigger from the remembered range prop while idle', () => { + renderPopover( { + rememberedCustomRange: { + from: new Date( 2026, 4, 1, 0, 0, 0, 0 ), + to: new Date( 2026, 4, 15, 23, 59, 59, 999 ), + }, + } ); + + expect( screen.queryByRole( 'button', { name: 'Custom' } ) ).not.toBeInTheDocument(); + } ); + + it( 'falls back to the custom label when nothing is remembered', () => { + renderPopover(); + + expect( screen.getByRole( 'button', { name: 'Custom' } ) ).toBeInTheDocument(); + } ); } ); 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 index 397044e7a883..bf3676aeee97 100644 --- 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 @@ -9,18 +9,14 @@ import { __ } from '@wordpress/i18n'; import { chevronDown } from '@wordpress/icons'; import { Button, Icon, Stack } from '@wordpress/ui'; import clsx from 'clsx'; -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useRef } from 'react'; import '@automattic/ui/style.css'; /** * Internal dependencies */ import { DateRangeInput } from '../date-range-input'; import { getCustomTriggerLabel, getCustomTriggerState } from './get-custom-trigger-state'; -import { - getCommittedCustomRange, - shouldRestoreLastCustomRange, - type RememberedCustomRange, -} from './last-custom-range'; +import { shouldRestoreLastCustomRange, type RememberedCustomRange } from './last-custom-range'; import './date-range-filter.scss'; /** @@ -217,6 +213,17 @@ type DateRangePopoverProps = DateRangePopoverContentProps & { */ onOpenChange?: ( isOpen: boolean ) => void; + /** + * The last applied custom range. It labels the trigger while a preset is + * driving the range, and is restaged when the popover reopens. + * + * Owned by `DateFiltersPanel` rather than held here, because the panel's + * measuring probe has to reproduce this trigger's label exactly. Local state + * here would be invisible to the probe, which would then measure "Custom" + * against a trigger showing a formatted range and under-measure the row. + */ + rememberedCustomRange?: RememberedCustomRange | null; + /** * Render the trigger as a `Composite.Item` so it joins the roving tabindex * of a surrounding `Composite` group (the date-filter surface). Leave unset @@ -241,13 +248,11 @@ export function DateRangePopover( { canApply, timeZone, onOpenChange, + rememberedCustomRange = null, isWideScreen = false, triggerAsCompositeItem = false, isCompact = false, }: DateRangePopoverProps ) { - const [ rememberedCustomRange, setRememberedCustomRange ] = - useState< RememberedCustomRange | null >( null ); - const [ isOpen, setIsOpen ] = useState( false ); /* @@ -257,14 +262,6 @@ export function DateRangePopover( { */ const closedByActionRef = useRef( false ); - useEffect( () => { - const committedCustomRange = getCommittedCustomRange( appliedPresetId, appliedRange ); - - if ( committedCustomRange ) { - setRememberedCustomRange( committedCustomRange ); - } - }, [ appliedPresetId, appliedRange ] ); - const handleOpenToggle = useCallback( ( next: boolean ) => { if ( next ) { 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 index 2937cbb3e27d..55a6699a3c50 100644 --- 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 @@ -5,6 +5,12 @@ export type { DateRange } from './date-range-filter'; * needs the exact string the trigger is showing, since "Custom" and a formatted * range differ by enough width to move the label-mode boundary. Exported so both * derive it from one implementation instead of two. + * + * For the same reason every input to that label is owned by the panel, including + * the remembered custom range below. Anything the trigger keeps to itself is + * invisible to the probe, and the two drift apart by exactly the width the + * measurement is trying to account for. */ export { getCustomTriggerLabel, getCustomTriggerState } from './get-custom-trigger-state'; export { getCommittedCustomRange } from './last-custom-range'; +export type { RememberedCustomRange } from './last-custom-range'; From 749cc200f6d69d20fd64013cf20ac21bc3844648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 3 Aug 2026 09:38:24 +0100 Subject: [PATCH 15/16] update locations report filters instance --- .../premium-analytics/routes/reports/locations/page.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/projects/packages/premium-analytics/routes/reports/locations/page.tsx b/projects/packages/premium-analytics/routes/reports/locations/page.tsx index 7bdc980cb9c1..4ea86358670e 100644 --- a/projects/packages/premium-analytics/routes/reports/locations/page.tsx +++ b/projects/packages/premium-analytics/routes/reports/locations/page.tsx @@ -116,7 +116,6 @@ export default function LocationsReportPage(): JSX.Element { const dateFilters = useReportDateFilters( ROUTE_FROM ); const dashboardLink = useDashboardLink(); - const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); const tableIsLoading = records.table.isLoading || records.table.isFetching; return ( @@ -133,11 +132,7 @@ export default function LocationsReportPage(): JSX.Element { > } - filters={ -
- -
- } + filters={ } > { records.isError ? ( Date: Mon, 3 Aug 2026 18:34:36 +0100 Subject: [PATCH 16/16] key the trigger font size to the collapsed row a 600px viewport query left a 16px trigger beside 13px pills --- .../src/date-filters-panel/date-filters-panel.scss | 9 +++++++++ .../ui/src/date-range-popover/date-range-filter.scss | 12 ++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss index d2fa1697a494..6e7ec340a00b 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.scss @@ -22,6 +22,15 @@ // adjustments for every descendant live here. &.is-compact { + // Collapsed, the trigger shares the row with the preset select rather + // than with the pills, so it takes the select's size. Keyed to the + // collapsed state and not to a 600px viewport: the abbreviated tier lets + // pills survive below that width, which left a 16px trigger sitting next + // to 13px pills. + .date-filters-panel-button { + font-size: max(var(--wpds-typography-font-size-md), 16px); + } + // The preset select and custom trigger become two separate bordered // controls, so drop the group's shared frame and flush hairline dividers // and space them with a gap instead. 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 index d7d4fb889d59..3521bb478aa6 100644 --- 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 @@ -22,7 +22,10 @@ // Hovered/focused trigger: same brand-tinted highlight the select-control // popup gives its items. .date-filters-panel-button { - font-size: max(var(--wpds-typography-font-size-md), 16px); + // Matches the preset pills it shares the row with. The collapsed row takes + // the select's larger size instead; that lives with the rest of the compact + // layout, in `date-filters-panel.scss`. + font-size: var(--wpds-typography-font-size-md); --wp-ui-button-background-color-active: var(--wpds-color-background-interactive-brand-weak-active); } @@ -112,10 +115,3 @@ ::view-transition-old(jp-premium-analytics--date-range-popover) { animation: none; } - -@media (min-width: 600px) { - - .date-filters-panel-button { - font-size: var(--wpds-typography-font-size-md); - } -}