-
Notifications
You must be signed in to change notification settings - Fork 303
feat(onboarding): extension showcase experiment on the extension step #6679
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tsahimatsliah
wants to merge
7
commits into
main
Choose a base branch
from
claude/extension-install-modal-review-a96e56
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
ac78796
feat(onboarding): extension showcase experiment on the extension step
tsahimatsliah 6deccb0
fix(onboarding): hold the extension step until the showcase flag reso…
tsahimatsliah 47b7dba
Merge branch 'main' into claude/extension-install-modal-review-a96e56
tsahimatsliah c26c682
Merge branch 'main' into claude/extension-install-modal-review-a96e56
tsahimatsliah 2157d95
fix(onboarding): address review on the extension showcase
tsahimatsliah 79ed193
Merge branch 'main' into claude/extension-install-modal-review-a96e56
tsahimatsliah aacbd16
fix(onboarding): finish the showcase tab pattern
tsahimatsliah File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
149 changes: 149 additions & 0 deletions
149
packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| ); | ||
| }); | ||
| }); |
235 changes: 235 additions & 0 deletions
235
packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| aria-labelledby={tabId(activeFeature.id)} | ||
| tabIndex={0} | ||
| className={classNames('mt-4 w-full', stageClassName)} | ||
| > | ||
| <ExtensionShowcaseStage feature={activeFeature} /> | ||
| </div> | ||
| </section> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.