Skip to content
Open
2 changes: 2 additions & 0 deletions .changeset/mosaic-profile-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 3 additions & 0 deletions packages/mosaic/src/components/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
164 changes: 164 additions & 0 deletions packages/mosaic/src/components/profile/profile-search-target.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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
* <SearchTarget id='seats' render={<section className='card' />}>
* <h3>Seats</h3>
* </SearchTarget>
* ```
*/
export const SearchTarget = React.forwardRef<HTMLDivElement, SearchTargetProps>(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<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 { 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 const SearchPageContext = React.createContext<string | null>(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<HTMLHeadingElement | null>;
}) {
const [query, setQuery] = React.useState('');
const targetsRef = React.useRef(new Map<string, HTMLElement>());
const [reveal, setReveal] = React.useState<Reveal | null>(null);
const revealedRef = React.useRef<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 || revealedRef.current === reveal) {
return;
}
revealedRef.current = reveal;
const key = reveal.target === null ? null : targetKey(reveal.page, reveal.target);
const element = key === null ? undefined : targetsRef.current.get(key);
if (key === null || !element) {
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<typeof useProfileSearch>;

function prefersReducedMotion() {
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
96 changes: 96 additions & 0 deletions packages/mosaic/src/components/profile/profile-search.styles.ts
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,
},
});
Loading
Loading