Repository navigation
feat(mosaic): add settings search to UserProfile and OrganizationProfile #10492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5c6d5f9
523b9cb
d87af20
ed5eaab
7005890
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,286 @@ | ||
| 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 | ||
| * <SearchTarget label='Seats' keywords={['members']} render={<section className='card' />}> | ||
| * <h3>Seats</h3> | ||
| * </SearchTarget> | ||
| * ``` | ||
| */ | ||
| export const SearchTarget = React.forwardRef<HTMLDivElement, SearchTargetProps>(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 ? ( | ||
| <div | ||
| data-search-target={options.id ?? options.label} | ||
| data-label={options.label} | ||
| data-keywords={JSON.stringify(options.keywords ?? [])} | ||
| /> | ||
| ) : null; | ||
|
|
||
| function props<Rest extends Record<string, unknown>>(styleProps: ReturnType<typeof stylex.props>, 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 ( | ||
| <div | ||
| key={search.highlightedTarget} | ||
| aria-hidden | ||
| onAnimationEnd={search.clearHighlight} | ||
| {...mergeStyleProps(themeProps('profile-search-highlight'), stylex.props(reset.base, styles.highlight))} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| 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( | ||
| <SearchIndexingContext.Provider value> | ||
| <SearchPageContext.Provider value={value}> | ||
| <div data-search-page={value}> | ||
| <SearchIndexBoundary>{children}</SearchIndexBoundary> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Contain suspension within each indexed page Could we wrap the indexed children in a Suspense boundary as well? SearchIndexBoundary catches errors, but not suspension. I reproduced this with an inactive custom page backed by React.lazy: focusing Search mounts that page here, its pending import reaches the application’s outer Suspense boundary, and the entire profile (including Search) is replaced by the loading fallback. The normal Tabs.Panel path already has a Suspense boundary through Freeze, but this indexing path bypasses it. A per-page Suspense fallback would let the other pages remain searchable while this one loads. |
||
| </div> | ||
| </SearchPageContext.Provider> | ||
| </SearchIndexingContext.Provider>, | ||
| 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<string | null>(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<HTMLElement>('[data-search-target]')).flatMap(marker => { | ||
| const page = marker.closest<HTMLElement>('[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<HTMLHeadingElement | null>; | ||
| }) { | ||
| 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<string, HTMLElement>()); | ||
| const [reveal, setReveal] = React.useState<Reveal | null>(null); | ||
| const [highlightedTarget, setHighlightedTarget] = React.useState<string | null>(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<typeof useProfileSearch>; | ||
|
|
||
| function prefersReducedMotion() { | ||
| return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Keep portals from indexed pages out of the visible UI
The detached container does not isolate portals created by these children: they still mount into their own destination. I reproduced this with an inactive custom page that renders a dialog through createPortal(..., document.body) alongside a SearchTarget. The dialog is initially absent, then appears just from focusing Search, without navigating to that page. Only descendants of SearchTarget are skipped, so sibling portal content still mounts. Could we isolate portal content during indexing, or avoid mounting arbitrary page bodies to discover their targets? Otherwise enabling showSearch can display UI belonging to inactive pages.