Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/web/e2e/flows/rankings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ test.describe('rankings', () => {

// The archived view, kept across a reload
await page.getByRole('button', { name: 'Remove filter Intent: Commercial' }).click();
await page.getByRole('radio', { name: 'Archived' }).click();
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Show archived keywords' }).click();
await expect(page).toHaveURL(/[?&]archived=true/);
await page.reload();
await expect(page.getByText('3 keywords', { exact: true }).first()).toBeVisible();
Expand Down Expand Up @@ -746,7 +747,8 @@ test.describe('rankings', () => {
const grid = page.getByRole('grid', { name: 'Tracked keywords' });
await expect(page.getByText('58 keywords', { exact: true }).first()).toBeVisible();

await page.getByRole('button', { name: 'Import', exact: true }).click();
await page.getByRole('button', { name: 'More ways to add keywords' }).click();
await page.getByRole('menuitem', { name: 'Import from a file' }).click();
await expect(page).toHaveURL(/\/seo\/keywords\/import(\?.*)?$/);
const dialog = page.getByRole('dialog', { name: 'Import keywords' });
await expect(dialog).toBeVisible();
Expand Down Expand Up @@ -783,7 +785,7 @@ test.describe('rankings', () => {
// The dialog is gone, the table stays, and the header follows the write until the report is ready
await expect(page).toHaveURL(/\/seo\/keywords(\?.*)?$/);
await expect(dialog).toBeHidden();
await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeFocused();
await expect(page.getByRole('button', { name: 'More ways to add keywords' })).toBeFocused();
await expect(page.getByRole('progressbar', { name: 'Importing keywords' })).toBeVisible();
await expect(page.getByText('Import finished: 3 added, 1 skipped')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('progressbar', { name: 'Importing keywords' })).toBeHidden();
Expand Down
39 changes: 37 additions & 2 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1930,8 +1930,40 @@
"downloadReport": "Download report",
"reportName": "keyword-import-{date}.csv"
},
"export": {
"open": "Export",
"xlsx": "Excel workbook with summary",
"csv": "CSV, table only",
"failed": {
"title": "The export did not finish",
"description": "The keywords could not be read. Try again."
},
"sheet": "Keywords",
"country": "Country",
"devices": "Devices",
"filters": "Filters",
"filtersNone": "None",
"filtersCount": "{count, plural, one {# filter} other {# filters}}",
"search": "Search",
"archived": "Archived keywords only",
"exported": "Exported",
"columns": {
"keyword": "Keyword",
"url": "Ranking URL",
"volume": "Search volume",
"difficulty": "KD",
"cpc": "CPC (USD)",
"intent": "Intent",
"priority": "Priority",
"groups": "Groups",
"checked": "Last checked"
}
},
"add": {
"open": "Add keywords",
"options": "Add keywords options",
"moreWays": "More ways to add keywords",
"importFile": "Import from a file",
"title": "Add keywords",
"description": "Keywords are tracked on Google. Their metrics arrive within minutes.",
"keywords": "Keywords",
Expand Down Expand Up @@ -2151,7 +2183,7 @@
},
"scope": {
"device": "Device",
"both": "Both",
"both": "Desktop and mobile",
"country": "Country: {country}",
"countrySearch": "Search countries",
"countryEmpty": "No countries match “{query}”",
Expand All @@ -2173,7 +2205,10 @@
"view": {
"label": "Show",
"active": "Active",
"archived": "Archived"
"archived": "Archived",
"showArchived": "Show archived keywords",
"archivedChip": "Archived keywords",
"backToActive": "Show active keywords"
},
"error": {
"title": "The keywords did not load",
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"react-hook-form": "^7.89.0",
"server-only": "^0.0.1",
"tailwind-merge": "^3.7.0",
"write-excel-file": "^4.1.1",
"zod": "^4.6.5"
},
"msw": {
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/app/shell/page-header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { PageHeader } from './page-header';
import { renderWithProviders } from '@/test/render';

describe('PageHeader with the title hidden', () => {
it('keeps the page’s heading for assistive technology but does not draw it', () => {
renderWithProviders(
<PageHeader
title="Keywords"
titleHidden
meta="Updated 2h ago"
toolbar={<div>toolbar</div>}
actions={<button type="button">Add</button>}
/>,
);
const heading = screen.getByRole('heading', { level: 1, name: 'Keywords' });
expect(heading).toHaveClass('sr-only');
expect(screen.getByText('toolbar')).toBeInTheDocument();
expect(screen.getByText('Updated 2h ago')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});

it('draws the title as before without it', () => {
renderWithProviders(<PageHeader title="Keywords" meta="Updated 2h ago" />);
expect(screen.getByRole('heading', { level: 1, name: 'Keywords' })).not.toHaveClass('sr-only');
});
});
46 changes: 29 additions & 17 deletions apps/web/src/components/app/shell/page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ export interface PageHeaderProps {
* beside the title instead of under it, so a table page spends one row above its table.
*/
toolbar?: ReactNode | undefined;
/** Keeps the toolbar on its own row under the title at every width, for a toolbar too wide to share the title row. */
stacked?: boolean | undefined;
/**
* The title is read by assistive technology and takes route focus but is not drawn, for a page whose title
* the top bar's breadcrumb already shows. The toolbar then takes the row, with the meta line and the
* actions at its end, so a table page spends one row above its table.
*/
titleHidden?: boolean | undefined;
/** The id the skip link and route focus use; the heading takes focus when a route changes. */
headingId?: string | undefined;
}
Expand All @@ -38,19 +42,36 @@ export function PageHeader({
actions,
tabs,
toolbar,
stacked = false,
titleHidden = false,
headingId,
}: PageHeaderProps): ReactElement {
const compact = toolbar !== undefined;
if (titleHidden) {
return (
<div data-slot="page-header" className="@container flex flex-col gap-3">
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
<h1 id={headingId} tabIndex={-1} className="sr-only outline-none">
{title}
</h1>
{toolbar === undefined ? null : <div className="order-1 min-w-0 flex-1 basis-96">{toolbar}</div>}
{meta === undefined && actions === undefined ? null : (
<div className="order-2 ml-auto flex h-control-sm items-center gap-3">
{meta === undefined ? null : <p className="type-meta whitespace-nowrap">{meta}</p>}
{actions}
</div>
)}
</div>
{tabs}
</div>
);
}
return (
<div data-slot="page-header" className="@container flex flex-col gap-3">
<div className={cn('flex flex-wrap gap-x-4 gap-y-3', compact ? 'items-center' : 'items-start')}>
<div
className={cn(
'flex min-w-0 basis-full md:basis-auto',
compact
? cn('order-1 items-baseline gap-3 md:flex-1', !stacked && '@6xl:flex-none')
: 'flex-col gap-1 md:flex-1',
compact ? 'order-1 items-baseline gap-3 md:flex-1 @6xl:flex-none' : 'flex-col gap-1 md:flex-1',
)}
>
<h1 id={headingId} tabIndex={-1} className="type-page-title outline-none">
Expand All @@ -60,19 +81,10 @@ export function PageHeader({
</div>
{toolbar === undefined ? null : (
// Narrow: its own row under the title and actions. Wide (the header's own width, so a docked inspector counts): between them
<div className={cn('order-3 min-w-0 basis-full', !stacked && '@6xl:order-2 @6xl:flex-1 @6xl:basis-80')}>
{toolbar}
</div>
<div className="order-3 min-w-0 basis-full @6xl:order-2 @6xl:flex-1 @6xl:basis-80">{toolbar}</div>
)}
{actions === undefined ? null : (
<div
className={cn(
'flex items-center gap-3 md:ml-auto',
compact && (stacked ? 'order-2' : 'order-2 @6xl:order-3'),
)}
>
{actions}
</div>
<div className={cn('flex items-center gap-3 md:ml-auto', compact && 'order-2 @6xl:order-3')}>{actions}</div>
)}
</div>
{tabs}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/ui/segmented-control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,25 @@ describe('SegmentedControl', () => {
);
expect(await axe(container)).toHaveNoViolations();
});

it('shows only icons when asked, each named by its label', () => {
renderWithProviders(
<SegmentedControl<Device>
semantics="view"
label="Device"
value="desktop"
onValueChange={vi.fn()}
iconOnly
size="sm"
items={[
{ value: 'desktop', label: 'Desktop', icon: Monitor },
{ value: 'mobile', label: 'Mobile', icon: Monitor },
]}
/>,
);
expect(screen.getByRole('radio', { name: 'Desktop' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Mobile' })).not.toBeChecked();
// The label is the name, not text on the page
expect(screen.queryByText('Desktop')).toBeNull();
});
});
63 changes: 43 additions & 20 deletions apps/web/src/components/ui/segmented-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ReactElement, Ref } from 'react';

import { Icon } from './icon';
import { useRadioFocus, type FocusHandle } from './radio-group';
import { Tooltip } from './tooltip';
import { cn } from '@/lib/utils';

export interface SegmentedControlItem<TValue extends string> {
Expand Down Expand Up @@ -35,6 +36,11 @@ export interface SegmentedControlProps<TValue extends string> extends Omit<
onValueChange: (value: TValue) => void;
/** 2 to 5 for a view option, 2 to 4 for a form value. */
items: readonly SegmentedControlItem<TValue>[];
/**
* Shows only the icons, each with its label as accessible name and tooltip (07-icons.md section 4): every
* item needs an icon. For options whose icons are understood without a word, such as the devices.
*/
iconOnly?: boolean | undefined;
/** 32 px in toolbars and panels, 36 px in page headers and forms. */
size?: 'sm' | 'md' | undefined;
/** Lets a form move focus to the chosen segment after a failed submit: `focusRef={field.ref}`. */
Expand Down Expand Up @@ -69,32 +75,49 @@ export function SegmentedControl<TValue extends string>({
onValueChange,
items,
size = 'md',
iconOnly = false,
focusRef,
className,
...props
}: SegmentedControlProps<TValue>): ReactElement {
const groupRef = useRadioFocus(focusRef);
const segments = items.map((item) => (
<RadioPrimitive.Root
key={item.value}
value={item.value}
disabled={item.disabled}
className={segmentClassName(size)}
>
{item.icon ? <Icon icon={item.icon} size={size === 'sm' ? 'sm' : 'default'} /> : null}
<span className="grid">
<span className="col-start-1 row-start-1">{item.label}</span>
{/* The heavier copy is never seen or read: it only holds the width the label has when chosen */}
<span aria-hidden="true" className="invisible col-start-1 row-start-1 type-body-strong">
{item.label}
const segments = items.map((item) => {
if (iconOnly && item.icon) {
return (
<Tooltip key={item.value} content={item.label} purpose="label">
<RadioPrimitive.Root
value={item.value}
disabled={item.disabled}
aria-label={item.label}
className={cn(segmentClassName(size), size === 'sm' ? 'w-control-sm px-0' : 'w-control px-0')}
>
<Icon icon={item.icon} size={size === 'sm' ? 'sm' : 'default'} />
</RadioPrimitive.Root>
</Tooltip>
);
}
return (
<RadioPrimitive.Root
key={item.value}
value={item.value}
disabled={item.disabled}
className={segmentClassName(size)}
>
{item.icon ? <Icon icon={item.icon} size={size === 'sm' ? 'sm' : 'default'} /> : null}
<span className="grid">
<span className="col-start-1 row-start-1">{item.label}</span>
{/* The heavier copy is never seen or read: it only holds the width the label has when chosen */}
<span aria-hidden="true" className="invisible col-start-1 row-start-1 type-body-strong">
{item.label}
</span>
</span>
</span>
{/* A flex container ignores the space for layout; it keeps label and count apart in the name */}
{item.count === undefined ? null : ' '}
{/* The count keeps the regular weight, so its width does not change with the choice */}
{item.count === undefined ? null : <span className="num type-body">{item.count}</span>}
</RadioPrimitive.Root>
));
{/* A flex container ignores the space for layout; it keeps label and count apart in the name */}
{item.count === undefined ? null : ' '}
{/* The count keeps the regular weight, so its width does not change with the choice */}
{item.count === undefined ? null : <span className="num type-body">{item.count}</span>}
</RadioPrimitive.Root>
);
});
// A view option is named without showing the name; a form value gets a visible legend below
const viewProps = semantics === 'view' ? { 'aria-label': label, 'data-slot': 'segmented-control' } : {};
const group = (
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/ui/split-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { screen } from '@testing-library/react';
import { Plus, Upload } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';

import { SplitButton } from './split-button';
import { axe } from '@/test/axe';
import { renderWithProviders } from '@/test/render';

function setup(onClick = vi.fn(), onImport = vi.fn()): ReturnType<typeof renderWithProviders> {
return renderWithProviders(
<SplitButton
variant="primary"
size="sm"
icon={Plus}
groupLabel="Add keywords options"
menuLabel="More ways to add keywords"
items={[{ id: 'import', label: 'Import from a file', icon: Upload, onSelect: onImport }]}
onClick={onClick}
>
Add keywords
</SplitButton>,
);
}

describe('SplitButton', () => {
it('is a named group of the default action and the menu that sits beside it', () => {
setup();
const group = screen.getByRole('group', { name: 'Add keywords options' });
expect(group).toContainElement(screen.getByRole('button', { name: 'Add keywords' }));
expect(group).toContainElement(screen.getByRole('button', { name: 'More ways to add keywords' }));
});

it('runs the default action from its own half, and the alternatives from the menu', async () => {
const onClick = vi.fn();
const onImport = vi.fn();
const { user } = setup(onClick, onImport);
await user.click(screen.getByRole('button', { name: 'Add keywords' }));
expect(onClick).toHaveBeenCalledOnce();
expect(onImport).not.toHaveBeenCalled();

await user.click(screen.getByRole('button', { name: 'More ways to add keywords' }));
await user.click(await screen.findByRole('menuitem', { name: 'Import from a file' }));
expect(onImport).toHaveBeenCalledOnce();
expect(onClick).toHaveBeenCalledOnce();
});

it('has no accessibility violations', async () => {
const { container } = setup();
expect(await axe(container)).toHaveNoViolations();
});
});
Loading
Loading