From 5c6d5f995fe769ce6bb8315bc00ee332c5990624 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 12:22:09 -0400 Subject: [PATCH 1/5] feat(mosaic): add settings search to UserProfile and OrganizationProfile --- .changeset/mosaic-profile-search.md | 2 + .../mosaic/src/components/profile/index.ts | 3 + .../profile/profile-search-index.tsx | 284 +++++++ .../profile/profile-search.styles.ts | 77 ++ .../profile/profile-search.test.tsx | 717 ++++++++++++++++++ .../src/components/profile/profile-search.tsx | 194 +++++ .../src/components/profile/profile.context.ts | 3 + .../components/profile/profile.messages.ts | 5 + .../src/components/profile/profile.styles.ts | 4 + .../mosaic/src/components/profile/profile.tsx | 61 +- .../mosaic/src/components/section/section.tsx | 20 +- .../organization-profile.feature.test.tsx | 30 + ...ganization-profile-danger-section.view.tsx | 2 +- ...anization-profile-profile-section.view.tsx | 2 +- .../organization-profile.tsx | 3 + .../organization-profile.types.ts | 2 + .../organization-profile.view.tsx | 8 +- .../profile-pages/profile-pages.view.tsx | 11 +- .../__tests__/user-profile.feature.test.tsx | 31 + .../__tests__/user-profile.view.test.tsx | 25 + ...er-profile-active-devices-section.view.tsx | 2 +- ...r-profile-billing-history-section.view.tsx | 2 +- ...rofile-connected-accounts-section.view.tsx | 2 +- .../user-profile-danger-section.view.tsx | 2 +- .../user-profile-email-section.view.tsx | 2 +- ...ofile-enterprise-accounts-section.view.tsx | 2 +- .../user-profile-passkeys-section.view.tsx | 2 +- .../user-profile-password-section.view.tsx | 2 +- ...r-profile-payment-methods-section.view.tsx | 2 +- .../user-profile-phone-section.view.tsx | 2 +- .../user-profile-profile-section.view.tsx | 2 +- .../user-profile-security-list.tsx | 2 +- ...user-profile-subscription-section.view.tsx | 2 +- ...user-profile-web3-wallets-section.view.tsx | 2 +- .../features/user-profile/user-profile.tsx | 3 + .../user-profile/user-profile.types.ts | 2 + .../user-profile/user-profile.view.tsx | 8 +- .../live/user-profile/user-profile/page.tsx | 43 +- 38 files changed, 1526 insertions(+), 42 deletions(-) create mode 100644 .changeset/mosaic-profile-search.md create mode 100644 packages/mosaic/src/components/profile/profile-search-index.tsx create mode 100644 packages/mosaic/src/components/profile/profile-search.styles.ts create mode 100644 packages/mosaic/src/components/profile/profile-search.test.tsx create mode 100644 packages/mosaic/src/components/profile/profile-search.tsx diff --git a/.changeset/mosaic-profile-search.md b/.changeset/mosaic-profile-search.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-profile-search.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/profile/index.ts b/packages/mosaic/src/components/profile/index.ts index 9f9d3b6f073..a06a025562b 100644 --- a/packages/mosaic/src/components/profile/index.ts +++ b/packages/mosaic/src/components/profile/index.ts @@ -9,3 +9,6 @@ export type { ProfileRootProps, ProfileTitleProps, } from './profile'; +export type { ProfileSearchPage } from './profile-search'; +export type { SearchTargetOptions, SearchTargetProps } from './profile-search-index'; +export { SearchTarget, useIsSearchIndexing } from './profile-search-index'; diff --git a/packages/mosaic/src/components/profile/profile-search-index.tsx b/packages/mosaic/src/components/profile/profile-search-index.tsx new file mode 100644 index 00000000000..a70dcad1954 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile-search-index.tsx @@ -0,0 +1,284 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; +import { createPortal } from 'react-dom'; + +import { useRender } from '../../primitives/utils'; +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { ProfileContext } from './profile.context'; +import { styles } from './profile-search.styles'; + +/** What the profile's search finds a target by. */ +export interface SearchTargetOptions { + /** Names the target in the search results. */ + label: string; + /** Other words that find the target. */ + keywords?: readonly string[]; + /** Tells apart targets on one page that share a `label`. Defaults to the `label`. */ + id?: string; +} + +export interface SearchTargetProps extends MosaicComponentProps<'div'>, SearchTargetOptions {} + +/** + * A part of a profile page that the profile's search can find, scroll to, and focus. Pass your own + * element as `render` to make it the target, with no wrapper of its own. While the search builds + * its index, the page renders a second, hidden time, and a target renders none of its children. + * + * ```tsx + * }> + *

Seats

+ *
+ * ``` + */ +export const SearchTarget = React.forwardRef(function SearchTarget( + { label, keywords, id, render, xstyle, ...rest }, + ref, +) { + const target = useSearchTarget({ label, keywords, id }); + const element = useRender({ + defaultTagName: 'div', + render, + ref: [ref, target.ref], + props: target.props(stylex.props(reset.base, xstyle), rest), + }); + return target.marker ?? element; +}); + +/** + * Whether this render is the hidden copy the profile's search builds its index from. Skip work + * there that only the visible page needs, such as fetching data a target does not depend on. + */ +export function useIsSearchIndexing(): boolean { + return React.useContext(SearchIndexingContext); +} + +export function useSearchTarget(options: SearchTargetOptions | undefined) { + const indexing = useIsSearchIndexing(); + const page = React.useContext(SearchPageContext); + const profile = React.useContext(ProfileContext); + const search = profile?.search; + const key = options && page !== null ? targetKey(page, options.id ?? options.label) : null; + const registerTarget = search?.registerTarget; + const ref = React.useCallback( + (element: HTMLElement | null) => { + if (key !== null) { + registerTarget?.(key, element); + } + }, + [key, registerTarget], + ); + const highlighted = key !== null && search?.highlightedTarget === key; + const marker = + options && indexing ? ( +
+ ) : null; + + function props>( + styleProps: ReturnType, + rest: Rest & { onAnimationEnd?: React.AnimationEventHandler }, + ) { + if (key === null) { + return mergeStyleProps(styleProps, rest); + } + const { onAnimationEnd, ...others } = rest; + return mergeStyleProps( + themeProps('profile-search-target'), + stylex.props(styles.target, highlighted && styles.targetHighlighted), + styleProps, + { + tabIndex: -1, + 'data-highlighted': highlighted ? '' : undefined, + onAnimationEnd: (event: React.AnimationEvent) => { + onAnimationEnd?.(event); + if (event.target === event.currentTarget) { + search?.clearHighlight(); + } + }, + }, + others, + ); + } + + return { marker, ref, props }; +} + +export function SearchIndexPage({ value, children }: { value: string; children: React.ReactNode }) { + const profile = React.useContext(ProfileContext); + const container = profile?.search.indexContainer; + if (!container) { + return null; + } + return createPortal( + + +
+ {children} +
+
+
, + container, + ); +} + +class SearchIndexBoundary extends React.Component<{ children: React.ReactNode }, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + return this.state.failed ? null : this.props.children; + } +} + +export const SearchPageContext = React.createContext(null); + +const SearchIndexingContext = React.createContext(false); + +export interface IndexedTarget { + page: string; + target: string; + label: string; + keywords: readonly string[]; +} + +const emptyIndex: readonly IndexedTarget[] = []; + +export function useSearchIndex(container: HTMLElement | null): readonly IndexedTarget[] { + const store = React.useMemo(() => (container ? indexStore(container) : null), [container]); + return React.useSyncExternalStore(store?.subscribe ?? noSubscription, store?.read ?? readEmptyIndex, readEmptyIndex); +} + +function indexStore(container: HTMLElement) { + let snapshot = readIndex(container); + return { + subscribe: (onChange: () => void) => { + snapshot = readIndex(container); + const observer = new MutationObserver(() => { + snapshot = readIndex(container); + onChange(); + }); + observer.observe(container, { + subtree: true, + childList: true, + attributeFilter: ['data-search-target', 'data-label', 'data-keywords'], + }); + return () => observer.disconnect(); + }, + read: () => snapshot, + }; +} + +function readIndex(container: HTMLElement): readonly IndexedTarget[] { + return Array.from(container.querySelectorAll('[data-search-target]')).flatMap(marker => { + const page = marker.closest('[data-search-page]')?.dataset.searchPage; + const { searchTarget: target, label, keywords } = marker.dataset; + if (page === undefined || target === undefined || label === undefined) { + return []; + } + return [{ page, target, label, keywords: parseKeywords(keywords) }]; + }); +} + +function parseKeywords(value: string | undefined): readonly string[] { + const parsed: unknown = value ? JSON.parse(value) : []; + return Array.isArray(parsed) ? parsed.filter((keyword): keyword is string => typeof keyword === 'string') : []; +} + +function noSubscription() { + return () => {}; +} + +function readEmptyIndex() { + return emptyIndex; +} + +export function targetKey(page: string, target: string): string { + return JSON.stringify([page, target]); +} + +interface Reveal { + page: string; + target: string | null; +} + +export function useProfileSearch({ + value, + selectPage, + pageTitleRef, +}: { + value: string; + selectPage: (value: string) => void; + pageTitleRef: React.RefObject; +}) { + const [query, setQuery] = React.useState(''); + const [focused, setFocused] = React.useState(false); + const [container] = React.useState(() => (typeof document === 'undefined' ? null : document.createElement('div'))); + const indexContainer = focused || query.trim() !== '' ? container : null; + const targetsRef = React.useRef(new Map()); + const [reveal, setReveal] = React.useState(null); + const [highlightedTarget, setHighlightedTarget] = React.useState(null); + + const registerTarget = React.useCallback((key: string, element: HTMLElement | null) => { + if (element) { + targetsRef.current.set(key, element); + } else { + targetsRef.current.delete(key); + } + }, []); + + const revealResult = React.useCallback( + (page: string, target: string | null) => { + setQuery(''); + setHighlightedTarget(null); + setReveal({ page, target }); + selectPage(page); + }, + [selectPage], + ); + + const clearHighlight = React.useCallback(() => setHighlightedTarget(null), []); + + React.useEffect(() => { + if (!reveal || reveal.page !== value) { + return; + } + setReveal(null); + const key = reveal.target === null ? null : targetKey(reveal.page, reveal.target); + const element = key === null ? undefined : targetsRef.current.get(key); + if (key === null || !element) { + pageTitleRef.current?.focus(); + return; + } + element.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' }); + element.focus({ preventScroll: true }); + setHighlightedTarget(key); + }, [reveal, value, pageTitleRef]); + + return React.useMemo( + () => ({ + query, + setQuery, + setFocused, + indexContainer, + registerTarget, + revealResult, + highlightedTarget, + clearHighlight, + }), + [query, indexContainer, registerTarget, revealResult, highlightedTarget, clearHighlight], + ); +} + +export type ProfileSearchState = ReturnType; + +function prefersReducedMotion() { + return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} diff --git a/packages/mosaic/src/components/profile/profile-search.styles.ts b/packages/mosaic/src/components/profile/profile-search.styles.ts new file mode 100644 index 00000000000..cbacb50d2c2 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile-search.styles.ts @@ -0,0 +1,77 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +const fade = stylex.keyframes({ + from: { opacity: 1 }, + to: { opacity: 0 }, +}); + +export const styles = stylex.create({ + search: { + flexShrink: 0, + marginBlockEnd: space['3'], + width: '100%', + }, + + results: { + padding: 0, + gap: space['3'], + flexGrow: 1, + maxHeight: 'none', + minHeight: 0, + }, + + group: { + gap: space['0.5'], + display: 'flex', + flexDirection: 'column', + }, + + groupLabel: { + gap: space['1.5'], + paddingBlock: space['1'], + paddingInline: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-foreground-secondary'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + + groupIcon: { + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + }, + + match: { + backgroundColor: 'transparent', + color: 'inherit', + fontWeight: fontWeightVars['--cl-font-semibold'], + }, + + target: { + outline: 'none', + isolation: 'isolate', + position: 'relative', + scrollMarginBlockStart: space['12'], + }, + + targetHighlighted: { + '::after': { + inset: `calc(-1 * ${space['3']})`, + borderRadius: `calc(${radiusVars['--cl-radius-xl']} + ${space['3']})`, + animationDuration: '1.5s', + animationFillMode: 'forwards', + animationName: fade, + animationTimingFunction: 'linear', + backgroundColor: colorVars['--cl-color-neutral-alpha-100'], + content: '""', + pointerEvents: 'none', + position: 'absolute', + zIndex: -1, + }, + }, +}); diff --git a/packages/mosaic/src/components/profile/profile-search.test.tsx b/packages/mosaic/src/components/profile/profile-search.test.tsx new file mode 100644 index 00000000000..2a8976bd6f8 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile-search.test.tsx @@ -0,0 +1,717 @@ +import { act, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../mosaic-provider'; +import { Dialog } from '../dialog'; +import { Section } from '../section'; +import type { ProfileNavProps } from './profile'; +import { Profile } from './profile'; +import { SearchTarget, useIsSearchIndexing } from './profile-search-index'; + +const pageTitles: Record = { + account: 'Account', + security: 'Security', + billing: 'Billing', + usage: 'Usage', +}; + +const searchPages: NonNullable = [ + { value: 'account', label: 'Account' }, + { value: 'security', label: 'Security' }, + { value: 'billing', label: 'Billing', keywords: ['invoices', 'plan'] }, + { value: 'usage', label: 'Usage', keywords: ['plan'] }, +]; + +function Titled({ children, keywords, id }: { children: string; keywords?: readonly string[]; id?: string }) { + return ( + + + + + {children} + + + + + ); +} + +const indexedRenders = vi.fn(); +const indexClosed = vi.fn(); +const sectionBodyRenders = vi.fn(); + +function UsagePage() { + const indexing = useIsSearchIndexing(); + indexedRenders(indexing); + React.useEffect(() => (indexing ? indexClosed : undefined), [indexing]); + return ( + <> + } + > +

Rate limits

+
+ } + > +

Seats included

+
+ } + > +

Extra seats

+
+ + ); +} + +function SectionBody() { + sectionBodyRenders(useIsSearchIndexing()); + return

Devices list

; +} + +function IndexOnly() { + return useIsSearchIndexing() ? : null; +} + +function Surface({ + initialPage = 'account', + showPhone = true, + searchable = true, +}: { + initialPage?: string; + showPhone?: boolean; + searchable?: boolean; +}) { + const [page, setPage] = React.useState(initialPage); + return ( + + User profile + + Account + Security + Billing + Usage + + + + Email addresses + {showPhone ? Phone numbers : null} + + + Password + + + + + + Billing page + + + + + + ); +} + +function renderSurface(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +function renderSecurityPage(children: React.ReactNode) { + return render( + + + User profile + + Security + + + {children} + + + , + ); +} + +function targetOf(title: string): HTMLElement { + const target = screen.getByRole('heading', { name: title }).closest('.cl-profile-search-target'); + if (!target) { + throw new Error(`No search target holds "${title}"`); + } + return target; +} + +function resultsOf(group: HTMLElement) { + return within(group) + .getAllByRole('option') + .map(option => option.textContent); +} + +const scrollIntoView = vi.fn(); + +function scrolledTargets() { + return scrollIntoView.mock.contexts.filter( + element => element instanceof HTMLElement && element.classList.contains('cl-profile-search-target'), + ); +} + +beforeEach(() => { + scrollIntoView.mockClear(); + indexedRenders.mockClear(); + indexClosed.mockClear(); + sectionBodyRenders.mockClear(); + Element.prototype.scrollIntoView = scrollIntoView; +}); + +afterEach(() => { + Reflect.deleteProperty(Element.prototype, 'scrollIntoView'); +}); + +describe('Profile search', () => { + it('shows the navigation until there is something to search for', async () => { + const user = userEvent.setup(); + renderSurface(); + + const search = screen.getByRole('combobox', { name: 'Search' }); + expect(screen.getByRole('tablist')).toBeVisible(); + + await user.type(search, ' '); + + expect(screen.getByRole('tablist')).toBeVisible(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('finds the sections a page renders, though the page is not open, grouped by page', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'PASS'); + + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + const listbox = screen.getByRole('listbox'); + const groups = within(listbox).getAllByRole('group'); + expect(groups).toHaveLength(1); + expect(groups[0]).toHaveAccessibleName('Security'); + expect(resultsOf(groups[0])).toEqual(['Password']); + expect(within(listbox).getByText('Pass', { selector: 'mark' })).toBeInTheDocument(); + expect(screen.queryByText('No results')).not.toBeInTheDocument(); + }); + + it('finds the targets of a page of your own', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'seats'); + + expect(resultsOf(screen.getByRole('group', { name: 'Usage' }))).toEqual(['Seats', 'Seats']); + }); + + it('finds a target inside a section that is not a target itself', async () => { + const user = userEvent.setup(); + renderSecurityPage( + + + , + ); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'recovery'); + + expect(screen.getByRole('option', { name: 'Recovery codes' })).toBeInTheDocument(); + }); + + it('finds a section by its keywords', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'throttle'); + + expect(resultsOf(screen.getByRole('group', { name: 'Usage' }))).toEqual(['Rate limits']); + }); + + it('lists a page whose name or keywords match', async () => { + const user = userEvent.setup(); + renderSurface(); + const search = screen.getByRole('combobox', { name: 'Search' }); + + await user.type(search, 'bill'); + expect(resultsOf(screen.getByRole('group', { name: 'Billing' }))).toEqual(['Billing']); + + await user.clear(search); + await user.type(search, 'invoices'); + expect(resultsOf(screen.getByRole('group', { name: 'Billing' }))).toEqual(['Billing']); + }); + + it('opens the chosen page when more than one page matches', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'plan'); + await user.click(screen.getByRole('option', { name: 'Billing' })); + + expect(screen.getByRole('tab', { name: 'Billing' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('does not find a section its page does not render', async () => { + const user = userEvent.setup(); + renderSurface({ showPhone: false }); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'phone'); + + expect(screen.getByText('No results')).toBeInTheDocument(); + }); + + it('finds a section that appears while the search is open', async () => { + const user = userEvent.setup(); + const reveals: (() => void)[] = []; + function Later() { + const [shown, setShown] = React.useState(false); + reveals.push(() => setShown(true)); + return shown ? Backup codes : null; + } + render( + + + User profile + + Account + + + + + + + + , + ); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'backup'); + expect(screen.getByText('No results')).toBeInTheDocument(); + + act(() => reveals.forEach(reveal => reveal())); + + expect(await screen.findByRole('option', { name: 'Backup codes' })).toBeInTheDocument(); + }); + + it('follows a target whose label or keywords change while the search is open', async () => { + const user = userEvent.setup(); + let advance = () => {}; + function Renamed() { + const [step, setStep] = React.useState(0); + advance = () => setStep(step + 1); + return ( + 0 ? 'Recovery codes' : 'Backup codes'} + keywords={step > 1 ? ['spare'] : []} + /> + ); + } + render( + + + User profile + + Account + + + + + + + + , + ); + const search = screen.getByRole('combobox', { name: 'Search' }); + + await user.type(search, 'codes'); + expect(screen.getByRole('option', { name: 'Backup codes' })).toBeInTheDocument(); + + act(() => advance()); + expect(await screen.findByRole('option', { name: 'Recovery codes' })).toBeInTheDocument(); + + await user.clear(search); + await user.type(search, 'spare'); + expect(screen.getByText('No results')).toBeInTheDocument(); + + act(() => advance()); + expect(await screen.findByRole('option', { name: 'Recovery codes' })).toBeInTheDocument(); + }); + + it('leaves out a page that throws while it is indexed, and keeps the rest', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); + function Broken(): React.ReactNode { + if (useIsSearchIndexing()) { + throw new Error('Broken page'); + } + return

Broken page content

; + } + render( + + + User profile + + Account + Broken + + + + Email addresses + + + + + + + , + ); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'email'); + + expect(screen.getByRole('option', { name: 'Email addresses' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Email addresses' })).toBeInTheDocument(); + }); + + it('says so when nothing matches', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'zzz'); + + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + expect(screen.getByText('No results')).toBeInTheDocument(); + }); + + it('renders closed pages only while the search is in use, and never the body of a section', async () => { + const user = userEvent.setup(); + renderSurface(); + expect(indexedRenders).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('combobox', { name: 'Search' })); + expect(indexedRenders).toHaveBeenCalledWith(true); + expect(sectionBodyRenders).not.toHaveBeenCalled(); + + await user.tab(); + expect(indexClosed).toHaveBeenCalled(); + indexedRenders.mockClear(); + await user.click(screen.getByRole('tab', { name: 'Account' })); + expect(indexedRenders).not.toHaveBeenCalled(); + }); + + it('stops rendering closed pages once the search goes away', async () => { + const user = userEvent.setup(); + const { rerender } = renderSurface(); + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'seat'); + expect(indexClosed).not.toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(indexClosed).toHaveBeenCalled(); + }); + + it('opens the page of a chosen section and brings the section itself into view', async () => { + const user = userEvent.setup(); + renderSurface(); + const search = screen.getByRole('combobox', { name: 'Search' }); + + await user.type(search, 'devices'); + await user.click(screen.getByRole('option', { name: 'Active devices' })); + + expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute('aria-selected', 'true'); + expect(search).toHaveValue(''); + expect(screen.getByRole('tablist')).toBeVisible(); + const target = screen.getByText('Devices list').parentElement; + expect(target?.tagName).toBe('SECTION'); + expect(target).toHaveClass('cl-section', 'cl-profile-search-target'); + expect(scrolledTargets()).toEqual([target]); + expect(target).toHaveFocus(); + expect(target).toHaveAttribute('data-highlighted'); + expect(targetOf('Password')).not.toHaveAttribute('data-highlighted'); + expect(sectionBodyRenders).not.toHaveBeenCalledWith(true); + }); + + it('keeps targets out of the tab order', () => { + renderSurface(); + + expect(targetOf('Phone numbers')).toHaveAttribute('tabindex', '-1'); + }); + + it('scrolls the chosen target into view, at once when motion is reduced', async () => { + const user = userEvent.setup(); + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + renderSurface(); + const search = screen.getByRole('combobox', { name: 'Search' }); + + await user.type(search, 'phone'); + await user.click(screen.getByRole('option', { name: 'Phone numbers' })); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'smooth', block: 'start' }); + expect(focus).toHaveBeenLastCalledWith({ preventScroll: true }); + + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' })), + ); + await user.type(search, 'email'); + await user.click(screen.getByRole('option', { name: 'Email addresses' })); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto', block: 'start' }); + }); + + it('makes the element passed as `render` the target', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'rate'); + await user.click(screen.getByRole('option', { name: 'Rate limits' })); + + const target = targetOf('Rate limits'); + expect(target.tagName).toBe('ARTICLE'); + expect(target).toHaveClass('usage-card'); + expect(screen.getByRole('heading', { name: 'Rate limits' }).parentElement).toBe(target); + expect(target).toHaveFocus(); + expect(target).toHaveAttribute('data-highlighted'); + }); + + it('tells apart targets that share a label by their id', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'seats'); + await user.click(screen.getAllByRole('option', { name: 'Seats' })[1]); + + expect(targetOf('Extra seats')).toHaveFocus(); + }); + + it('brings a section on the open page into view', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'phone'); + await user.click(screen.getByRole('option', { name: 'Phone numbers' })); + + expect(screen.getByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(targetOf('Phone numbers')).toHaveFocus(); + expect(targetOf('Phone numbers')).toHaveAttribute('data-highlighted'); + }); + + it('clears the highlight once its animation ends', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'phone'); + await user.click(screen.getByRole('option', { name: 'Phone numbers' })); + const target = targetOf('Phone numbers'); + act(() => { + target.dispatchEvent(new Event('animationend', { bubbles: true })); + }); + + expect(target).not.toHaveAttribute('data-highlighted'); + }); + + it('keeps the highlight when an animation inside the target ends', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'phone'); + await user.click(screen.getByRole('option', { name: 'Phone numbers' })); + act(() => { + screen + .getByRole('heading', { name: 'Phone numbers' }) + .dispatchEvent(new Event('animationend', { bubbles: true })); + }); + + expect(targetOf('Phone numbers')).toHaveAttribute('data-highlighted'); + }); + + it('chooses the active result with the keyboard', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'numbers'); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(screen.getByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(targetOf('Phone numbers')).toHaveFocus(); + }); + + it('focuses the page title when a page is chosen', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'bill'); + await user.click(screen.getByRole('option', { name: 'Billing' })); + + expect(screen.getByRole('tab', { name: 'Billing' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('heading', { name: 'Billing' })).toHaveFocus(); + expect(scrolledTargets()).toEqual([]); + }); + + it('falls back to the page title when the chosen section is not on the page', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'index only'); + await user.click(screen.getByRole('option', { name: 'Index only' })); + + expect(screen.getByRole('heading', { name: 'Security' })).toHaveFocus(); + expect(scrolledTargets()).toEqual([]); + }); + + it('falls back to the page title when the chosen section has left the page', async () => { + const user = userEvent.setup(); + const retires: (() => void)[] = []; + function Retired() { + const [retired, setRetired] = React.useState(false); + retires.push(() => setRetired(true)); + return useIsSearchIndexing() || !retired ? Old devices : null; + } + renderSecurityPage(); + + act(() => retires.forEach(retire => retire())); + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'old'); + await user.click(screen.getByRole('option', { name: 'Old devices' })); + + expect(screen.getByRole('heading', { name: 'Security' })).toHaveFocus(); + expect(scrolledTargets()).toEqual([]); + }); + + it('clears the search on Escape, and leaves the dialog open', async () => { + const user = userEvent.setup(); + render( + + + + + + + , + ); + const search = screen.getByRole('combobox', { name: 'Search' }); + + await user.type(search, 'pass'); + await user.keyboard('{Escape}'); + + expect(search).toHaveValue(''); + expect(screen.getByRole('dialog', { name: 'User profile' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog', { name: 'User profile' })).not.toBeInTheDocument(); + }); + + it('clears the search from its clear button', async () => { + const user = userEvent.setup(); + renderSurface(); + const search = screen.getByRole('combobox', { name: 'Search' }); + + await user.type(search, 'pass'); + await user.click(screen.getByRole('button', { name: 'Clear search' })); + + expect(search).toHaveValue(''); + expect(search).toHaveFocus(); + expect(screen.getByRole('tablist')).toBeVisible(); + }); + + it('renders no search without pages to search', () => { + render( + + User profile + + Account + + + Account page + + , + ); + + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('renders a target outside a profile as its element alone', () => { + render( + } + > + Seats + , + ); + + expect(screen.getByText('Seats').tagName).toBe('ARTICLE'); + }); + + describe('compact', () => { + const original = globalThis.ResizeObserver; + + afterEach(() => { + globalThis.ResizeObserver = original; + }); + + function reportWidth(width: number) { + class FakeResizeObserver { + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + if (!target.classList.contains('cl-profile-sentinel')) { + return; + } + Object.defineProperty(target, 'offsetWidth', { configurable: true, value: width }); + Object.defineProperty(target, 'offsetHeight', { configurable: true, value: 600 }); + this.callback([], this as unknown as ResizeObserver); + } + disconnect() {} + unobserve() {} + } + globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; + } + + it.each([ + ['select', 2], + ['sheet', 3], + ])('renders no search in the %s layout', (_layout, width) => { + reportWidth(width); + + renderSurface(); + + expect(screen.queryByRole('combobox', { name: 'Search' })).not.toBeInTheDocument(); + }); + + it('renders no search in the open sheet', async () => { + const user = userEvent.setup(); + reportWidth(3); + renderSurface(); + + await user.click(screen.getByRole('button', { name: 'Account' })); + + expect(screen.getByRole('dialog', { name: 'User profile' })).toContainElement(screen.getByRole('tablist')); + expect(screen.queryByRole('combobox', { name: 'Search' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/mosaic/src/components/profile/profile-search.tsx b/packages/mosaic/src/components/profile/profile-search.tsx new file mode 100644 index 00000000000..dca84f6984c --- /dev/null +++ b/packages/mosaic/src/components/profile/profile-search.tsx @@ -0,0 +1,194 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass'; +import { X } from '../../icons/glyphs/x'; +import { useMessages } from '../../localization'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { Button } from '../button'; +import { Combobox } from '../combobox'; +import { Icon } from '../icon'; +import { InputGroup } from '../input-group'; +import { styles } from './profile-search.styles'; +import { type IndexedTarget, type ProfileSearchState, useSearchIndex } from './profile-search-index'; + +/** A page the profile's search lists, found by its `label` or `keywords`. */ +export interface ProfileSearchPage { + /** The `value` of the page's `Profile.NavItem`. */ + value: string; + label: string; + icon?: React.ReactNode; + keywords?: readonly string[]; +} + +export function ProfileSearch({ pages, search }: { pages: readonly ProfileSearchPage[]; search: ProfileSearchState }) { + const m = useMessages('profile'); + const { query, setQuery, setFocused, indexContainer, revealResult } = search; + const index = useSearchIndex(indexContainer); + const inputRef = React.useRef(null); + const groupId = React.useId(); + const normalized = query.trim().toLocaleLowerCase(); + const groups = normalized ? matchesOf(pages, index, normalized) : []; + const results = new Map(groups.flatMap(group => group.results.map(result => [result.key, result] as const))); + + React.useEffect( + () => () => { + setQuery(''); + setFocused(false); + }, + [setQuery, setFocused], + ); + + return ( + { + const result = key === null ? undefined : results.get(key); + if (result) { + revealResult(result.page, result.target); + } + }} + > + + + + + setFocused(true)} + onBlur={() => setFocused(false)} + onKeyDown={event => { + if (event.key === 'Escape' && query !== '') { + event.stopPropagation(); + setQuery(''); + } + }} + /> + {query ? ( + + + + ) : null} + + {normalized ? ( + + {groups.length === 0 ? {m.search.empty} : null} + {groups.map(({ page, results: pageResults }) => ( +
+
+ {page.icon ? ( + + {page.icon} + + ) : null} + {page.label} +
+ {pageResults.map(result => ( + + + + ))} +
+ ))} +
+ ) : null} +
+ ); +} + +interface Result { + key: string; + label: string; + page: string; + target: string | null; +} + +function matchesOf(pages: readonly ProfileSearchPage[], index: readonly IndexedTarget[], query: string) { + const matches = (label: string, keywords: readonly string[] = []) => + [label, ...keywords].some(text => text.toLocaleLowerCase().includes(query)); + return pages + .map(page => { + const results: Result[] = []; + if (matches(page.label, page.keywords)) { + results.push({ key: JSON.stringify([page.value]), label: page.label, page: page.value, target: null }); + } + for (const entry of index) { + if (entry.page === page.value && matches(entry.label, entry.keywords)) { + results.push({ + key: JSON.stringify([page.value, entry.target]), + label: entry.label, + page: page.value, + target: entry.target, + }); + } + } + return { page, results }; + }) + .filter(group => group.results.length > 0); +} + +function Highlight({ text, query }: { text: string; query: string }) { + const start = text.toLocaleLowerCase().indexOf(query); + if (start === -1) { + return text; + } + const end = start + query.length; + return ( + + {text.slice(0, start)} + + {text.slice(start, end)} + + {text.slice(end)} + + ); +} diff --git a/packages/mosaic/src/components/profile/profile.context.ts b/packages/mosaic/src/components/profile/profile.context.ts index 0301a4f57fa..6df111c0fff 100644 --- a/packages/mosaic/src/components/profile/profile.context.ts +++ b/packages/mosaic/src/components/profile/profile.context.ts @@ -1,5 +1,7 @@ import React from 'react'; +import type { ProfileSearchState } from './profile-search-index'; + export type ProfileNavLayout = 'column' | 'select' | 'sheet'; export type ProfileDismiss = 'back' | 'corner' | 'none'; @@ -21,6 +23,7 @@ export interface ProfileContextValue { navTriggerRef: React.MutableRefObject; inline: boolean; dismiss: ProfileDismiss; + search: ProfileSearchState; } export const ProfileContext = React.createContext(null); diff --git a/packages/mosaic/src/components/profile/profile.messages.ts b/packages/mosaic/src/components/profile/profile.messages.ts index 71610dc528a..8f52e9ffa40 100644 --- a/packages/mosaic/src/components/profile/profile.messages.ts +++ b/packages/mosaic/src/components/profile/profile.messages.ts @@ -1,3 +1,8 @@ export const profileMessages = { backToApp: 'Back to app', + search: { + label: 'Search', + clear: 'Clear search', + empty: 'No results', + }, } as const; diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index e4383cf69e7..792d851c9d4 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -205,6 +205,10 @@ export const styles = stylex.create({ lineHeight: typeScaleVars['--cl-text-xs-leading'], }, + navListHidden: { + display: 'none', + }, + navItem: { borderColor: 'transparent', borderRadius: radiusVars['--cl-radius-md'], diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 0f12cbf3db1..6ce90c4a0e1 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -28,6 +28,9 @@ import { VisuallyHidden } from '../visually-hidden'; import type { ProfileContextValue, ProfileDismiss, ProfileNavLayout } from './profile.context'; import { ContentPanelContext, ProfileContext } from './profile.context'; import { contentScroll, contentViewportScroll, styles } from './profile.styles'; +import type { ProfileSearchPage } from './profile-search'; +import { ProfileSearch } from './profile-search'; +import { SearchIndexPage, SearchPageContext, useProfileSearch } from './profile-search-index'; // The sentinel's width (1/2/3px) carries the CSS breakpoints into JS; unmeasured is wide. function navLayoutFor(sentinelWidth: number): ProfileNavLayout { @@ -113,6 +116,7 @@ const Root = React.forwardRef(function Profile }, [onValueChange, closeNav], ); + const search = useProfileSearch({ value, selectPage, pageTitleRef }); const context = React.useMemo( () => ({ titleId, @@ -131,6 +135,7 @@ const Root = React.forwardRef(function Profile navTriggerRef, inline, dismiss, + search, }), [ titleId, @@ -146,6 +151,7 @@ const Root = React.forwardRef(function Profile pageTitleId, inline, dismiss, + search, ], ); const element = useRender({ @@ -225,7 +231,10 @@ const Title = React.forwardRef(function P ); }); -export type ProfileNavProps = MosaicComponentProps<'nav'>; +export interface ProfileNavProps extends MosaicComponentProps<'nav'> { + /** The pages the search lists. Their sections come from the `SearchTarget`s each page renders. Without it the nav has no search. */ + search?: readonly ProfileSearchPage[]; +} type NavItemMode = 'tab' | 'option'; @@ -273,7 +282,7 @@ function NavBranding() { * the page title's select, or a tablist in a sheet on a phone. */ const Nav = React.forwardRef(function ProfileNav( - { children, render, xstyle, ...rest }, + { search: searchPages, children, render, xstyle, ...rest }, ref, ) { const { @@ -287,7 +296,10 @@ const Nav = React.forwardRef(function ProfileNav( navTriggerRef, inline, dismiss, + search, } = useProfileContext('Profile.Nav'); + const searchable = Boolean(searchPages) && navLayout === 'column'; + const searching = searchable && search.query.trim() !== ''; useSafeLayoutEffect(() => { setNavItems(children); }, [children, setNavItems]); @@ -306,7 +318,19 @@ const Nav = React.forwardRef(function ProfileNav( children: ( <> {dismiss === 'back' ? : null} - + {searchable && searchPages ? ( + + ) : null} + {renderBranding && !compact && !inline ? : null} @@ -646,23 +670,28 @@ export interface ProfileContentPanelProps extends MosaicComponentProps<'div'> { } const ContentPanel = React.forwardRef(function ProfileContentPanel( - { value, xstyle, ...rest }, + { value, xstyle, children, ...rest }, ref, ) { const { compact, pageTitleId } = useProfileContext('Profile.ContentPanel'); return ( - - - - - + + + + + {children} + + + + {children} + ); }); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index e3435143c62..def24609df0 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -14,10 +14,15 @@ import { FeedbackBody, hasMessage, useHeldMessage, useMessageHeight } from '../. import { withTruncatableLabel } from '../../utils/truncatable-label'; import type { HeadingProps } from '../heading'; import { Heading, useHeadingLevel } from '../heading'; +import type { SearchTargetOptions } from '../profile/profile-search-index'; +import { useSearchTarget } from '../profile/profile-search-index'; import { sectionNestedItemMarker } from './section.markers.stylex'; import { styles } from './section.styles'; -export type SectionRootProps = Omit, 'title'>; +export interface SectionRootProps extends Omit, 'title'> { + /** Makes the section a target of the profile's search, found by this `label` and `keywords`. */ + search?: SearchTargetOptions; +} export type SectionGroupProps = MosaicComponentProps<'div'>; export type SectionHeaderProps = MosaicComponentProps<'div'>; export type SectionTitleProps = Omit; @@ -46,13 +51,18 @@ const SectionHeaderContext = React.createContext(false); const SectionItemsContext = React.createContext(false); const SectionItemWrapContext = React.createContext(false); -const Root = React.forwardRef(function SectionRoot({ render, xstyle, ...rest }, ref) { - return useRender({ +const Root = React.forwardRef(function SectionRoot( + { search, render, xstyle, ...rest }, + ref, +) { + const target = useSearchTarget(search); + const element = useRender({ defaultTagName: 'section', render, - ref, - props: mergeStyleProps(themeProps('section'), stylex.props(reset.base, styles.root, xstyle), rest), + ref: [ref, target.ref], + props: target.props(stylex.props(reset.base, styles.root, xstyle), { ...themeProps('section'), ...rest }), }); + return target.marker ?? element; }); const Group = React.forwardRef(function SectionGroup( diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx index f52f2cadd02..527ce23577e 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx @@ -136,6 +136,36 @@ describe('OrganizationProfile', () => { expect(screen.getByRole('heading', { level: 3, name: 'API Keys' })).toBeVisible(); }); + it('finds a search target on a page of your own, and brings it into view', async () => { + serve(); + const user = userEvent.setup(); + await renderWithClerk( + } + > +

Quota

+ + ), + }, + ]} + />, + ); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'quota'); + await user.click(screen.getByRole('option', { name: 'Quota' })); + + const quota = screen.getByRole('heading', { name: 'Quota' }); + expect(quota).toBeVisible(); + expect(quota.parentElement).toHaveFocus(); + }); + it('adds custom pages in the requested order and opens on the first', async () => { serve(); await renderWithClerk( diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx index bd02789857e..82771d2d682 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx @@ -25,7 +25,7 @@ export function OrganizationProfileDangerSectionView({ } return ( - + diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.view.tsx index 3199d0dba9e..63cf2d3f519 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.view.tsx @@ -31,7 +31,7 @@ export function OrganizationProfileProfileSectionView({ const m = useMessages('organizationProfile').profile; return ( - + diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index 9d736de54df..6723216b76f 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; +import { SearchTarget } from '../../components/profile'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; import { useProfilePagesController } from '../profile-pages/profile-pages.controller'; import { organizationProfilePageIds } from './organization-profile.layout'; @@ -129,3 +130,5 @@ export function organizationProfilePanels( available.includes('apiKeys') && !hideAPIKeys ? : undefined, }; } + +OrganizationProfile.SearchTarget = SearchTarget; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts index 8c421e9a5bf..054addc5ff3 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts @@ -15,6 +15,8 @@ export interface CustomOrganizationProfilePage { path: string; href?: never; icon?: ReactNode; + /** Other words that find the page in the profile's search. */ + keywords?: readonly string[]; content: ReactNode; } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx index 7db4186450b..2b6e439a698 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -43,7 +43,13 @@ export function organizationProfilePageList( ): ProfilePage[] { return organizationProfileNavEntries(options).map(entry => entry.custom - ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content } + ? { + id: entry.id, + label: entry.custom.label, + icon: entry.custom.icon, + keywords: entry.custom.keywords, + content: entry.custom.content, + } : { id: entry.id, label: labels[entry.id], diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx b/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx index 710176849c3..47dab73db02 100644 --- a/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx +++ b/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import React from 'react'; -import type { ProfileRootProps } from '../../components/profile'; +import type { ProfileRootProps, ProfileSearchPage } from '../../components/profile'; import { Profile } from '../../components/profile'; /** One destination in a profile's navigation, with the page it opens. */ @@ -9,6 +9,7 @@ export interface ProfilePage { id: string; label: string; icon: ReactNode; + keywords?: readonly string[]; content: ReactNode; } @@ -36,6 +37,12 @@ export const ProfilePagesView = React.forwardRef group.pages); const activeEntry = pages.find(page => page.id === activePage) ?? pages[0]; + const searchPages: ProfileSearchPage[] = pages.map(page => ({ + value: page.id, + label: page.label, + icon: page.icon, + keywords: page.keywords, + })); return ( {label} - + {groups.map((group, index) => group.label === undefined ? ( { expect(screen.getByRole('heading', { level: 3, name: 'API Keys' })).toBeVisible(); }); + it('finds a search target on a page of your own, and brings it into view', async () => { + serve(); + const user = userEvent.setup(); + await renderWithClerk( + } + > +

Quota

+ + ), + }, + ]} + />, + ); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'quota'); + expect(screen.getByRole('tablist', { hidden: true }).checkVisibility()).toBe(false); + await user.click(screen.getByRole('option', { name: 'Quota' })); + + const quota = screen.getByRole('heading', { name: 'Quota' }); + expect(quota).toBeVisible(); + expect(quota.parentElement).toHaveFocus(); + }); + it('adds custom pages in the requested order and opens on the first', async () => { serve(); await renderWithClerk( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index 333f6968b00..9cf93222262 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -256,4 +256,29 @@ describe('UserProfileView', () => { expect(document.querySelector('.cl-profile-nav-group')).not.toBeInTheDocument(); }); }); + + it('finds a section on a page that is not open, and opens that page', async () => { + const onPageChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPageChange }); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'subscr'); + const billing = screen.getByRole('group', { name: 'Billing' }); + expect(billing).toContainElement(screen.getByRole('option', { name: 'Subscription' })); + + await user.click(screen.getByRole('option', { name: 'Subscription' })); + + expect(onPageChange).toHaveBeenCalledWith('billing'); + }); + + it('finds a page of your own by its keywords', async () => { + const user = userEvent.setup(); + renderView({ + customPages: [{ label: 'Usage', path: 'usage', keywords: ['quota'], content:

Usage content

}], + }); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'quota'); + + expect(screen.getByRole('option', { name: 'Usage' })).toBeInTheDocument(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx index f95b6830764..00f5963c1bc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx @@ -40,7 +40,7 @@ export function UserProfileActiveDevicesSectionView({ return (
{m.loaded} - + diff --git a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.view.tsx index f48b884f417..2a80d8b476d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.view.tsx @@ -42,7 +42,7 @@ export function UserProfileBillingHistorySectionView({ onView, }: UserProfileBillingHistorySectionViewProps) { return ( - + diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx index 0ce9ea67d93..a9a4b159057 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx @@ -36,7 +36,7 @@ export function UserProfileConnectedAccountsSectionView({ return ( <> {hasRows ? ( - + + diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx index c110e120037..005f1c524a9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx @@ -49,7 +49,7 @@ export function UserProfileEmailSectionView({ ) : undefined; return ( - + - + diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.view.tsx index e6a6ffc5475..209b14dbf64 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.view.tsx @@ -52,7 +52,7 @@ export function UserProfilePasskeysSectionView({ return ( <> - + + diff --git a/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section/user-profile-payment-methods-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section/user-profile-payment-methods-section.view.tsx index ab56a106095..49438bb8d83 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section/user-profile-payment-methods-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section/user-profile-payment-methods-section.view.tsx @@ -30,7 +30,7 @@ export function UserProfilePaymentMethodsSectionView({ onRemove, }: UserProfilePaymentMethodsSectionViewProps) { return ( - + diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx index f5c0d37718b..7cc21f3f402 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx @@ -50,7 +50,7 @@ export function UserProfilePhoneSectionView({ ) : undefined; return ( - + + diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx index 5e9a48f5ec2..323e8201218 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx @@ -25,7 +25,7 @@ export function UserProfileSecurityList({ children: ReactNode; }) { return ( - + + diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx index aeab97de06c..887bd302006 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx @@ -30,7 +30,7 @@ export function UserProfileWeb3WalletsSectionView({ return ( <> {hasRows ? ( - + ); } + +UserProfile.SearchTarget = SearchTarget; diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts index dcd57d0fa44..353f4e1dd02 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts @@ -37,6 +37,8 @@ export interface CustomProfilePage { path: string; href?: never; icon?: ReactNode; + /** Other words that find the page in the profile's search. */ + keywords?: readonly string[]; /** Rendered as the page itself. */ content: ReactNode; } diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx index 19845659935..afc0591ed29 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -83,7 +83,13 @@ function userProfilePageList( ): ProfilePage[] { return userProfileNavEntries({ pages, customPages, pageOrder }).map(entry => entry.custom - ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content } + ? { + id: entry.id, + label: entry.custom.label, + icon: entry.custom.icon, + keywords: entry.custom.keywords, + content: entry.custom.content, + } : { id: entry.id, label: labels[entry.id], diff --git a/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx b/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx index 54a77360a8b..6a0e8db842b 100644 --- a/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx @@ -1,6 +1,8 @@ 'use client'; +import { Icon } from '@clerk/mosaic/components/icon'; import { UserProfile } from '@clerk/mosaic/features/user-profile/user-profile'; +import { Bolt } from '@clerk/mosaic/icons/glyphs/bolt'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; @@ -28,8 +30,47 @@ export default function UserProfileLivePage() { to use the live harness.

) : null} - Loading…

} /> + Loading…

} + customPages={[ + { + path: 'usage', + label: 'Usage', + icon: ( + + ), + keywords: ['quota'], + content: , + }, + ]} + />
); } + +function UsagePage() { + return ( +
+ } + > +

Rate limits

+

1,000 requests per minute.

+
+ } + > +

Seats

+

4 of 10 seats used.

+
+
+ ); +} From 523b9cbaddee5c342f722b2c87c81a7c5824e3cf Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 13:31:15 -0400 Subject: [PATCH 2/5] feat(mosaic): draw the search highlight with anchor positioning --- .../profile/profile-search-index.tsx | 34 ++++++------- .../profile/profile-search.styles.ts | 48 +++++++++++++------ .../profile/profile-search.test.tsx | 8 ++-- .../mosaic/src/components/profile/profile.tsx | 11 ++++- .../__tests__/user-profile.feature.test.tsx | 45 ++++++++++++++++- 5 files changed, 109 insertions(+), 37 deletions(-) diff --git a/packages/mosaic/src/components/profile/profile-search-index.tsx b/packages/mosaic/src/components/profile/profile-search-index.tsx index a70dcad1954..4f7cd773b5c 100644 --- a/packages/mosaic/src/components/profile/profile-search-index.tsx +++ b/packages/mosaic/src/components/profile/profile-search-index.tsx @@ -79,35 +79,37 @@ export function useSearchTarget(options: SearchTargetOptions | undefined) { /> ) : null; - function props>( - styleProps: ReturnType, - rest: Rest & { onAnimationEnd?: React.AnimationEventHandler }, - ) { + function props>(styleProps: ReturnType, rest: Rest) { if (key === null) { return mergeStyleProps(styleProps, rest); } - const { onAnimationEnd, ...others } = rest; return mergeStyleProps( themeProps('profile-search-target'), stylex.props(styles.target, highlighted && styles.targetHighlighted), styleProps, - { - tabIndex: -1, - 'data-highlighted': highlighted ? '' : undefined, - onAnimationEnd: (event: React.AnimationEvent) => { - onAnimationEnd?.(event); - if (event.target === event.currentTarget) { - search?.clearHighlight(); - } - }, - }, - others, + { tabIndex: -1, 'data-highlighted': highlighted ? '' : undefined }, + rest, ); } return { marker, ref, props }; } +export function SearchHighlight() { + const search = React.useContext(ProfileContext)?.search; + if (!search?.highlightedTarget) { + return null; + } + return ( +
+ ); +} + export function SearchIndexPage({ value, children }: { value: string; children: React.ReactNode }) { const profile = React.useContext(ProfileContext); const container = profile?.search.indexContainer; diff --git a/packages/mosaic/src/components/profile/profile-search.styles.ts b/packages/mosaic/src/components/profile/profile-search.styles.ts index cbacb50d2c2..7873074abac 100644 --- a/packages/mosaic/src/components/profile/profile-search.styles.ts +++ b/packages/mosaic/src/components/profile/profile-search.styles.ts @@ -2,6 +2,11 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +// Literals: StyleX cannot import them. Must match `tabs.styles.ts`. +const anchors = '@supports (anchor-scope: all) and (not (-moz-appearance: none))' as const; +const targetAnchor = '--_cl-profile-search-target'; +const gap = space['3']; + const fade = stylex.keyframes({ from: { opacity: 1 }, to: { opacity: 0 }, @@ -54,24 +59,37 @@ export const styles = stylex.create({ target: { outline: 'none', - isolation: 'isolate', - position: 'relative', scrollMarginBlockStart: space['12'], }, targetHighlighted: { - '::after': { - inset: `calc(-1 * ${space['3']})`, - borderRadius: `calc(${radiusVars['--cl-radius-xl']} + ${space['3']})`, - animationDuration: '1.5s', - animationFillMode: 'forwards', - animationName: fade, - animationTimingFunction: 'linear', - backgroundColor: colorVars['--cl-color-neutral-alpha-100'], - content: '""', - pointerEvents: 'none', - position: 'absolute', - zIndex: -1, - }, + // eslint-disable-next-line @stylexjs/valid-styles -- `anchor-name`: anchor positioning postdates StyleX's property allowlist; it compiles and emits correctly. + anchorName: targetAnchor, + }, + + highlightScope: { + // eslint-disable-next-line @stylexjs/valid-styles -- `anchor-scope`: anchor positioning postdates StyleX's property allowlist; it compiles and emits correctly. + anchorScope: targetAnchor, + isolation: 'isolate', + position: 'relative', + }, + + highlight: { + // eslint-disable-next-line @stylexjs/valid-styles -- `position-anchor`: anchor positioning postdates StyleX's property allowlist; it compiles and emits correctly. + positionAnchor: targetAnchor, + borderRadius: `calc(${radiusVars['--cl-radius-xl']} + ${gap})`, + animationDuration: '1.5s', + animationFillMode: 'forwards', + animationName: fade, + animationTimingFunction: 'linear', + backgroundColor: colorVars['--cl-color-neutral-alpha-100'], + insetBlockEnd: `calc(anchor(end) - ${gap})`, + insetBlockStart: `calc(anchor(start) - ${gap})`, + insetInlineEnd: `calc(anchor(end) - ${gap})`, + insetInlineStart: `calc(anchor(start) - ${gap})`, + pointerEvents: 'none', + position: 'absolute', + visibility: { [anchors]: 'visible', default: 'hidden' }, + zIndex: -1, }, }); diff --git a/packages/mosaic/src/components/profile/profile-search.test.tsx b/packages/mosaic/src/components/profile/profile-search.test.tsx index 2a8976bd6f8..87da4129793 100644 --- a/packages/mosaic/src/components/profile/profile-search.test.tsx +++ b/packages/mosaic/src/components/profile/profile-search.test.tsx @@ -524,12 +524,14 @@ describe('Profile search', () => { await user.type(screen.getByRole('combobox', { name: 'Search' }), 'phone'); await user.click(screen.getByRole('option', { name: 'Phone numbers' })); - const target = targetOf('Phone numbers'); act(() => { - target.dispatchEvent(new Event('animationend', { bubbles: true })); + document + .querySelector('.cl-profile-search-highlight') + ?.dispatchEvent(new Event('animationend', { bubbles: true })); }); - expect(target).not.toHaveAttribute('data-highlighted'); + expect(targetOf('Phone numbers')).not.toHaveAttribute('data-highlighted'); + expect(document.querySelector('.cl-profile-search-highlight')).toBeNull(); }); it('keeps the highlight when an animation inside the target ends', async () => { diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 6ce90c4a0e1..3ca1841c0df 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -30,7 +30,8 @@ import { ContentPanelContext, ProfileContext } from './profile.context'; import { contentScroll, contentViewportScroll, styles } from './profile.styles'; import type { ProfileSearchPage } from './profile-search'; import { ProfileSearch } from './profile-search'; -import { SearchIndexPage, SearchPageContext, useProfileSearch } from './profile-search-index'; +import { styles as searchStyles } from './profile-search.styles'; +import { SearchHighlight, SearchIndexPage, SearchPageContext, useProfileSearch } from './profile-search-index'; // The sentinel's width (1/2/3px) carries the CSS breakpoints into JS; unmeasured is wide. function navLayoutFor(sentinelWidth: number): ProfileNavLayout { @@ -643,11 +644,17 @@ const Content = React.forwardRef(function P ), )} > -
+
{children} + {inline && renderBranding && !compact ? (
{ expect(quota.parentElement).toHaveFocus(); }); + it('draws the highlight around a found target without positioning the target', async () => { + const slowHighlight = document.createElement('style'); + slowHighlight.textContent = '.cl-profile-search-highlight { animation-duration: 60s !important; }'; + document.head.append(slowHighlight); + onTestFinished(() => slowHighlight.remove()); + serve(); + const user = userEvent.setup(); + await renderWithClerk( + } + > +

Quota

+ + ), + }, + ]} + />, + ); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'quota'); + await user.click(screen.getByRole('option', { name: 'Quota' })); + + const target = screen.getByRole('heading', { name: 'Quota' }).closest('section'); + const highlight = document.querySelector('.cl-profile-search-highlight'); + if (!target || !highlight) { + throw new Error('Expected a highlighted target'); + } + expect(getComputedStyle(target).position).toBe('static'); + const around = target.getBoundingClientRect(); + const drawn = highlight.getBoundingClientRect(); + expect(drawn.left).toBeCloseTo(around.left - 12); + expect(drawn.top).toBeCloseTo(around.top - 12); + expect(drawn.width).toBeCloseTo(around.width + 24); + expect(drawn.height).toBeCloseTo(around.height + 24); + }); + it('adds custom pages in the requested order and opens on the first', async () => { serve(); await renderWithClerk( From d87af20f5c0d7a33e4fc484420f3c42d64977027 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 14:05:33 -0400 Subject: [PATCH 3/5] fix(mosaic): keep the search reveal from scrolling past its target A leaving profile page is taken out of the layout so it no longer pushes the new page down while it exits, and a found target scrolls only as far as it needs to be in view. --- .../profile/profile-search-index.tsx | 2 +- .../profile/profile-search.styles.ts | 1 + .../profile/profile-search.test.tsx | 4 ++-- .../src/components/profile/profile.styles.ts | 7 ++++++ .../mosaic/src/components/profile/profile.tsx | 6 ++++- .../__tests__/user-profile.feature.test.tsx | 22 +++++++++++++++++++ 6 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/components/profile/profile-search-index.tsx b/packages/mosaic/src/components/profile/profile-search-index.tsx index 4f7cd773b5c..6a30b4cb2ac 100644 --- a/packages/mosaic/src/components/profile/profile-search-index.tsx +++ b/packages/mosaic/src/components/profile/profile-search-index.tsx @@ -259,7 +259,7 @@ export function useProfileSearch({ pageTitleRef.current?.focus(); return; } - element.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' }); + element.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'nearest' }); element.focus({ preventScroll: true }); setHighlightedTarget(key); }, [reveal, value, pageTitleRef]); diff --git a/packages/mosaic/src/components/profile/profile-search.styles.ts b/packages/mosaic/src/components/profile/profile-search.styles.ts index 7873074abac..d65ee2ea4f4 100644 --- a/packages/mosaic/src/components/profile/profile-search.styles.ts +++ b/packages/mosaic/src/components/profile/profile-search.styles.ts @@ -59,6 +59,7 @@ export const styles = stylex.create({ target: { outline: 'none', + scrollMarginBlockEnd: space['12'], scrollMarginBlockStart: space['12'], }, diff --git a/packages/mosaic/src/components/profile/profile-search.test.tsx b/packages/mosaic/src/components/profile/profile-search.test.tsx index 87da4129793..2c5969e75ba 100644 --- a/packages/mosaic/src/components/profile/profile-search.test.tsx +++ b/packages/mosaic/src/components/profile/profile-search.test.tsx @@ -469,7 +469,7 @@ describe('Profile search', () => { await user.type(search, 'phone'); await user.click(screen.getByRole('option', { name: 'Phone numbers' })); - expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'smooth', block: 'start' }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'smooth', block: 'nearest' }); expect(focus).toHaveBeenLastCalledWith({ preventScroll: true }); vi.stubGlobal( @@ -478,7 +478,7 @@ describe('Profile search', () => { ); await user.type(search, 'email'); await user.click(screen.getByRole('option', { name: 'Email addresses' })); - expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto', block: 'start' }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto', block: 'nearest' }); }); it('makes the element passed as `render` the target', async () => { diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index 792d851c9d4..9f7c2037dd0 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -326,6 +326,13 @@ export const styles = stylex.create({ contentBody: { marginInline: 'auto', maxInlineSize: CONTENT_MAX_WIDTH, + position: 'relative', + }, + + contentPanel: { + insetInline: 0, + insetBlockStart: 0, + position: { default: null, ':where([data-closed])': 'absolute' }, }, }); diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 3ca1841c0df..7bf7efa2bea 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -691,7 +691,11 @@ const ContentPanel = React.forwardRef( aria-labelledby={`${pageTitleId}-label`} // Compact, no tablist is showing, so the page is a plain group rather than an orphan tabpanel. {...(compact ? { role: 'group', tabIndex: undefined } : null)} - {...mergeStyleProps(themeProps('profile-content-panel', { value }), stylex.props(xstyle), rest)} + {...mergeStyleProps( + themeProps('profile-content-panel', { value }), + stylex.props(styles.contentPanel, xstyle), + rest, + )} > {children} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx index a209c968e86..32583d1169e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx @@ -177,6 +177,28 @@ describe('UserProfile', () => { expect(drawn.height).toBeCloseTo(around.height + 24); }); + it('leaves the page where it is when the found target is already in view', async () => { + serve(); + const user = userEvent.setup(); + await renderWithClerk( +
+ +
, + ); + const scroller = screen.getByTestId('scroller'); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'password'); + await user.click(screen.getByRole('option', { name: 'Password' })); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + expect(screen.getByRole('heading', { level: 3, name: 'Security' })).toBeVisible(); + expect(scroller.scrollTop).toBe(0); + }); + it('adds custom pages in the requested order and opens on the first', async () => { serve(); await renderWithClerk( From ed5eaab809ddca1c5f83a4a2f3566d1a72444dad Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 18:02:42 -0400 Subject: [PATCH 4/5] feat(mosaic): add showSearch to UserProfile and OrganizationProfile Search is now off by default. Pass showSearch to show it. --- .../organization-profile.feature.test.tsx | 10 ++++++++++ .../organization-profile.view.test.tsx | 18 ++++++++++++++++++ .../organization-profile.tsx | 10 +++++++++- .../profile-pages/profile-pages.view.tsx | 11 +++++++++-- .../__tests__/user-profile.feature.test.tsx | 13 ++++++++++++- .../__tests__/user-profile.view.test.tsx | 19 ++++++++++++++++++- .../features/user-profile/user-profile.tsx | 12 +++++++++++- .../organization-profile/page.tsx | 5 ++++- .../live/user-profile/user-profile/page.tsx | 1 + 9 files changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx index 527ce23577e..383c5f03e45 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx @@ -106,6 +106,15 @@ describe('OrganizationProfile', () => { expect(tabNames()).toEqual(tabs); }); + it.each([ + { showSearch: undefined, shown: false }, + { showSearch: true, shown: true }, + ])('shows the search only when asked: $showSearch', async ({ showSearch, shown }) => { + serve(); + await renderWithClerk(); + expect(screen.queryByRole('combobox', { name: 'Search' }) !== null).toBe(shown); + }); + it('passes the API keys options to the API keys page', async () => { serve({ orgAPIKeysEnabled: true, permissions: ['org:sys_api_keys:read', 'org:sys_api_keys:manage'] }); await renderWithClerk( @@ -141,6 +150,7 @@ describe('OrganizationProfile', () => { const user = userEvent.setup(); await renderWithClerk( { expect(screen.getByText('Members content')).toBeVisible(); }); +it.each([ + { showSearch: undefined, shown: false }, + { showSearch: true, shown: true }, +])('shows the search only when asked: $showSearch', ({ showSearch, shown }) => { + render( + + }} + /> + , + ); + + expect(screen.queryByRole('combobox', { name: 'Search' }) !== null).toBe(shown); +}); + it('warns when there are no pages to show', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index 6723216b76f..d16cc242a21 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -19,7 +19,7 @@ import { OrganizationProfileMembersPanel } from './organization-profile-members- /** Everything `` takes: your own pages and their order, the page it opens on, and where leaving the organization lands. */ export interface OrganizationProfileProps extends - Pick, + Pick, Pick { /** * The page the profile opens on: a built-in page's id, or a custom page's `path`. Unlike @@ -66,6 +66,12 @@ export interface OrganizationProfileProps * ``` * * @example + * `showSearch` adds a search field above the navigation that finds pages, and the sections on them. + * ```tsx + * + * ``` + * + * @example * `customPages` adds your own pages; `pageOrder` places them among the built-in ones. * ```tsx * diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx b/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx index 47dab73db02..0caa9e37c6b 100644 --- a/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx +++ b/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx @@ -28,11 +28,18 @@ export interface ProfilePagesViewProps extends Omit void; + /** + * Shows a search field above the navigation that finds pages, and the sections on them. Only the + * column layout shows it. + * + * @default false + */ + showSearch?: boolean; } /** A `Profile` built from groups of pages: their navigation, and the active page beside it. */ export const ProfilePagesView = React.forwardRef(function ProfilePagesView( - { label, groups, activePage, onPageChange, ...rest }, + { label, groups, activePage, onPageChange, showSearch = false, ...rest }, ref, ) { const pages = groups.flatMap(group => group.pages); @@ -52,7 +59,7 @@ export const ProfilePagesView = React.forwardRef {label} - + {groups.map((group, index) => group.label === undefined ? ( { const user = userEvent.setup(); await renderWithClerk( { const user = userEvent.setup(); await renderWithClerk( { expect(drawn.height).toBeCloseTo(around.height + 24); }); + it.each([ + { showSearch: undefined, shown: false }, + { showSearch: true, shown: true }, + ])('shows the search only when asked: $showSearch', async ({ showSearch, shown }) => { + serve(); + await renderWithClerk(); + expect(screen.queryByRole('combobox', { name: 'Search' }) !== null).toBe(shown); + }); + it('leaves the page where it is when the found target is already in view', async () => { serve(); const user = userEvent.setup(); @@ -185,7 +196,7 @@ describe('UserProfile', () => { data-testid='scroller' style={{ height: 400, overflowY: 'auto' }} > - +
, ); const scroller = screen.getByTestId('scroller'); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index 9cf93222262..534131737dd 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -257,10 +257,26 @@ describe('UserProfileView', () => { }); }); + it('shows the search only when asked', () => { + const { rerender, props } = renderView(); + expect(screen.queryByRole('combobox', { name: 'Search' })).toBeNull(); + + rerender( + + + , + ); + + expect(screen.getByRole('combobox', { name: 'Search' })).toBeVisible(); + }); + it('finds a section on a page that is not open, and opens that page', async () => { const onPageChange = vi.fn(); const user = userEvent.setup(); - renderView({ onPageChange }); + renderView({ onPageChange, showSearch: true }); await user.type(screen.getByRole('combobox', { name: 'Search' }), 'subscr'); const billing = screen.getByRole('group', { name: 'Billing' }); @@ -274,6 +290,7 @@ describe('UserProfileView', () => { it('finds a page of your own by its keywords', async () => { const user = userEvent.setup(); renderView({ + showSearch: true, customPages: [{ label: 'Usage', path: 'usage', keywords: ['quota'], content:

Usage content

}], }); diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index e33057388d7..02cb179f3ad 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -29,7 +29,9 @@ export type UserProfileMode = Exclude; /** Everything `` takes: your own pages and their order, the page it opens on, and the OAuth scopes it asks for. */ export interface UserProfileProps - extends Pick, Pick { + extends + Pick, + Pick { /** * The page the profile opens on: a built-in page's id, or a custom page's `path`. Unlike * `pageOrder`, it leaves the navigation order alone. A page the navigation does not list opens @@ -106,6 +108,12 @@ export interface UserProfileProps * ``` * * @example + * `showSearch` adds a search field above the navigation that finds pages, and the sections on them. + * ```tsx + * + * ``` + * + * @example * `customPages` adds your own pages; `pageOrder` places them among the built-in ones. * ```tsx * }) { @@ -156,6 +165,7 @@ export function UserProfileSurface({ initialPage: defaultPage ?? pageOrder?.[0] ?? 'account', customPages, pageOrder, + showSearch, pages: { account: , security: , diff --git a/packages/swingset/src/app/(clerk)/live/organization-profile/organization-profile/page.tsx b/packages/swingset/src/app/(clerk)/live/organization-profile/organization-profile/page.tsx index c87a399c180..14fb1d4a419 100644 --- a/packages/swingset/src/app/(clerk)/live/organization-profile/organization-profile/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/organization-profile/organization-profile/page.tsx @@ -33,7 +33,10 @@ export default function OrganizationProfileLivePage() { {isLoaded && isSignedIn && !organization ? (

Pick an active organization to see its profile.

) : null} - Loading…

} /> + Loading…

} + />
); diff --git a/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx b/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx index 6a0e8db842b..78c471ae08f 100644 --- a/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx @@ -31,6 +31,7 @@ export default function UserProfileLivePage() {

) : null} Loading…

} customPages={[ { From 700589004dd8a1932de58cffda431556a3acbea8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 15:01:13 -0400 Subject: [PATCH 5/5] docs(swingset): toggle showSearch from the profile playgrounds --- .../swingset/src/stories/organization-profile.mdx | 12 ++++++++++++ .../src/stories/organization-profile.stories.tsx | 15 ++++++++++++++- packages/swingset/src/stories/user-profile.mdx | 12 ++++++++++++ .../swingset/src/stories/user-profile.stories.tsx | 15 ++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index 36a10e0905f..5e40c62f66b 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -39,3 +39,15 @@ navigation dismisses it in place of the corner close button. name='Fullscreen' storyModule={Stories} /> + +## Playground + +`showSearch` adds a search field above the navigation that finds pages, and the sections on them. +Only the column layout shows it. + + + + diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index 5b6ebcbb853..db6f32de904 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -16,9 +16,21 @@ export const meta: StoryMeta = { label: 'Organization profile', layout: 'wide', source: 'packages/mosaic/src/features/organization-profile/organization-profile.view.tsx', + styles: { + _variants: { + showSearch: { true: {}, false: {} }, + }, + _defaultVariants: { + showSearch: 'false', + }, + }, }; -export function Default() { +function knobsAsProps(props: Record) { + return { showSearch: props.showSearch === true || props.showSearch === 'true' }; +} + +export function Default(props: Record) { const { activePage, setActivePage, pages } = useOrganizationProfileFixture(); return ( ); } diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index a75bc3cb465..3f38933651a 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -41,3 +41,15 @@ navigation dismisses it in place of the corner close button. name='Fullscreen' storyModule={Stories} /> + +## Playground + +`showSearch` adds a search field above the navigation that finds pages, and the sections on them. +Only the column layout shows it. + + + + diff --git a/packages/swingset/src/stories/user-profile.stories.tsx b/packages/swingset/src/stories/user-profile.stories.tsx index 0ef8a86a063..cdb2c5d2208 100644 --- a/packages/swingset/src/stories/user-profile.stories.tsx +++ b/packages/swingset/src/stories/user-profile.stories.tsx @@ -15,14 +15,26 @@ export const meta: StoryMeta = { label: 'User profile', layout: 'wide', source: 'packages/mosaic/src/features/user-profile/user-profile.view.tsx', + styles: { + _variants: { + showSearch: { true: {}, false: {} }, + }, + _defaultVariants: { + showSearch: 'false', + }, + }, }; +function knobsAsProps(props: Record) { + return { showSearch: props.showSearch === true || props.showSearch === 'true' }; +} + /** * The profile as a page's content — `elevation='flush'`, the way a `Card` chooses its elevation: * unframed, flush with its host, scrolling with the page. The fixture stands in for the model and * controller: every page's data, and actions that update it, so the surface behaves. */ -export function Default() { +export function Default(props: Record) { const { activePage, setActivePage, pages } = useUserProfileFixture(); return ( ); }