From e623e756aa63007ad27a56e40dffefc8e4236976 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 12:22:09 -0400 Subject: [PATCH 1/7] 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 3b345da1a96ba0ebc7ea459321e03402b928450d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 13:31:15 -0400 Subject: [PATCH 2/7] 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 405ea5353d076ec00a6b1f0c62bc9cde79571e66 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 14:05:33 -0400 Subject: [PATCH 3/7] 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 7491a1070171c25fb0879bcf83e78e5ae5342f8f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 18:02:42 -0400 Subject: [PATCH 4/7] 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 fe65fe4e14a4d1e824b73b25235ccc0392c1a6d3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 15:01:13 -0400 Subject: [PATCH 5/7] 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 ( ); } From 6b8b034ea2e9ce23463f8192236ba5350a27a95a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 16:18:24 -0400 Subject: [PATCH 6/7] feat(mosaic): search sections without rendering closed pages Each page now declares the sections the search finds, so closed pages are never rendered or fetched to build the index. Built-in user profile sections come from a search model that reuses each section's hide check, and their labels come from localized messages. Custom pages declare their sections with `search` and mark them with `SearchTarget id`. --- .../mosaic/src/components/profile/index.ts | 6 +- .../profile/profile-search-index.tsx | 286 ---------------- .../profile/profile-search-target.tsx | 163 +++++++++ .../profile/profile-search.test.tsx | 317 ++++-------------- .../src/components/profile/profile-search.tsx | 38 +-- .../src/components/profile/profile.context.ts | 2 +- .../mosaic/src/components/profile/profile.tsx | 5 +- .../mosaic/src/components/section/section.tsx | 14 +- .../organization-profile.feature.test.tsx | 13 +- ...ganization-profile-danger-section.view.tsx | 2 +- ...anization-profile-profile-section.view.tsx | 2 +- .../organization-profile.types.ts | 4 + .../organization-profile.view.tsx | 16 +- .../profile-pages/profile-pages.view.tsx | 4 +- .../__tests__/user-profile.feature.test.tsx | 50 ++- .../__tests__/user-profile.view.test.tsx | 12 +- ...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-mfa-section.model.ts | 7 +- .../user-profile-mfa-section.view.tsx | 1 + .../user-profile-passkeys-section.model.ts | 2 +- .../user-profile-passkeys-section.view.tsx | 2 +- .../user-profile-password-section.model.ts | 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/user-profile-search.model.ts | 87 +++++ .../user-profile-security-list.tsx | 4 +- ...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 | 3 + .../user-profile/user-profile.view.tsx | 37 +- .../live/user-profile/user-profile/page.tsx | 10 +- .../src/stories/fixtures/user-profile.tsx | 40 ++- .../src/stories/user-profile.stories.tsx | 12 +- 41 files changed, 557 insertions(+), 611 deletions(-) delete mode 100644 packages/mosaic/src/components/profile/profile-search-index.tsx create mode 100644 packages/mosaic/src/components/profile/profile-search-target.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-search.model.ts diff --git a/packages/mosaic/src/components/profile/index.ts b/packages/mosaic/src/components/profile/index.ts index a06a025562b..93b3d4513b0 100644 --- a/packages/mosaic/src/components/profile/index.ts +++ b/packages/mosaic/src/components/profile/index.ts @@ -9,6 +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'; +export type { ProfileSearchPage, ProfileSearchTarget } from './profile-search'; +export type { SearchTargetProps } from './profile-search-target'; +export { SearchTarget } from './profile-search-target'; diff --git a/packages/mosaic/src/components/profile/profile-search-index.tsx b/packages/mosaic/src/components/profile/profile-search-index.tsx deleted file mode 100644 index 6a30b4cb2ac..00000000000 --- a/packages/mosaic/src/components/profile/profile-search-index.tsx +++ /dev/null @@ -1,286 +0,0 @@ -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) { - if (key === null) { - return mergeStyleProps(styleProps, rest); - } - return mergeStyleProps( - themeProps('profile-search-target'), - stylex.props(styles.target, highlighted && styles.targetHighlighted), - styleProps, - { 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; - 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: 'nearest' }); - 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-target.tsx b/packages/mosaic/src/components/profile/profile-search-target.tsx new file mode 100644 index 00000000000..ec4ae5501a1 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile-search-target.tsx @@ -0,0 +1,163 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +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'; + +export interface SearchTargetProps extends MosaicComponentProps<'div'> { + /** The `id` of the page's search target this element is. */ + id: string; +} + +/** + * The element the profile's search scrolls to and focuses for the target with this `id`. Pass your + * own element as `render` to make it the target, with no wrapper of its own. + * + * ```tsx + * }> + *

Seats

+ *
+ * ``` + */ +export const SearchTarget = React.forwardRef(function SearchTarget( + { id, render, xstyle, ...rest }, + ref, +) { + const target = useSearchTarget(id); + return useRender({ + defaultTagName: 'div', + render, + ref: [ref, target.ref], + props: target.props(stylex.props(reset.base, xstyle), rest), + }); +}); + +export function useSearchTarget(id: string | undefined) { + const page = React.useContext(SearchPageContext); + const search = React.useContext(ProfileContext)?.search; + const key = id !== undefined && page !== null ? targetKey(page, id) : 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; + + function props>(styleProps: ReturnType, rest: Rest) { + if (key === null) { + return mergeStyleProps(styleProps, rest); + } + return mergeStyleProps( + themeProps('profile-search-target'), + stylex.props(styles.target, highlighted && styles.targetHighlighted), + styleProps, + { tabIndex: -1, 'data-highlighted': highlighted ? '' : undefined }, + rest, + ); + } + + return { ref, props }; +} + +export function SearchHighlight() { + const search = React.useContext(ProfileContext)?.search; + if (!search?.highlightedTarget) { + return null; + } + return ( +
+ ); +} + +export const SearchPageContext = React.createContext(null); + +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 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: 'nearest' }); + element.focus({ preventScroll: true }); + setHighlightedTarget(key); + }, [reveal, value, pageTitleRef]); + + return React.useMemo( + () => ({ + query, + setQuery, + registerTarget, + revealResult, + highlightedTarget, + clearHighlight, + }), + [query, 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.test.tsx b/packages/mosaic/src/components/profile/profile-search.test.tsx index 2c5969e75ba..063ea3ab409 100644 --- a/packages/mosaic/src/components/profile/profile-search.test.tsx +++ b/packages/mosaic/src/components/profile/profile-search.test.tsx @@ -8,7 +8,7 @@ import { Dialog } from '../dialog'; import { Section } from '../section'; import type { ProfileNavProps } from './profile'; import { Profile } from './profile'; -import { SearchTarget, useIsSearchIndexing } from './profile-search-index'; +import { SearchTarget } from './profile-search-target'; const pageTitles: Record = { account: 'Account', @@ -18,15 +18,40 @@ const pageTitles: Record = { }; const searchPages: NonNullable = [ - { value: 'account', label: 'Account' }, - { value: 'security', label: 'Security' }, + { + value: 'account', + label: 'Account', + targets: [ + { id: 'email', label: 'Email addresses', keywords: ['inbox'] }, + { id: 'phone', label: 'Phone numbers' }, + ], + }, + { + value: 'security', + label: 'Security', + targets: [ + { id: 'password', label: 'Password' }, + { id: 'devices', label: 'Active devices' }, + { id: 'recovery', label: 'Recovery codes' }, + { id: 'retired', label: 'Old devices' }, + ], + }, { value: 'billing', label: 'Billing', keywords: ['invoices', 'plan'] }, - { value: 'usage', label: 'Usage', keywords: ['plan'] }, + { + value: 'usage', + label: 'Usage', + keywords: ['plan'], + targets: [ + { id: 'rate-limits', label: 'Rate limits', keywords: ['throttle'] }, + { id: 'seats-included', label: 'Seats' }, + { id: 'seats-extra', label: 'Seats' }, + ], + }, ]; -function Titled({ children, keywords, id }: { children: string; keywords?: readonly string[]; id?: string }) { +function Titled({ children, id }: { children: string; id: string }) { return ( - + @@ -38,32 +63,29 @@ function Titled({ children, keywords, id }: { children: string; keywords?: reado ); } -const indexedRenders = vi.fn(); -const indexClosed = vi.fn(); -const sectionBodyRenders = vi.fn(); +const pageRenders = vi.fn(); + +function Rendered({ page, children }: { page: string; children: React.ReactNode }) { + pageRenders(page); + return children; +} function UsagePage() { - const indexing = useIsSearchIndexing(); - indexedRenders(indexing); - React.useEffect(() => (indexing ? indexClosed : undefined), [indexing]); return ( <> } >

Rate limits

} >

Seats included

} > @@ -73,24 +95,7 @@ function UsagePage() { ); } -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; -}) { +function Surface({ initialPage = 'account', searchable = true }: { initialPage?: string; searchable?: boolean }) { const [page, setPage] = React.useState(initialPage); return ( - Email addresses - {showPhone ? Phone numbers : null} + + Email addresses + Phone numbers + - Password - - - - + + Password + +

Devices list

+
+ + +

Recovery codes

+
+
+
+
+ + Billing page - Billing page - + + +
@@ -133,22 +150,6 @@ function renderSurface(props: React.ComponentProps = {}) { ); } -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) { @@ -173,9 +174,7 @@ function scrolledTargets() { beforeEach(() => { scrollIntoView.mockClear(); - indexedRenders.mockClear(); - indexClosed.mockClear(); - sectionBodyRenders.mockClear(); + pageRenders.mockClear(); Element.prototype.scrollIntoView = scrollIntoView; }); @@ -197,7 +196,7 @@ describe('Profile search', () => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); - it('finds the sections a page renders, though the page is not open, grouped by page', async () => { + it('finds the sections a page declares, grouped by page', async () => { const user = userEvent.setup(); renderSurface(); @@ -222,19 +221,6 @@ describe('Profile search', () => { 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(); @@ -267,132 +253,6 @@ describe('Profile search', () => { 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(); @@ -403,37 +263,27 @@ describe('Profile search', () => { 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 () => { + it('renders no page but the open one while searching', async () => { const user = userEvent.setup(); renderSurface(); - expect(indexedRenders).not.toHaveBeenCalled(); + pageRenders.mockClear(); 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(); + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'seats'); + + expect(screen.getAllByRole('option', { name: 'Seats' })).toHaveLength(2); + expect(pageRenders.mock.calls.every(([page]) => page === 'account')).toBe(true); }); - it('stops rendering closed pages once the search goes away', async () => { + it('finds a target inside a section that is not a target itself', async () => { const user = userEvent.setup(); - const { rerender } = renderSurface(); - await user.type(screen.getByRole('combobox', { name: 'Search' }), 'seat'); - expect(indexClosed).not.toHaveBeenCalled(); + renderSurface(); - rerender( - - - , - ); + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'recovery'); + await user.click(screen.getByRole('option', { name: 'Recovery codes' })); - expect(indexClosed).toHaveBeenCalled(); + expect(targetOf('Recovery codes')).toHaveFocus(); }); - it('opens the page of a chosen section and brings the section itself into view', async () => { const user = userEvent.setup(); renderSurface(); @@ -452,7 +302,6 @@ describe('Profile search', () => { 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', () => { @@ -576,24 +425,6 @@ describe('Profile search', () => { 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' })); @@ -657,7 +488,7 @@ describe('Profile search', () => { it('renders a target outside a profile as its element alone', () => { render( } > Seats diff --git a/packages/mosaic/src/components/profile/profile-search.tsx b/packages/mosaic/src/components/profile/profile-search.tsx index dca84f6984c..e55642ca380 100644 --- a/packages/mosaic/src/components/profile/profile-search.tsx +++ b/packages/mosaic/src/components/profile/profile-search.tsx @@ -11,7 +11,7 @@ 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'; +import type { ProfileSearchState } from './profile-search-target'; /** A page the profile's search lists, found by its `label` or `keywords`. */ export interface ProfileSearchPage { @@ -20,25 +20,27 @@ export interface ProfileSearchPage { label: string; icon?: React.ReactNode; keywords?: readonly string[]; + /** The sections of the page the search finds. Each `id` matches a `Section.Root searchId` or `SearchTarget id` on the page. */ + targets?: readonly ProfileSearchTarget[]; +} + +/** A section of a page the profile's search finds by its `label` or `keywords`. */ +export interface ProfileSearchTarget { + id: string; + label: string; + 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 { query, setQuery, revealResult } = search; const inputRef = React.useRef(null); const groupId = React.useId(); const normalized = query.trim().toLocaleLowerCase(); - const groups = normalized ? matchesOf(pages, index, normalized) : []; + const groups = normalized ? matchesOf(pages, normalized) : []; const results = new Map(groups.flatMap(group => group.results.map(result => [result.key, result] as const))); - React.useEffect( - () => () => { - setQuery(''); - setFocused(false); - }, - [setQuery, setFocused], - ); + React.useEffect(() => () => setQuery(''), [setQuery]); return ( setFocused(true)} - onBlur={() => setFocused(false)} onKeyDown={event => { if (event.key === 'Escape' && query !== '') { event.stopPropagation(); @@ -152,7 +152,7 @@ interface Result { target: string | null; } -function matchesOf(pages: readonly ProfileSearchPage[], index: readonly IndexedTarget[], query: string) { +function matchesOf(pages: readonly ProfileSearchPage[], query: string) { const matches = (label: string, keywords: readonly string[] = []) => [label, ...keywords].some(text => text.toLocaleLowerCase().includes(query)); return pages @@ -161,13 +161,13 @@ function matchesOf(pages: readonly ProfileSearchPage[], index: readonly IndexedT 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)) { + for (const target of page.targets ?? []) { + if (matches(target.label, target.keywords)) { results.push({ - key: JSON.stringify([page.value, entry.target]), - label: entry.label, + key: JSON.stringify([page.value, target.id]), + label: target.label, page: page.value, - target: entry.target, + target: target.id, }); } } diff --git a/packages/mosaic/src/components/profile/profile.context.ts b/packages/mosaic/src/components/profile/profile.context.ts index 6df111c0fff..9d6e3aa8156 100644 --- a/packages/mosaic/src/components/profile/profile.context.ts +++ b/packages/mosaic/src/components/profile/profile.context.ts @@ -1,6 +1,6 @@ import React from 'react'; -import type { ProfileSearchState } from './profile-search-index'; +import type { ProfileSearchState } from './profile-search-target'; export type ProfileNavLayout = 'column' | 'select' | 'sheet'; diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 7bf7efa2bea..44c5c75c7c6 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -31,7 +31,7 @@ import { contentScroll, contentViewportScroll, styles } from './profile.styles'; import type { ProfileSearchPage } from './profile-search'; import { ProfileSearch } from './profile-search'; import { styles as searchStyles } from './profile-search.styles'; -import { SearchHighlight, SearchIndexPage, SearchPageContext, useProfileSearch } from './profile-search-index'; +import { SearchHighlight, SearchPageContext, useProfileSearch } from './profile-search-target'; // The sentinel's width (1/2/3px) carries the CSS breakpoints into JS; unmeasured is wide. function navLayoutFor(sentinelWidth: number): ProfileNavLayout { @@ -233,7 +233,7 @@ const Title = React.forwardRef(function P }); 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. */ + /** The pages the search lists, with the sections each one declares. Without it the nav has no search. */ search?: readonly ProfileSearchPage[]; } @@ -701,7 +701,6 @@ const ContentPanel = React.forwardRef( - {children} ); }); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index def24609df0..2ec587ba57c 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -14,14 +14,13 @@ 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 { useSearchTarget } from '../profile/profile-search-target'; import { sectionNestedItemMarker } from './section.markers.stylex'; import { styles } from './section.styles'; export interface SectionRootProps extends Omit, 'title'> { - /** Makes the section a target of the profile's search, found by this `label` and `keywords`. */ - search?: SearchTargetOptions; + /** Makes the section the element the profile's search reveals for the target with this `id`. */ + searchId?: string; } export type SectionGroupProps = MosaicComponentProps<'div'>; export type SectionHeaderProps = MosaicComponentProps<'div'>; @@ -52,17 +51,16 @@ const SectionItemsContext = React.createContext(false); const SectionItemWrapContext = React.createContext(false); const Root = React.forwardRef(function SectionRoot( - { search, render, xstyle, ...rest }, + { searchId, render, xstyle, ...rest }, ref, ) { - const target = useSearchTarget(search); - const element = useRender({ + const target = useSearchTarget(searchId); + return useRender({ defaultTagName: 'section', render, 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 383c5f03e45..ee61b76b8f2 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 @@ -155,9 +155,10 @@ describe('OrganizationProfile', () => { { path: 'usage', label: 'Usage', + search: [{ id: 'quota', label: 'Quota' }], content: ( } >

Quota

@@ -176,6 +177,16 @@ describe('OrganizationProfile', () => { expect(quota.parentElement).toHaveFocus(); }); + it('finds the sections of the general page', async () => { + serve(); + const user = userEvent.setup(); + await renderWithClerk(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'danger'); + + expect(within(screen.getByRole('group', { name: 'General' })).getByRole('option')).toHaveTextContent('Danger zone'); + }); + 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 82771d2d682..30106278af3 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 63cf2d3f519..dfd430de777 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.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts index 054addc5ff3..787c31075e4 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts @@ -1,5 +1,7 @@ import type { ReactNode } from 'react'; +import type { ProfileSearchTarget } from '../../components/profile'; + export type OrganizationProfilePageId = 'general' | 'members' | 'security' | 'billing' | 'apiKeys'; export interface OrganizationProfilePages { @@ -17,6 +19,8 @@ export interface CustomOrganizationProfilePage { icon?: ReactNode; /** Other words that find the page in the profile's search. */ keywords?: readonly string[]; + /** The sections the profile's search finds on the page. Mark each one with a `SearchTarget` of the same `id`. */ + search?: readonly ProfileSearchTarget[]; 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 2b6e439a698..bda493abc97 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -2,7 +2,7 @@ import { logger } from '@clerk/shared/logger'; import React from 'react'; import { Icon } from '../../components/icon'; -import { useMessages } from '../../localization'; +import { type MosaicMessages, useMessages } from '../../localization'; import type { ProfilePage, ProfilePagesViewProps } from '../profile-pages/profile-pages.view'; import { ProfilePagesView } from '../profile-pages/profile-pages.view'; import { ORGANIZATION_PROFILE_PAGE_ICONS, organizationProfileNavEntries } from './organization-profile.layout'; @@ -20,7 +20,7 @@ export interface OrganizationProfileViewProps export const OrganizationProfileView = React.forwardRef( function OrganizationProfileView({ pages, customPages, pageOrder, label, ...rest }, ref) { const m = useMessages('organizationProfile'); - const list = organizationProfilePageList({ pages, customPages, pageOrder }, m.pages); + const list = organizationProfilePageList({ pages, customPages, pageOrder }, m); if (list.length === 0) { logger.warnOnce('[Clerk] OrganizationProfile has no pages to show.'); } @@ -39,7 +39,7 @@ export const OrganizationProfileView = React.forwardRef, + m: MosaicMessages['organizationProfile'], ): ProfilePage[] { return organizationProfileNavEntries(options).map(entry => entry.custom @@ -48,17 +48,25 @@ export function organizationProfilePageList( label: entry.custom.label, icon: entry.custom.icon, keywords: entry.custom.keywords, + search: entry.custom.search, content: entry.custom.content, } : { id: entry.id, - label: labels[entry.id], + label: m.pages[entry.id], icon: ( ), + search: + entry.id === 'general' + ? [ + { id: 'profile', label: m.profile.sectionTitle }, + { id: 'danger', label: m.danger.sectionTitle }, + ] + : undefined, content: options.pages[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 0caa9e37c6b..e1e63443ed5 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, ProfileSearchPage } from '../../components/profile'; +import type { ProfileRootProps, ProfileSearchPage, ProfileSearchTarget } from '../../components/profile'; import { Profile } from '../../components/profile'; /** One destination in a profile's navigation, with the page it opens. */ @@ -10,6 +10,7 @@ export interface ProfilePage { label: string; icon: ReactNode; keywords?: readonly string[]; + search?: readonly ProfileSearchTarget[]; content: ReactNode; } @@ -49,6 +50,7 @@ export const ProfilePagesView = React.forwardRef { { path: 'usage', label: 'Usage', + search: [{ id: 'quota', label: 'Quota' }], content: ( } >

Quota

@@ -149,9 +150,10 @@ describe('UserProfile', () => { { path: 'usage', label: 'Usage', + search: [{ id: 'quota', label: 'Quota' }], content: ( } >

Quota

@@ -188,6 +190,50 @@ describe('UserProfile', () => { expect(screen.queryByRole('combobox', { name: 'Search' }) !== null).toBe(shown); }); + it.each([ + { passkey: false, results: [] }, + { passkey: true, results: ['Passkeys'] }, + ])('finds a section only when the instance shows it: passkeys $passkey', async ({ passkey, results }) => { + serveFapi({ + environment: fapiEnvironment({ attributes: { passkey: { enabled: passkey } } }), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + const user = userEvent.setup(); + await renderWithClerk(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'passkey'); + + expect(screen.queryAllByRole('option').map(option => option.textContent)).toEqual(results); + }); + + it('finds the sections of a closed page without loading it', async () => { + serve(); + const sessions = holdRequests('get', '/v1/me/sessions/active'); + const user = userEvent.setup(); + await renderWithClerk(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'devices'); + + expect(within(screen.getByRole('group', { name: 'Security' })).getByRole('option')).toHaveTextContent( + 'Active devices', + ); + expect(sessions.requests).toHaveLength(0); + sessions.release(); + }); + + it('finds a section by the name a localization gives it', async () => { + serve(); + const user = userEvent.setup(); + await renderWithClerk(, undefined, { + overrides: { userProfile: { password: { label: 'Passphrase' } } }, + }); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'passphrase'); + await user.click(screen.getByRole('option', { name: 'Passphrase' })); + + expect(screen.getByRole('heading', { name: 'Passphrase' }).closest('.cl-profile-search-target')).toHaveFocus(); + }); + it('leaves the page where it is when the found target is already in view', async () => { serve(); const user = userEvent.setup(); 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 534131737dd..982b0c55c7b 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 @@ -276,15 +276,15 @@ describe('UserProfileView', () => { 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, showSearch: true }); + renderView({ onPageChange, showSearch: true, searchSections: { security: ['password', 'activeDevices'] } }); - 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.type(screen.getByRole('combobox', { name: 'Search' }), 'devices'); + const security = screen.getByRole('group', { name: 'Security' }); + expect(security).toContainElement(screen.getByRole('option', { name: 'Active devices' })); - await user.click(screen.getByRole('option', { name: 'Subscription' })); + await user.click(screen.getByRole('option', { name: 'Active devices' })); - expect(onPageChange).toHaveBeenCalledWith('billing'); + expect(onPageChange).toHaveBeenCalledWith('security'); }); it('finds a page of your own by its keywords', async () => { 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 00f5963c1bc..558492444e7 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 2a80d8b476d..f48b884f417 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 a9a4b159057..e0d962e0c19 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 005f1c524a9..a9590497812 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-mfa-section/user-profile-mfa-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts index bcc8e6e2d51..8356d8ba781 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts @@ -21,6 +21,11 @@ function reloadUser(user: UserResource): Promise { return user.reload().catch(() => undefined); } +export function offersMfa(environment: EnvironmentResource): boolean { + const factors = configuredFactors(environment); + return factors.includes('phone_code') || factors.includes('totp'); +} + function configuredFactors(environment: EnvironmentResource | undefined): string[] { return Object.values(environment?.userSettings.attributes ?? {}).flatMap(attribute => attribute?.used_for_second_factor ? attribute.second_factors : [], @@ -240,7 +245,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { const smsEnabled = secondFactors.includes('phone_code'); const totpEnabled = secondFactors.includes('totp'); const backupEnabled = secondFactors.includes('backup_code'); - if (!smsEnabled && !totpEnabled) { + if (!offersMfa(environment)) { return { status: 'hidden' }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx index e4609cf442b..ab07dc71b02 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx @@ -71,6 +71,7 @@ export function UserProfileMfaSectionView({ addLabel={m.addLabel} emptyLabel={m.empty} hasItems={methods.length > 0} + searchId='mfa' label={m.label} > {methods.map(method => ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts index a927281f3d1..04b6d43d847 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts @@ -69,7 +69,7 @@ export type UserProfilePasskeysModel = addReverification: ReverificationController; }; -function getPasskeysProjection( +export function getPasskeysProjection( user: UserResource, environment: EnvironmentResource, isSatellite: boolean, 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 209b14dbf64..f19e3f9257f 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 49438bb8d83..ab56a106095 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 7cc21f3f402..eca09561a10 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-search.model.ts b/packages/mosaic/src/features/user-profile/user-profile-search.model.ts new file mode 100644 index 00000000000..b1bf56409f3 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-search.model.ts @@ -0,0 +1,87 @@ +import { useClerk, useSession, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, UserResource } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { allowsIdentificationCreation } from '../../utils/allows-identification-creation'; +import type { UserProfilePageId } from './user-profile.types'; +import { toContactAccess } from './user-profile-contact.utils'; +import { projectEnterpriseAccounts } from './user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model'; +import { offersMfa } from './user-profile-mfa-section/user-profile-mfa-section.model'; +import { getPasskeysProjection } from './user-profile-passkeys-section/user-profile-passkeys-section.model'; +import { getPasswordPolicy } from './user-profile-password-section/user-profile-password-section.model'; +import { projectWeb3Wallets } from './user-profile-web3-wallets-section/user-profile-web3-wallets-section.model'; + +export type UserProfileSectionId = + | 'profile' + | 'email' + | 'phone' + | 'connectedAccounts' + | 'enterpriseAccounts' + | 'web3Wallets' + | 'danger' + | 'password' + | 'passkeys' + | 'mfa' + | 'activeDevices'; + +export type UserProfileSearchSections = Partial>; + +export function useUserProfileSearchModel(): UserProfileSearchSections { + const clerk = useClerk(); + const { user } = useUser(); + const { session } = useSession(); + const environment = useMosaicEnvironment(); + + if (!user || !environment) { + return {}; + } + return { + account: accountSections(user, environment), + security: session ? securitySections(user, environment, clerk.isSatellite) : [], + }; +} + +function accountSections(user: UserResource, environment: EnvironmentResource): UserProfileSectionId[] { + const { attributes, enterpriseSSO, socialProviderStrategies, web3FirstFactors } = environment.userSettings; + const canAdd = allowsIdentificationCreation(user, enterpriseSSO); + const web3Wallet = attributes.web3_wallet; + return shown([ + ['profile', true], + ['email', toContactAccess(attributes.email_address, user.emailAddresses.length, canAdd).show], + ['phone', toContactAccess(attributes.phone_number, user.phoneNumbers.length, canAdd).show], + ['connectedAccounts', socialProviderStrategies.length > 0], + [ + 'enterpriseAccounts', + projectEnterpriseAccounts({ enabled: enterpriseSSO.enabled, accounts: user.enterpriseAccounts, connections: [] }) + .status !== 'hidden', + ], + [ + 'web3Wallets', + Boolean(web3Wallet?.enabled) && + projectWeb3Wallets({ + wallets: user.web3Wallets, + primaryId: user.primaryWeb3WalletId, + enabledStrategies: web3FirstFactors, + allowCreation: !web3Wallet?.immutable && canAdd, + }).status !== 'hidden', + ], + ['danger', user.deleteSelfEnabled], + ]); +} + +function securitySections( + user: UserResource, + environment: EnvironmentResource, + isSatellite: boolean, +): UserProfileSectionId[] { + return shown([ + ['password', getPasswordPolicy(user, environment).status !== 'hidden'], + ['passkeys', getPasskeysProjection(user, environment, isSatellite).status !== 'hidden'], + ['mfa', offersMfa(environment)], + ['activeDevices', true], + ]); +} + +function shown(sections: readonly (readonly [UserProfileSectionId, boolean])[]): UserProfileSectionId[] { + return sections.flatMap(([id, visible]) => (visible ? [id] : [])); +} 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 323e8201218..505b67a01c9 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 @@ -7,6 +7,7 @@ import { Plus } from '../../icons/glyphs/plus'; export function UserProfileSecurityList({ sectionRef, + searchId, label, addLabel, emptyLabel, @@ -16,6 +17,7 @@ export function UserProfileSecurityList({ children, }: { sectionRef?: Ref; + searchId?: string; label: string; addLabel: string; emptyLabel: string; @@ -25,7 +27,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 887bd302006..850af6761d4 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 ? ( - + }) { const model = useUserProfileUserModel(); const builtInPages = useUserProfilePages(); + const searchSections = useUserProfileSearchModel(); if (model.status === 'loading') { return fallback ?? null; @@ -166,6 +168,7 @@ export function UserProfileSurface({ customPages, pageOrder, showSearch, + searchSections, pages: { account: , security: , 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 353f4e1dd02..df9b08ad902 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { FormSubmitError } from '../../components/form'; +import type { ProfileSearchTarget } from '../../components/profile'; /** Plain data, so nothing downstream of the model imports a Clerk error. */ export interface UserProfileFormError { @@ -39,6 +40,8 @@ export interface CustomProfilePage { icon?: ReactNode; /** Other words that find the page in the profile's search. */ keywords?: readonly string[]; + /** The sections the profile's search finds on the page. Mark each one with a `SearchTarget` of the same `id`. */ + search?: readonly ProfileSearchTarget[]; /** 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 afc0591ed29..1642add6313 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -2,7 +2,7 @@ import { logger } from '@clerk/shared/logger'; import React from 'react'; import { Icon } from '../../components/icon'; -import { useMessages } from '../../localization'; +import { type MosaicMessages, useMessages } from '../../localization'; import type { OrganizationProfilePageId, OrganizationProfilePageListOptions, @@ -12,6 +12,7 @@ import type { ProfilePage, ProfilePageGroup, ProfilePagesViewProps } from '../pr import { ProfilePagesView } from '../profile-pages/profile-pages.view'; import { ORGANIZATION_PAGE_PREFIX, USER_PROFILE_PAGE_ICONS, userProfileNavEntries } from './user-profile.layout'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; +import type { UserProfileSearchSections, UserProfileSectionId } from './user-profile-search.model'; export interface UserProfileViewProps extends Omit< ProfilePagesViewProps, @@ -25,6 +26,8 @@ export interface UserProfileViewProps extends Omit< */ activePage: UserProfilePageId | `organization/${OrganizationProfilePageId}` | (string & {}); pages: UserProfilePages; + /** The sections each built-in page shows, for the search to find. */ + searchSections?: UserProfileSearchSections; /** Pages of the consumer's own, added to the navigation after the built-ins. */ customPages?: readonly CustomProfilePage[]; /** @@ -44,18 +47,18 @@ export interface UserProfileViewProps extends Omit< * first one, so a page turned off by the environment cannot leave the surface blank. */ export const UserProfileView = React.forwardRef(function UserProfileView( - { pages, customPages, pageOrder, organization, label, ...rest }, + { pages, searchSections = {}, customPages, pageOrder, organization, label, ...rest }, ref, ) { const m = useMessages('userProfile'); - const organizationLabels = useMessages('organizationProfile').pages; - const accountPages = userProfilePageList({ pages, customPages, pageOrder }, m.pages); + const organizationMessages = useMessages('organizationProfile'); + const accountPages = userProfilePageList({ pages, customPages, pageOrder }, searchSections, m); const groups: ProfilePageGroup[] = organization ? [ { label: m.accountGroup, pages: accountPages }, { label: m.organizationGroup, - pages: organizationProfilePageList(organization, organizationLabels).map(page => ({ + pages: organizationProfilePageList(organization, organizationMessages).map(page => ({ ...page, id: `${ORGANIZATION_PAGE_PREFIX}${page.id}`, })), @@ -79,8 +82,10 @@ export const UserProfileView = React.forwardRef, - labels: Record, + searchSections: UserProfileSearchSections, + m: MosaicMessages['userProfile'], ): ProfilePage[] { + const sectionLabels = userProfileSectionLabels(m); return userProfileNavEntries({ pages, customPages, pageOrder }).map(entry => entry.custom ? { @@ -88,18 +93,36 @@ function userProfilePageList( label: entry.custom.label, icon: entry.custom.icon, keywords: entry.custom.keywords, + search: entry.custom.search, content: entry.custom.content, } : { id: entry.id, - label: labels[entry.id], + label: m.pages[entry.id], icon: ( ), + search: searchSections[entry.id]?.map(id => ({ id, label: sectionLabels[id] })), content: pages[entry.id], }, ); } + +function userProfileSectionLabels(m: MosaicMessages['userProfile']): Record { + return { + profile: m.profile.title, + email: m.email.label, + phone: m.phone.label, + connectedAccounts: m.connectedAccounts.title, + enterpriseAccounts: m.enterpriseAccounts.title, + web3Wallets: m.web3Wallets.title, + danger: m.danger.sectionTitle, + password: m.password.label, + passkeys: m.passkeys.label, + mfa: m.mfa.label, + activeDevices: m.activeDevices.title, + }; +} 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 78c471ae08f..544b395ae5b 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 @@ -44,6 +44,10 @@ export default function UserProfileLivePage() { /> ), keywords: ['quota'], + search: [ + { id: 'rate-limits', label: 'Rate limits', keywords: ['throttle', 'requests'] }, + { id: 'seats', label: 'Seats', keywords: ['members'] }, + ], content: , }, ]} @@ -57,16 +61,14 @@ function UsagePage() { return (
} >

Rate limits

1,000 requests per minute.

} >

Seats

diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index a61f7ac3f03..26e4a41e165 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -1,4 +1,5 @@ import { useConfirmController } from '@clerk/mosaic/blocks/confirm'; +import { SearchTarget } from '@clerk/mosaic/components/profile'; import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view'; import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { @@ -190,5 +191,42 @@ export function useUserProfileFixture() { apiKeys: , }; - return { activePage, setActivePage, pages, devices: activeDevices.devices }; + const searchSections: UserProfileViewProps['searchSections'] = { + account: ['profile', 'email', 'phone', 'connectedAccounts', 'web3Wallets', 'danger'], + security: ['password', 'passkeys', 'mfa', 'activeDevices'], + }; + const customPages: UserProfileViewProps['customPages'] = [ + { + path: 'usage', + label: 'Usage', + keywords: ['quota'], + search: [ + { id: 'rate-limits', label: 'Rate limits', keywords: ['throttle', 'requests'] }, + { id: 'seats', label: 'Seats', keywords: ['members'] }, + ], + content: , + }, + ]; + return { activePage, setActivePage, pages, searchSections, customPages, devices: activeDevices.devices }; +} + +function UsagePage() { + return ( +
+ } + > +

Rate limits

+

1,000 requests per minute.

+
+ } + > +

Seats

+

4 of 10 seats used.

+
+
+ ); } diff --git a/packages/swingset/src/stories/user-profile.stories.tsx b/packages/swingset/src/stories/user-profile.stories.tsx index cdb2c5d2208..eefddd883b7 100644 --- a/packages/swingset/src/stories/user-profile.stories.tsx +++ b/packages/swingset/src/stories/user-profile.stories.tsx @@ -35,11 +35,13 @@ function knobsAsProps(props: Record) { * controller: every page's data, and actions that update it, so the surface behaves. */ export function Default(props: Record) { - const { activePage, setActivePage, pages } = useUserProfileFixture(); + const { activePage, setActivePage, pages, searchSections, customPages } = useUserProfileFixture(); return ( ) { * positions it while the profile paints itself, names the dialog, and carries its dismiss. */ export function Overlay() { - const { activePage, setActivePage, pages } = useUserProfileFixture(); + const { activePage, setActivePage, pages, searchSections, customPages } = useUserProfileFixture(); return ( }>Manage account @@ -60,6 +62,8 @@ export function Overlay() { @@ -68,7 +72,7 @@ export function Overlay() { } export function Fullscreen() { - const { activePage, setActivePage, pages } = useUserProfileFixture(); + const { activePage, setActivePage, pages, searchSections, customPages } = useUserProfileFixture(); return ( }>Manage account @@ -76,6 +80,8 @@ export function Fullscreen() { From 1fee5370f7a5f5303f6b3c8bf826778fdfae305c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Nov 2026 15:26:27 -0500 Subject: [PATCH 7/7] fix(mosaic): track the handled search reveal in a ref --- .../components/profile/profile-search-target.tsx | 5 +++-- .../src/components/profile/profile-search.test.tsx | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/components/profile/profile-search-target.tsx b/packages/mosaic/src/components/profile/profile-search-target.tsx index ec4ae5501a1..3bf84cf5678 100644 --- a/packages/mosaic/src/components/profile/profile-search-target.tsx +++ b/packages/mosaic/src/components/profile/profile-search-target.tsx @@ -105,6 +105,7 @@ export function useProfileSearch({ const [query, setQuery] = React.useState(''); const targetsRef = React.useRef(new Map()); const [reveal, setReveal] = React.useState(null); + const revealedRef = React.useRef(null); const [highlightedTarget, setHighlightedTarget] = React.useState(null); const registerTarget = React.useCallback((key: string, element: HTMLElement | null) => { @@ -128,10 +129,10 @@ export function useProfileSearch({ const clearHighlight = React.useCallback(() => setHighlightedTarget(null), []); React.useEffect(() => { - if (!reveal || reveal.page !== value) { + if (!reveal || reveal.page !== value || revealedRef.current === reveal) { return; } - setReveal(null); + revealedRef.current = reveal; const key = reveal.target === null ? null : targetKey(reveal.page, reveal.target); const element = key === null ? undefined : targetsRef.current.get(key); if (key === null || !element) { diff --git a/packages/mosaic/src/components/profile/profile-search.test.tsx b/packages/mosaic/src/components/profile/profile-search.test.tsx index 063ea3ab409..c9ce615e4cd 100644 --- a/packages/mosaic/src/components/profile/profile-search.test.tsx +++ b/packages/mosaic/src/components/profile/profile-search.test.tsx @@ -304,6 +304,19 @@ describe('Profile search', () => { expect(targetOf('Password')).not.toHaveAttribute('data-highlighted'); }); + it('reveals a chosen section once, not again when its page is reopened', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'devices'); + await user.click(screen.getByRole('option', { name: 'Active devices' })); + await user.click(screen.getByRole('tab', { name: 'Account' })); + await user.click(screen.getByRole('tab', { name: 'Security' })); + + expect(scrolledTargets()).toHaveLength(1); + expect(screen.getByText('Devices list').parentElement).not.toHaveFocus(); + }); + it('keeps targets out of the tab order', () => { renderSurface();