Skip to content

Commit 7bc4b4a

Browse files
authored
Premium Analytics: Show highlight metric notes in a tooltip (#54011)
* Premium Analytics: Add a tooltip for metric notes The highlight grids show each metric's note in a native title, which appears late, uses browser styling, and never shows for keyboard users. This component makes the noted element a WPDS Tooltip trigger and a tab stop, so the note opens on hover and on focus. Assistive tech reads the visually hidden copy in the trigger, and the popup is hidden from it, so the note is read once. * Premium Analytics: Show tile notes in a tooltip Site overview, Post detail and Subscriber highlights put each tile's note in a native title, which keyboard users never see. The tile header now opens the note in the WPDS tooltip on hover and on focus. * Premium Analytics: Show post highlight notes in a tooltip The post highlight card put each metric's note in a native title on the whole metric, which keyboard users never see. The note now opens in the WPDS tooltip from the metric label. The trigger stops at the label because a shortened value already opens its own tooltip with the exact figure, and one hover must not open both. * Premium Analytics: Add changelog entries for metric note tooltips The plugins that show the highlight grids follow the same set as the change that added the notes: the Premium Analytics plugin, Jetpack, and wpcomsh, which gets the dashboard through jetpack-mu-wpcom. * Address review: Stop asserting the removed note title (Codex P1 #1) Site overview and Subscriber highlights tests still looked each note up by its native title, which the tooltip replaced, so they failed. The visually hidden copy already shows which note each tile carries, and the tooltip itself is tested once in MetricNoteTooltip. * Address review: Keep the hidden note popup out of focus (Codex P2 #1) Base UI renders the tooltip popup with tabindex -1, so the aria-hidden popup could still take focus programmatically. A focused element hidden from assistive tech strands a screen reader user on nothing. The note is not interactive, so the popup now drops the tabindex. * Address review: Name the plain div in the note docblock The final review found "a plain box" vague for the element a metric with no note renders. * Address review: Drop the wpcomsh changelog entry wpcomsh stopped bundling Premium Analytics in #52717, so its users never see this change. The Jetpack entry already covers Atomic sites.
1 parent 9f3ad71 commit 7bc4b4a

14 files changed

Lines changed: 174 additions & 33 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: changed
3+
4+
Highlights: Show metric notes in a styled tooltip that also opens on keyboard focus.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { act, render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
6+
/**
7+
* Internal dependencies
8+
*/
9+
import { MetricNoteTooltip } from '../metric-note-tooltip';
10+
11+
const NOTE = 'Sum of daily visitors.';
12+
13+
type User = ReturnType< typeof userEvent.setup >;
14+
15+
function setup( note?: string ) {
16+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
17+
render( <MetricNoteTooltip note={ note }>Visitors</MetricNoteTooltip> );
18+
return user;
19+
}
20+
21+
async function settle() {
22+
await act( () => jest.runAllTimersAsync() );
23+
}
24+
25+
describe( 'MetricNoteTooltip', () => {
26+
beforeEach( () => jest.useFakeTimers() );
27+
afterEach( () => jest.useRealTimers() );
28+
29+
it.each( [
30+
[ 'hover', ( user: User ) => user.hover( screen.getByText( 'Visitors' ) ) ],
31+
[ 'keyboard focus', ( user: User ) => user.tab() ],
32+
] )( 'shows the note in a tooltip on %s', async ( _, open ) => {
33+
const user = setup( NOTE );
34+
35+
await open( user );
36+
await settle();
37+
38+
expect( screen.getByTestId( 'metric-note-tooltip' ) ).toHaveTextContent( NOTE );
39+
} );
40+
41+
it( 'gives assistive tech the note once, while the tooltip is open', async () => {
42+
const user = setup( NOTE );
43+
44+
await user.tab();
45+
await settle();
46+
47+
expect( screen.getAllByText( NOTE ) ).toHaveLength( 2 );
48+
expect( screen.getByTestId( 'metric-note-tooltip' ) ).toHaveAttribute( 'aria-hidden', 'true' );
49+
expect( screen.getByTestId( 'metric-note-tooltip' ) ).not.toHaveAttribute( 'tabindex' );
50+
} );
51+
52+
it( 'leaves a metric with no note out of the tab order', async () => {
53+
const user = setup();
54+
55+
await user.tab();
56+
57+
expect( document.body ).toHaveFocus();
58+
} );
59+
} );
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { MetricNoteTooltip } from './metric-note-tooltip';
2+
export type { MetricNoteTooltipProps } from './metric-note-tooltip';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Widget roots clip overflow, so the focus ring is drawn inset.
2+
.trigger:focus-visible {
3+
border-radius: var(--wpds-border-radius-sm);
4+
outline:
5+
var(--wpds-border-width-focus) solid
6+
var(--wpds-color-stroke-focus);
7+
outline-offset: calc(-1 * var(--wpds-border-width-focus));
8+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { Tooltip, VisuallyHidden } from '@jetpack-premium-analytics/externals';
5+
import clsx from 'clsx';
6+
/**
7+
* Internal dependencies
8+
*/
9+
import styles from './metric-note-tooltip.module.scss';
10+
import type { ReactNode } from 'react';
11+
12+
export type MetricNoteTooltipProps = {
13+
/**
14+
* Caveat about how the metric is aggregated. Without one, the children render in a plain `div`.
15+
*/
16+
note?: string;
17+
18+
className?: string;
19+
20+
children: ReactNode;
21+
};
22+
23+
/**
24+
* Shows a metric's note in a tooltip on hover and keyboard focus, and reads it once to assistive tech.
25+
*
26+
* @param {MetricNoteTooltipProps} props - The component props.
27+
* @return The rendered box.
28+
*/
29+
export function MetricNoteTooltip( { note, className, children }: MetricNoteTooltipProps ) {
30+
if ( ! note ) {
31+
return <div className={ className }>{ children }</div>;
32+
}
33+
34+
return (
35+
<Tooltip.Root>
36+
<Tooltip.Trigger
37+
render={ <div className={ clsx( styles.trigger, className ) } tabIndex={ 0 } /> }
38+
>
39+
{ children }
40+
<VisuallyHidden>{ note }</VisuallyHidden>
41+
</Tooltip.Trigger>
42+
{ /* Assistive tech reads the copy in the trigger, so the popup is hidden, and
43+
drops the `tabIndex={-1}` Base UI gives it so nothing hidden can take focus. */ }
44+
<Tooltip.Popup aria-hidden="true" tabIndex={ undefined } data-testid="metric-note-tooltip">
45+
{ note }
46+
</Tooltip.Popup>
47+
</Tooltip.Root>
48+
);
49+
}

‎projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-tile/__tests__/metric-tile-grid.test.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* External dependencies
33
*/
4-
import { render, screen } from '@testing-library/react';
4+
import { act, render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
56
import { comment, postList } from '@wordpress/icons';
67
/**
78
* Internal dependencies
@@ -22,6 +23,8 @@ function renderMetricGrid() {
2223
}
2324

2425
describe( 'MetricTileGrid', () => {
26+
afterEach( () => jest.useRealTimers() );
27+
2528
it( 'renders metric tiles as a list', () => {
2629
renderMetricGrid();
2730

@@ -73,7 +76,9 @@ describe( 'MetricTileGrid', () => {
7376
expect( screen.queryByText( /%/ ) ).not.toBeInTheDocument();
7477
} );
7578

76-
it( 'exposes a tile note as visually hidden assistive text', () => {
79+
it( 'shows a tile note in a tooltip on the tile header', async () => {
80+
jest.useFakeTimers();
81+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
7782
render(
7883
<MetricTileGrid
7984
tiles={ [
@@ -82,6 +87,11 @@ describe( 'MetricTileGrid', () => {
8287
/>
8388
);
8489

85-
expect( screen.getByText( 'Sum of daily visitors.' ) ).toBeInTheDocument();
90+
await user.hover( screen.getByText( 'Visitors' ) );
91+
await act( () => jest.runAllTimersAsync() );
92+
93+
expect( screen.getByTestId( 'metric-note-tooltip' ) ).toHaveTextContent(
94+
'Sum of daily visitors.'
95+
);
8696
} );
8797
} );

‎projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-tile/metric-tile-grid.tsx‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/**
22
* External dependencies
33
*/
4-
import { Icon, Text, VisuallyHidden } from '@jetpack-premium-analytics/externals';
4+
import { Icon, Text } from '@jetpack-premium-analytics/externals';
55
import clsx from 'clsx';
66
/**
77
* Internal dependencies
88
*/
9+
import { MetricNoteTooltip } from '../metric-note-tooltip';
910
import { MetricValue } from '../metric-value';
1011
import { MetricWithComparison } from '../metric-with-comparison';
1112
import styles from './metric-tile-grid.module.scss';
@@ -43,8 +44,7 @@ type MetricTileGridItem = {
4344
previousValue?: number | null;
4445

4546
/**
46-
* Caveat about how the value is aggregated. Shown as a hover tooltip on the
47-
* tile header and mirrored as visually hidden text for assistive technology.
47+
* Caveat about how the value is aggregated, shown in a tooltip on the tile header.
4848
*/
4949
note?: string;
5050

@@ -158,13 +158,10 @@ export function MetricTileGrid( {
158158
<div className={ styles.grid } data-layout={ layout } role="list">
159159
{ tiles.map( tile => (
160160
<div key={ tile.key } className={ clsx( styles.tile, tile.className ) } role="listitem">
161-
<div className={ styles.header } title={ tile.note }>
161+
<MetricNoteTooltip note={ tile.note } className={ styles.header }>
162162
{ tile.icon && <Icon icon={ tile.icon } size={ 20 } className={ styles.icon } /> }
163163
<Text className={ styles.label }>{ tile.label }</Text>
164-
{ /* The `title` tooltip is invisible to keyboard and screen-reader
165-
users, so the caveat is repeated as visually hidden text. */ }
166-
{ tile.note && <VisuallyHidden>{ tile.note }</VisuallyHidden> }
167-
</div>
164+
</MetricNoteTooltip>
168165
<MetricTileValue
169166
tile={ tile }
170167
dataFormat={ dataFormat }

‎projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* External dependencies
33
*/
4-
import { render, screen } from '@testing-library/react';
4+
import { act, render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
56
import { getSettings, setSettings } from '@wordpress/date';
67
/**
78
* Internal dependencies
@@ -36,7 +37,10 @@ const BASE_SETTINGS = getSettings();
3637

3738
describe( 'PostHighlightCard', () => {
3839
beforeEach( () => setSettings( EN_US_SETTINGS ) );
39-
afterEach( () => setSettings( BASE_SETTINGS ) );
40+
afterEach( () => {
41+
setSettings( BASE_SETTINGS );
42+
jest.useRealTimers();
43+
} );
4044

4145
it( 'links the title to the detail route and carries the report window', () => {
4246
render(
@@ -124,13 +128,15 @@ describe( 'PostHighlightCard', () => {
124128
expect( screen.getByText( '0' ) ).toBeInTheDocument();
125129
} );
126130

127-
// A lifetime metric shown next to a period-scoped one must say so, and the
128-
// `title` tooltip alone is invisible to assistive technology.
129-
it( 'exposes a metric note as both a tooltip and visually hidden text', () => {
131+
it( 'shows a metric note in a tooltip on the metric label', async () => {
132+
jest.useFakeTimers();
133+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
130134
render( <PostHighlightCard { ...props } /> );
131135

132-
expect( screen.getByTitle( 'All-time total.' ) ).toBeInTheDocument();
133-
expect( screen.getByText( 'All-time total.' ) ).toBeInTheDocument();
136+
await user.hover( screen.getByText( 'Likes' ) );
137+
await act( () => jest.runAllTimersAsync() );
138+
139+
expect( screen.getByTestId( 'metric-note-tooltip' ) ).toHaveTextContent( 'All-time total.' );
134140
} );
135141

136142
it( 'renders the featured image only when one is present', () => {

‎projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@
123123
min-inline-size: 0;
124124
}
125125

126+
.metricHeader {
127+
display: flex;
128+
min-inline-size: 0;
129+
}
130+
126131
.metricLabel {
127132
overflow: hidden;
128133
color: var(--wpds-color-foreground-content-neutral);

‎projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { __, sprintf } from '@wordpress/i18n';
88
/**
99
* Internal dependencies
1010
*/
11+
import { MetricNoteTooltip } from '../metric-note-tooltip';
1112
import { MetricValue } from '../metric-value';
1213
import { PostTitleLink } from '../post-title-link';
1314
import styles from './post-highlight-card.module.scss';
@@ -40,8 +41,7 @@ export type PostHighlightCardMetric = {
4041

4142
/**
4243
* Caveat about how the value is aggregated (e.g. an all-time total among
43-
* date-scoped neighbours). Shown as a hover tooltip and mirrored as
44-
* visually hidden text for assistive technology.
44+
* date-scoped neighbours), shown in a tooltip on the label.
4545
*/
4646
note?: string;
4747
};
@@ -127,13 +127,12 @@ function PostHighlightMetric( {
127127
dataFormat: DataFormat;
128128
} ) {
129129
return (
130-
<div className={ styles.metric } title={ metric.note }>
131-
<Text className={ styles.metricLabel } variant="body-md">
132-
{ metric.label }
133-
</Text>
134-
{ /* The `title` tooltip is invisible to keyboard and screen-reader users,
135-
so the caveat is repeated as visually hidden text. */ }
136-
{ metric.note && <VisuallyHidden>{ metric.note }</VisuallyHidden> }
130+
<div className={ styles.metric }>
131+
<MetricNoteTooltip note={ metric.note } className={ styles.metricHeader }>
132+
<Text className={ styles.metricLabel } variant="body-md">
133+
{ metric.label }
134+
</Text>
135+
</MetricNoteTooltip>
137136
{ metric.value === undefined ? (
138137
<Text className={ styles.metricValue } variant="body-md">
139138
{ /* Spelled out below, since a screen reader may skip the dash. */ }

0 commit comments

Comments
 (0)