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..93b3d4513b0 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, 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-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.styles.ts b/packages/mosaic/src/components/profile/profile-search.styles.ts new file mode 100644 index 00000000000..d65ee2ea4f4 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile-search.styles.ts @@ -0,0 +1,96 @@ +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 }, +}); + +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', + scrollMarginBlockEnd: space['12'], + scrollMarginBlockStart: space['12'], + }, + + targetHighlighted: { + // 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 new file mode 100644 index 00000000000..063ea3ab409 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile-search.test.tsx @@ -0,0 +1,550 @@ +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 } from './profile-search-target'; + +const pageTitles: Record = { + account: 'Account', + security: 'Security', + billing: 'Billing', + usage: 'Usage', +}; + +const searchPages: NonNullable = [ + { + 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'], + targets: [ + { id: 'rate-limits', label: 'Rate limits', keywords: ['throttle'] }, + { id: 'seats-included', label: 'Seats' }, + { id: 'seats-extra', label: 'Seats' }, + ], + }, +]; + +function Titled({ children, id }: { children: string; id: string }) { + return ( + + + + + {children} + + + + + ); +} + +const pageRenders = vi.fn(); + +function Rendered({ page, children }: { page: string; children: React.ReactNode }) { + pageRenders(page); + return children; +} + +function UsagePage() { + return ( + <> + } + > +

Rate limits

+
+ } + > +

Seats included

+
+ } + > +

Extra seats

+
+ + ); +} + +function Surface({ initialPage = 'account', searchable = true }: { initialPage?: string; searchable?: boolean }) { + const [page, setPage] = React.useState(initialPage); + return ( + + User profile + + Account + Security + Billing + Usage + + + + + Email addresses + Phone numbers + + + + + Password + +

Devices list

+
+ + +

Recovery codes

+
+
+
+
+ + Billing page + + + + + + +
+
+ ); +} + +function renderSurface(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +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(); + pageRenders.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 declares, 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 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('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 no page but the open one while searching', async () => { + const user = userEvent.setup(); + renderSurface(); + pageRenders.mockClear(); + + await user.click(screen.getByRole('combobox', { name: 'Search' })); + 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('finds a target inside a section that is not a target itself', async () => { + const user = userEvent.setup(); + renderSurface(); + + await user.type(screen.getByRole('combobox', { name: 'Search' }), 'recovery'); + await user.click(screen.getByRole('option', { name: 'Recovery codes' })); + + 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(); + 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'); + }); + + 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: 'nearest' }); + 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: 'nearest' }); + }); + + 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' })); + act(() => { + document + .querySelector('.cl-profile-search-highlight') + ?.dispatchEvent(new Event('animationend', { bubbles: true })); + }); + + 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 () => { + 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' }), '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..e55642ca380 --- /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 { ProfileSearchState } from './profile-search-target'; + +/** 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[]; + /** 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, revealResult } = search; + const inputRef = React.useRef(null); + const groupId = React.useId(); + const normalized = query.trim().toLocaleLowerCase(); + 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(''), [setQuery]); + + return ( + { + const result = key === null ? undefined : results.get(key); + if (result) { + revealResult(result.page, result.target); + } + }} + > + + + + + { + 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[], 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 target of page.targets ?? []) { + if (matches(target.label, target.keywords)) { + results.push({ + key: JSON.stringify([page.value, target.id]), + label: target.label, + page: page.value, + target: target.id, + }); + } + } + 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..9d6e3aa8156 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-target'; + 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..9f7c2037dd0 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'], @@ -322,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 0f12cbf3db1..44c5c75c7c6 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -28,6 +28,10 @@ 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 { styles as searchStyles } from './profile-search.styles'; +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 { @@ -113,6 +117,7 @@ const Root = React.forwardRef(function Profile }, [onValueChange, closeNav], ); + const search = useProfileSearch({ value, selectPage, pageTitleRef }); const context = React.useMemo( () => ({ titleId, @@ -131,6 +136,7 @@ const Root = React.forwardRef(function Profile navTriggerRef, inline, dismiss, + search, }), [ titleId, @@ -146,6 +152,7 @@ const Root = React.forwardRef(function Profile pageTitleId, inline, dismiss, + search, ], ); const element = useRender({ @@ -225,7 +232,10 @@ const Title = React.forwardRef(function P ); }); -export type ProfileNavProps = MosaicComponentProps<'nav'>; +export interface ProfileNavProps extends MosaicComponentProps<'nav'> { + /** The pages the search lists, with the sections each one declares. Without it the nav has no search. */ + search?: readonly ProfileSearchPage[]; +} type NavItemMode = 'tab' | 'option'; @@ -273,7 +283,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 +297,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 +319,19 @@ const Nav = React.forwardRef(function ProfileNav( children: ( <> {dismiss === 'back' ? : null} - + {searchable && searchPages ? ( + + ) : null} + {renderBranding && !compact && !inline ? : null} @@ -619,11 +644,17 @@ const Content = React.forwardRef(function P ), )} > -
+
{children} + {inline && renderBranding && !compact ? (
{ } const ContentPanel = React.forwardRef(function ProfileContentPanel( - { value, xstyle, ...rest }, + { value, xstyle, children, ...rest }, ref, ) { const { compact, pageTitleId } = useProfileContext('Profile.ContentPanel'); return ( - - - - - + + + + + {children} + + + + ); }); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index e3435143c62..2ec587ba57c 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -14,10 +14,14 @@ import { FeedbackBody, hasMessage, useHeldMessage, useMessageHeight } from '../. import { withTruncatableLabel } from '../../utils/truncatable-label'; import type { HeadingProps } from '../heading'; import { Heading, useHeadingLevel } from '../heading'; +import { useSearchTarget } from '../profile/profile-search-target'; 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 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'>; export type SectionTitleProps = Omit; @@ -46,12 +50,16 @@ 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) { +const Root = React.forwardRef(function SectionRoot( + { searchId, render, xstyle, ...rest }, + ref, +) { + const target = useSearchTarget(searchId); return 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 }), }); }); 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..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 @@ -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( @@ -136,6 +145,48 @@ 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('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/__tests__/organization-profile.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx index 657f348ba7c..823aad39570 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx @@ -24,6 +24,24 @@ it('renders the content given for the active page', () => { 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-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..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 3199d0dba9e..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.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index 9d736de54df..d16cc242a21 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'; @@ -18,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 @@ -65,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 * @@ -129,3 +138,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..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 { @@ -15,6 +17,10 @@ export interface CustomOrganizationProfilePage { path: string; href?: never; 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 7db4186450b..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,20 +39,34 @@ export const OrganizationProfileView = React.forwardRef, + m: MosaicMessages['organizationProfile'], ): 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, + 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 710176849c3..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 } 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. */ @@ -9,6 +9,8 @@ export interface ProfilePage { id: string; label: string; icon: ReactNode; + keywords?: readonly string[]; + search?: readonly ProfileSearchTarget[]; content: ReactNode; } @@ -27,15 +29,29 @@ 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); 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, + targets: page.search, + })); 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('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.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.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(); + 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( 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..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 @@ -256,4 +256,46 @@ describe('UserProfileView', () => { expect(document.querySelector('.cl-profile-nav-group')).not.toBeInTheDocument(); }); }); + + 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, showSearch: true, searchSections: { security: ['password', 'activeDevices'] } }); + + 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: 'Active devices' })); + + expect(onPageChange).toHaveBeenCalledWith('security'); + }); + + 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

}], + }); + + 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..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-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..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 c110e120037..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 e6a6ffc5475..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-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..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 5e9a48f5ec2..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 ( - + {hasRows ? ( - + ; /** 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 @@ -105,6 +109,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 * }) { const model = useUserProfileUserModel(); const builtInPages = useUserProfilePages(); + const searchSections = useUserProfileSearchModel(); if (model.status === 'loading') { return fallback ?? null; @@ -155,6 +167,8 @@ export function UserProfileSurface({ initialPage: defaultPage ?? pageOrder?.[0] ?? 'account', customPages, pageOrder, + showSearch, + searchSections, pages: { account: , security: , @@ -220,3 +234,5 @@ function CombinedUserProfileView({ ); } + +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..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 { @@ -37,6 +38,10 @@ export interface CustomProfilePage { path: string; href?: never; 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 19845659935..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,21 +82,47 @@ 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 - ? { 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, + 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/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 54a77360a8b..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 @@ -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,50 @@ export default function UserProfileLivePage() { to use the live harness.

) : null} - Loading…

} /> + Loading…

} + customPages={[ + { + path: 'usage', + label: 'Usage', + icon: ( + + ), + keywords: ['quota'], + search: [ + { id: 'rate-limits', label: 'Rate limits', keywords: ['throttle', 'requests'] }, + { id: 'seats', label: 'Seats', keywords: ['members'] }, + ], + content: , + }, + ]} + />
); } + +function UsagePage() { + return ( +
+ } + > +

Rate limits

+

1,000 requests per minute.

+
+ } + > +

Seats

+

4 of 10 seats used.

+
+
+ ); +} 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/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..eefddd883b7 100644 --- a/packages/swingset/src/stories/user-profile.stories.tsx +++ b/packages/swingset/src/stories/user-profile.stories.tsx @@ -15,21 +15,36 @@ 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() { - const { activePage, setActivePage, pages } = useUserProfileFixture(); +export function Default(props: Record) { + const { activePage, setActivePage, pages, searchSections, customPages } = useUserProfileFixture(); return ( ); } @@ -39,7 +54,7 @@ export function Default() { * 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 @@ -47,6 +62,8 @@ export function Overlay() { @@ -55,7 +72,7 @@ export function Overlay() { } export function Fullscreen() { - const { activePage, setActivePage, pages } = useUserProfileFixture(); + const { activePage, setActivePage, pages, searchSections, customPages } = useUserProfileFixture(); return ( }>Manage account @@ -63,6 +80,8 @@ export function Fullscreen() {