feat(mobile): search page ui (#8012)

feat(mobile): search page ui

fix(core): quick search tags performance issue
This commit is contained in:
CatsJuice
2024-08-29 09:05:23 +00:00
parent 5e8683c9be
commit f1bb1fc9b8
26 changed files with 731 additions and 50 deletions
@@ -9,13 +9,16 @@ import {
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, useCallback } from 'react';
import { forwardRef, type ReactNode, useCallback } from 'react';
import * as styles from './styles.css';
import { DocCardTags } from './tag';
export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
meta: DocMeta;
meta: {
id: DocMeta['id'];
title?: ReactNode;
} & { [key: string]: any };
showTags?: boolean;
}
@@ -1,5 +1,6 @@
export * from './app-tabs';
export * from './doc-card';
export * from './page-header';
export * from './search-button';
export * from './search-input';
export * from './search-result';
export * from './workspace-selector';
@@ -1,17 +0,0 @@
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { SearchIcon } from '@blocksuite/icons/rc';
import * as styles from './styles.css';
export const SearchButton = () => {
const t = useI18n();
return (
<WorkbenchLink to="/search">
<div className={styles.search}>
<SearchIcon className={styles.icon} />
{t['Quick search']()}
</div>
</WorkbenchLink>
);
};
@@ -1,25 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const search = style({
width: '100%',
height: 44,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 8px',
background: cssVarV2('layer/background/primary'),
borderRadius: 10,
color: cssVarV2('text/secondary'),
fontSize: 17,
fontWeight: 400,
lineHeight: '22px',
letterSpacing: -0.43,
});
export const icon = style({
width: 20,
height: 20,
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,110 @@
import { useAutoFocus } from '@affine/component';
import { SearchIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { getSvgPath } from 'figma-squircle';
import { debounce } from 'lodash-es';
import {
type FormEventHandler,
forwardRef,
type HTMLProps,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './style.css';
export interface SearchInputProps
extends Omit<HTMLProps<HTMLInputElement>, 'onInput'> {
value?: string;
height?: number;
cornerRadius?: number;
cornerSmoothing?: number;
debounce?: number;
onInput?: (value: string) => void;
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
function SearchInput(
{
className,
style,
placeholder = 'Search',
value = '',
height = 44,
cornerRadius = 10,
cornerSmoothing = 0.6,
autoFocus,
debounce: debounceDuration,
onInput,
onClick,
...attrs
},
upstreamRef
) {
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(window.innerWidth);
const [inputValue, setInputValue] = useState(value);
const clipPath = useMemo(
() => getSvgPath({ width, height, cornerRadius, cornerSmoothing }),
[cornerRadius, cornerSmoothing, height, width]
);
useEffect(() => {
setWidth(containerRef.current?.offsetWidth ?? 0);
}, []);
const emitValue = useMemo(() => {
const cb = (value: string) => onInput?.(value);
return debounceDuration ? debounce(cb, debounceDuration) : cb;
}, [debounceDuration, onInput]);
const handleInput: FormEventHandler<HTMLInputElement> = useCallback(
e => {
const value = e.currentTarget.value;
setInputValue(value);
emitValue(value);
},
[emitValue]
);
const inputRef = (el: HTMLInputElement | null) => {
focusRef.current = el;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(el);
} else {
upstreamRef.current = el;
}
}
};
return (
<div
onClick={onClick}
ref={containerRef}
className={clsx(styles.wrapper, className)}
style={{ ...style, height, clipPath: `path('${clipPath}')` }}
>
<div className={styles.prefixIcon}>
<SearchIcon width="20" height="20" />
</div>
<input
ref={inputRef}
{...attrs}
value={inputValue}
onInput={handleInput}
className={styles.input}
/>
{!inputValue ? (
<div className={styles.placeholder}>{placeholder}</div>
) : null}
</div>
);
}
);
@@ -0,0 +1,43 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const wrapper = style({
position: 'relative',
backgroundColor: cssVarV2('layer/background/primary'),
viewTransitionName: 'mobile-search-input',
});
export const prefixIcon = style({
position: 'absolute',
width: 36,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2('icon/primary'),
pointerEvents: 'none',
});
export const input = style({
padding: '11px 8px 11px 36px',
width: '100%',
height: '100%',
outline: 'none',
border: 'none',
fontWeight: 400,
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
});
export const placeholder = style([
input,
{
position: 'absolute',
left: 0,
top: 0,
pointerEvents: 'none',
color: cssVarV2('text/secondary'),
},
]);
@@ -0,0 +1,2 @@
export * from './search-res-label';
export * from './universal-item';
@@ -0,0 +1,15 @@
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
import { HighlightText } from '@affine/core/modules/quicksearch/views/highlight-text';
import { isI18nString, useI18n } from '@affine/i18n';
export interface SearchResLabelProps {
item: QuickSearchItem;
}
export const SearchResLabel = ({ item }: SearchResLabelProps) => {
const i18n = useI18n();
const text = !isI18nString(item.label)
? i18n.t(item.label.title)
: i18n.t(item.label);
return <HighlightText text={text} start="<b>" end="</b>" />;
};
@@ -0,0 +1,43 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 12,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
height: 44,
color: 'unset',
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
':focus': { color: 'unset' },
});
export const iconWrapper = style({
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: 24,
color: cssVarV2('icon/primary'),
});
export const content = style({
width: 0,
flex: 1,
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const suffixIcon = style({
color: cssVarV2('icon/secondary'),
});
@@ -0,0 +1,32 @@
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { SearchResLabel } from './search-res-label';
import * as styles from './universal-item.css';
export interface UniversalSearchResultItemProps {
id: string;
item: QuickSearchItem;
category: 'tag' | 'collection';
}
export const UniversalSearchResultItem = ({
id,
item,
category,
}: UniversalSearchResultItemProps) => {
return (
<WorkbenchLink to={`/${category}/${id}`} className={styles.item}>
<div className={styles.iconWrapper}>
{item.icon &&
(typeof item.icon === 'function' ? <item.icon /> : item.icon)}
</div>
<div className={styles.content}>
<SearchResLabel item={item} />
</div>
<ArrowRightSmallIcon fontSize="16px" className={styles.suffixIcon} />
</WorkbenchLink>
);
};