Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React from 'react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { ExtensionShowcase } from './ExtensionShowcase';
import { defaultExtensionShowcaseFeatures } from './defaultFeatures';

const featureById = (id: string) =>
defaultExtensionShowcaseFeatures.find((feature) => feature.id === id)!;

const scrollIntoView = jest.fn();

beforeEach(() => {
scrollIntoView.mockClear();
Element.prototype.scrollIntoView = scrollIntoView;
});

describe('ExtensionShowcase', () => {
it('opens on the new tab feed', () => {
render(<ExtensionShowcase />);

expect(screen.getByRole('tab', { name: 'New tab feed' })).toHaveAttribute(
'aria-selected',
'true',
);
expect(screen.getByText(featureById('newtab').description)).toBeVisible();
expect(screen.getByRole('tabpanel')).toHaveAccessibleName('New tab feed');
expect(
screen.getByLabelText(featureById('newtab').media.alt),
).toBeVisible();
});

it('owns the tabs from the tablist and lets the keyboard reach the panel', () => {
render(<ExtensionShowcase />);
const tablist = screen.getByRole('tablist', { name: 'Extension features' });

expect(within(tablist).getAllByRole('tab')).toHaveLength(
defaultExtensionShowcaseFeatures.length,
);
expect(tablist.children).toHaveLength(
defaultExtensionShowcaseFeatures.length,
);
expect(screen.getByRole('tabpanel')).toHaveAttribute('tabindex', '0');
});

it('renders nothing without features', () => {
const { container } = render(<ExtensionShowcase features={[]} />);

expect(container).toBeEmptyDOMElement();
});

it('centers the tab on first paint and again once fonts are ready', async () => {
let fontsReady: () => void;
Object.defineProperty(document, 'fonts', {
configurable: true,
value: {
ready: new Promise<void>((resolve) => {
fontsReady = resolve;
}),
},
});
render(<ExtensionShowcase />);

expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenLastCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);

await act(async () => fontsReady());

expect(scrollIntoView).toHaveBeenCalledTimes(2);
expect(scrollIntoView).toHaveBeenLastCalledWith(
expect.objectContaining({ behavior: 'auto' }),
);

fireEvent.click(screen.getByRole('tab', { name: 'Companion' }));

expect(scrollIntoView).toHaveBeenLastCalledWith(
expect.objectContaining({ behavior: 'smooth' }),
);
});

it('glides without animation when the user prefers reduced motion', () => {
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
render(<ExtensionShowcase />);

fireEvent.click(screen.getByRole('tab', { name: 'Companion' }));

expect(scrollIntoView).toHaveBeenLastCalledWith(
expect.objectContaining({ behavior: 'auto' }),
);
});

it('swaps the caption and illustration on selection', () => {
const onFeatureChange = jest.fn();
render(<ExtensionShowcase onFeatureChange={onFeatureChange} />);

fireEvent.click(screen.getByRole('tab', { name: 'Companion' }));

const companion = featureById('companion');
expect(onFeatureChange).toHaveBeenCalledWith('companion');
expect(screen.getByText(companion.description)).toBeVisible();
expect(screen.getByAltText(companion.media.alt)).toBeVisible();
expect(
screen.queryByText(featureById('newtab').description),
).not.toBeInTheDocument();
});

it('moves the selection with the arrow, Home and End keys', () => {
const focus = jest.spyOn(HTMLElement.prototype, 'focus');
render(<ExtensionShowcase />);
const selected = () => screen.getByRole('tab', { selected: true });

fireEvent.keyDown(selected(), { key: 'ArrowRight' });
expect(selected()).toHaveTextContent('Companion');
expect(selected()).toHaveFocus();
expect(focus).toHaveBeenLastCalledWith({ preventScroll: true });

fireEvent.keyDown(selected(), { key: 'ArrowLeft' });
fireEvent.keyDown(selected(), { key: 'ArrowLeft' });
expect(selected()).toHaveTextContent('Shortcuts');

fireEvent.keyDown(selected(), { key: 'End' });
expect(selected()).toHaveTextContent('Focus mode');

fireEvent.keyDown(selected(), { key: 'ArrowRight' });
expect(selected()).toHaveTextContent('Read it here');

fireEvent.keyDown(selected(), { key: 'Home' });
expect(selected()).toHaveTextContent('Read it here');
});

it('warms up every illustration on mount', () => {
const sources: string[] = [];
jest.spyOn(window, 'Image').mockImplementation(
() =>
({
set src(value: string) {
sources.push(value);
},
} as HTMLImageElement),
);
render(<ExtensionShowcase />);

expect(sources).toEqual(
defaultExtensionShowcaseFeatures
.filter((feature) => feature.media.type === 'image')
.map((feature) => feature.media.src),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import type { KeyboardEvent, ReactElement } from 'react';
import React, {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '../../typography/Typography';
import { FunnelTargetId } from '../../../features/onboarding/types/funnelEvents';
import { ExtensionShowcaseStage } from './ExtensionShowcaseStage';
import {
defaultExtensionShowcaseFeatureId,
defaultExtensionShowcaseFeatures,
} from './defaultFeatures';
import type { ExtensionShowcaseFeature } from './types';

export interface ExtensionShowcaseProps {
features?: ExtensionShowcaseFeature[];
defaultFeatureId?: string;
onFeatureChange?: (featureId: string) => void;
className?: string;
/** Applied to the stage wrapper, e.g. to cap its width. */
stageClassName?: string;
}

interface ShowcaseTabProps {
feature: ExtensionShowcaseFeature;
id: string;
panelId: string;
isActive: boolean;
onClick: () => void;
onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
}

function ShowcaseTab({
feature,
id,
panelId,
isActive,
onClick,
onKeyDown,
}: ShowcaseTabProps): ReactElement {
return (
<button
type="button"
role="tab"
id={id}
aria-selected={isActive}
aria-controls={panelId}
tabIndex={isActive ? 0 : -1}
onClick={onClick}
onKeyDown={onKeyDown}
data-funnel-track={FunnelTargetId.ExtensionFeature}
className={classNames(
'shrink-0 whitespace-nowrap rounded-12 border px-4 py-2 transition-all',
isActive
? 'scale-105 shadow-2'
: 'border-border-subtlest-tertiary text-text-tertiary hover:border-border-subtlest-secondary hover:text-text-primary',
)}
style={
isActive
? {
color: feature.accent,
borderColor: `color-mix(in srgb, ${feature.accent} 42%, transparent)`,
backgroundColor: `color-mix(in srgb, ${feature.accent} 14%, transparent)`,
}
: undefined
}
>
<Typography tag={TypographyTag.Span} type={TypographyType.Callout} bold>
{feature.label}
</Typography>
</button>
);
}

const nextTabIndex = (key: string, current: number, count: number): number => {
switch (key) {
case 'ArrowLeft':
return (current - 1 + count) % count;
case 'ArrowRight':
return (current + 1) % count;
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return -1;
}
};

const prefersReducedMotion = (): boolean =>
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;

export function ExtensionShowcase({
features = defaultExtensionShowcaseFeatures,
defaultFeatureId = defaultExtensionShowcaseFeatureId,
onFeatureChange,
className,
stageClassName,
}: ExtensionShowcaseProps): ReactElement | null {
const [activeId, setActiveId] = useState(defaultFeatureId);
const activeFeature =
features.find((feature) => feature.id === activeId) ?? features[0];
const activeFeatureId = activeFeature?.id;
const baseId = useId();
const scrollerRef = useRef<HTMLDivElement>(null);
const hasCentered = useRef(false);

// The selected tab sits in the middle and the rest fan out to both sides,
// like the product tour on the homepage. The first paint centers instantly
// and again once web fonts settle the tab widths; later selections glide.
useLayoutEffect(() => {
const centerActiveTab = (behavior: ScrollBehavior): void => {
scrollerRef.current
?.querySelector<HTMLElement>('[aria-selected="true"]')
?.scrollIntoView({ behavior, inline: 'center', block: 'nearest' });
};

if (hasCentered.current) {
centerActiveTab(prefersReducedMotion() ? 'auto' : 'smooth');
return undefined;
}

centerActiveTab('auto');
hasCentered.current = true;
let isCurrent = true;
document.fonts?.ready.then(() => {
if (isCurrent) {
centerActiveTab('auto');
}
});

return () => {
isCurrent = false;
};
}, [activeFeatureId]);

// Warm the illustrations up so the first click on a tab shows its stage
// instead of the glows alone while the image lands.
const illustrationSources = features
.flatMap(({ media }) => (media.type === 'image' ? [media.src] : []))
.join(' ');
useEffect(() => {
illustrationSources.split(' ').forEach((src) => {
if (src) {
new Image().src = src;
}
});
}, [illustrationSources]);

if (!activeFeature) {
return null;
}

const tabId = (featureId: string): string => `${baseId}-tab-${featureId}`;
const panelId = `${baseId}-panel`;

const selectFeature = (featureId: string): void => {
setActiveId(featureId);
onFeatureChange?.(featureId);
};

const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
const nextIndex = nextTabIndex(
event.key,
features.indexOf(activeFeature),
features.length,
);
if (nextIndex < 0) {
return;
}

event.preventDefault();
const next = features[nextIndex];
selectFeature(next.id);
document.getElementById(tabId(next.id))?.focus({ preventScroll: true });
};

return (
<section
className={classNames('flex w-full flex-col items-center', className)}
>
<div aria-live="polite" className="w-full">
<Typography
key={activeFeature.id}
tag={TypographyTag.P}
type={TypographyType.Body}
color={TypographyColor.Secondary}
className="animate-showcase-caption-in mx-auto min-h-[3.25rem] max-w-xl text-balance text-center"
>
{activeFeature.description}
</Typography>
</div>
<div
ref={scrollerRef}
className="no-scrollbar showcase-carousel-mask mt-6 w-full overflow-x-auto"
>
<div
role="tablist"
aria-label="Extension features"
className="flex w-max items-center gap-2.5 px-[50%] py-3"
>
{features.map((feature) => (
<ShowcaseTab
key={feature.id}
feature={feature}
id={tabId(feature.id)}
panelId={panelId}
isActive={feature.id === activeFeature.id}
onClick={() => selectFeature(feature.id)}
onKeyDown={onKeyDown}
/>
))}
</div>
</div>
<div
id={panelId}
role="tabpanel"
Comment thread
tsahimatsliah marked this conversation as resolved.
aria-labelledby={tabId(activeFeature.id)}
tabIndex={0}
className={classNames('mt-4 w-full', stageClassName)}
>
<ExtensionShowcaseStage feature={activeFeature} />
</div>
</section>
);
}
Loading
Loading