diff --git a/projects/js-packages/storybook/changelog/register-premium-analytics-widgets-path b/projects/js-packages/storybook/changelog/register-premium-analytics-widgets-path new file mode 100644 index 000000000000..a29648a3733d --- /dev/null +++ b/projects/js-packages/storybook/changelog/register-premium-analytics-widgets-path @@ -0,0 +1,3 @@ +Significance: patch +Type: changed +Comment: Register the premium-analytics widgets directory as a story discovery path. diff --git a/projects/js-packages/storybook/storybook/projects.js b/projects/js-packages/storybook/storybook/projects.js index 6601798f40a9..f5261e5205aa 100644 --- a/projects/js-packages/storybook/storybook/projects.js +++ b/projects/js-packages/storybook/storybook/projects.js @@ -14,6 +14,7 @@ export const projects = [ 'projects/js-packages/social-logos/src/react', 'projects/packages/my-jetpack/_inc/components', 'projects/packages/premium-analytics/packages', + 'projects/packages/premium-analytics/widgets', 'projects/packages/publicize/_inc/components', 'projects/packages/search/src/dashboard/components', 'projects/packages/videopress/src/client/admin/components', diff --git a/projects/packages/premium-analytics/changelog/add-top-posts-widget-storybook b/projects/packages/premium-analytics/changelog/add-top-posts-widget-storybook new file mode 100644 index 000000000000..fb3d577fbe6d --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-top-posts-widget-storybook @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Storybook: Add a prop-driven Top posts & pages widget story under Packages/Premium Analytics/Widgets. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx index 099a44db2d0d..dfee85fbaef4 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx @@ -5,7 +5,8 @@ import { LeaderboardChartUnresponsive as BaseLeaderboardChart, useGlobalChartsContext, Legend, - hexToRgba, + lightenHexColor, + normalizeColorToHex, } from '@automattic/charts'; import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { Icon, Stack } from '@wordpress/ui'; @@ -136,16 +137,21 @@ export function LeaderboardChart( { ); /** - * Get chart colors for legend + * Bar color for overlay-label mode. + * + * The label sits on top of the bar, so the bar needs to read as a faint + * tint of the primary color. We can't pass a translucent color through the + * chart's `primaryColor` prop — it resolves the value via getElementStyles, + * which strips the alpha channel. Instead we pre-blend the primary with + * white to produce the opaque equivalent of an 8% alpha fill. */ - const chartColors = useMemo( () => { - const { color: primaryColor } = getElementStyles( { index: 0 } ); - if ( ! withComparison ) { - return { primaryColor }; + const barColor = useMemo( () => { + if ( ! withOverlayLabel ) { + return undefined; } - const { color: secondaryColor } = getElementStyles( { index: 1 } ); - return { primaryColor, secondaryColor }; - }, [ withComparison, getElementStyles ] ); + const { color: primaryColor } = getElementStyles( { index: 0 } ); + return lightenHexColor( normalizeColorToHex( primaryColor ), 0.92 ); + }, [ withOverlayLabel, getElementStyles ] ); /** * Merge theme bar border radius with style prop. @@ -183,7 +189,7 @@ export function LeaderboardChart( { withComparison={ withComparison } valueFormatter={ valueFormatter } legendLabels={ legendLabels } - primaryColor={ withOverlayLabel ? hexToRgba( chartColors.primaryColor, 0.08 ) : undefined } + primaryColor={ barColor } withOverlayLabel={ withOverlayLabel } showLegend={ false } style={ chartStyle } diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts index 121c13e7227b..40aa6c4178e4 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts @@ -18,3 +18,4 @@ export { } from './chart-leaderboard'; export { BarChart, type BarChartProps, type BarChartData, type BarChartStyle } from './chart-bar'; export { ChartEmptyState, type ChartEmptyStateProps } from './chart-empty-state'; +export { WidgetLoadingOverlay } from './widget-loading-overlay'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index 9b561dd55f5b..87259b94517c 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -20,6 +20,7 @@ export { type BarChartProps, type BarChartData, type BarChartStyle, + WidgetLoadingOverlay, } from './components'; /** diff --git a/projects/packages/premium-analytics/tsconfig.json b/projects/packages/premium-analytics/tsconfig.json index 3619d8447603..202a51a70064 100644 --- a/projects/packages/premium-analytics/tsconfig.json +++ b/projects/packages/premium-analytics/tsconfig.json @@ -9,5 +9,5 @@ "@jetpack-premium-analytics/*": [ "./packages/*/src" ] } }, - "include": [ "routes/**/*", "packages/**/*", "types/**/*" ] + "include": [ "routes/**/*", "packages/**/*", "types/**/*", "widgets/**/*" ] } diff --git a/projects/packages/premium-analytics/widgets/top-posts/index.ts b/projects/packages/premium-analytics/widgets/top-posts/index.ts new file mode 100644 index 000000000000..0abccc7e86cf --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/index.ts @@ -0,0 +1,2 @@ +export { TopPostsWidget, type TopPostsWidgetProps } from './top-posts-widget'; +export type { TopPostRow } from './types'; diff --git a/projects/packages/premium-analytics/widgets/top-posts/stories/top-posts-widget.stories.tsx b/projects/packages/premium-analytics/widgets/top-posts/stories/top-posts-widget.stories.tsx new file mode 100644 index 000000000000..e0f82b0c5c25 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/stories/top-posts-widget.stories.tsx @@ -0,0 +1,196 @@ +/** + * Internal dependencies + */ +import { withChartTheme } from '../../../packages/widgets-toolkit/src/stories/with-chart-theme'; +import { TopPostsWidget } from '../top-posts-widget'; +import type { TopPostRow } from '../types'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof TopPostsWidget > = { + title: 'Packages/Premium Analytics/Widgets/TopPosts', + component: TopPostsWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'The "Top posts & pages" widget. Renders the most-viewed posts and pages for the period as a leaderboard, with each row linking to the published content. This is a presentational component — it takes already-fetched rows via props and handles the loading, error, empty, and populated states.', + }, + }, + }, + decorators: [ withChartTheme ], +}; + +export default meta; + +type Story = StoryObj< typeof TopPostsWidget >; + +const mockRows: TopPostRow[] = [ + { + label: 'How we cut our build times in half', + value: 12840, + previousValue: 9870, + href: 'https://example.com/cut-build-times-in-half', + type: 'post', + }, + { + label: 'Pricing', + value: 9320, + previousValue: 10110, + href: 'https://example.com/pricing', + type: 'page', + }, + { + label: '10 lessons from scaling to a million users', + value: 7610, + previousValue: 5400, + href: 'https://example.com/lessons-scaling-million-users', + type: 'post', + }, + { + label: 'About us', + value: 4180, + previousValue: 4360, + href: 'https://example.com/about', + type: 'page', + }, + { + label: 'A practical guide to feature flags', + value: 2950, + previousValue: 0, + href: 'https://example.com/guide-to-feature-flags', + type: 'post', + }, +]; + +const mockLongLabelRows: TopPostRow[] = [ + { + label: + 'An exhaustively long, keyword-stuffed headline that almost certainly needs to be truncated before it overflows the row', + value: 8400, + href: 'https://example.com/very-long-headline-that-needs-truncation', + type: 'post', + }, + { + label: 'Frequently asked questions about billing, refunds, and account management', + value: 5120, + href: 'https://example.com/faq-billing-refunds-account-management', + type: 'page', + }, + { + label: 'Changelog', + value: 2010, + href: 'https://example.com/changelog', + type: 'page', + }, +]; + +/** + * Default populated state — a mix of posts and pages ranked by views. + */ +export const Default: Story = { + args: { + rows: mockRows, + }, +}; + +/** + * Comparison state — each value shows its change versus the previous period + * (green for gains, red for losses), driven by each row's `previousValue`. + * Mirrors the overlay comparison mode of the toolkit's `LeaderboardChart`. + */ +export const WithComparison: Story = { + args: { + rows: mockRows, + withComparison: true, + showLegend: true, + legendLabels: { + primary: 'Jun 1 – 18, 2026', + comparison: 'May 14 – 31, 2026', + }, + }, +}; + +/** + * Loading state — the chart renders its loading overlay while data is fetched. + */ +export const Loading: Story = { + args: { + rows: [], + isLoading: true, + }, +}; + +/** + * Empty state — no views were recorded for the selected period. + */ +export const NoViews: Story = { + args: { + rows: [], + }, +}; + +/** + * Error state — the report could not be loaded. + */ +export const ErrorState: Story = { + args: { + isError: true, + }, +}; + +/** + * Long titles are truncated with an ellipsis so rows stay single-line. + */ +export const LongLabels: Story = { + args: { + rows: mockLongLabelRows, + }, +}; + +/** + * Creates a decorator that wraps the story in a fixed-size container so the + * widget's responsiveness can be inspected at a given width. + * + * @param width - The container width (any CSS length). + * @param [height] - The container height; defaults to `auto`. + * @return A Storybook decorator. + */ +const createSizeDecorator = ( width: string, height = 'auto' ): Decorator => { + return Story => ( +