Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
163 changes: 163 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,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
* <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 [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,
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