UI: Redesign search dialog style

This commit is contained in:
Fuma Nama
2025-06-14 00:08:58 +08:00
parent 82fc4c8587
commit b675728ed7
11 changed files with 128 additions and 119 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Redesign search dialog style
+3 -5
View File
@@ -52,10 +52,8 @@ export default function CustomSearchDialog(props: SharedProps) {
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
<SearchDialogFooter className="flex flex-row">
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
<SearchDialogFooter className="flex flex-row items-center">
<TagsList tag={tag} onTagChange={setTag} allowClear>
<TagsListItem value="ui">Framework</TagsListItem>
<TagsListItem value="headless">Core</TagsListItem>
@@ -65,7 +63,7 @@ export default function CustomSearchDialog(props: SharedProps) {
<a
href="https://orama.com"
rel="noreferrer noopener"
className="ms-auto text-xs text-fd-muted-foreground"
className="ms-auto text-xs text-nowrap text-fd-muted-foreground"
>
Search powered by Orama
</a>
+1 -3
View File
@@ -42,9 +42,7 @@ export default function CustomSearchDialog(props: SharedProps) {
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
<SearchDialogFooter>
<a
href="https://algolia.com"
+1 -3
View File
@@ -34,9 +34,7 @@ export default function DefaultSearchDialog(props: SharedProps) {
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
</SearchDialogContent>
</SearchDialog>
);
@@ -43,9 +43,7 @@ export default function CustomSearchDialog(props: SharedProps) {
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
<SearchDialogFooter>
<a
href="https://orama.com"
+1 -3
View File
@@ -42,9 +42,7 @@ export default function DefaultSearchDialog(props: SharedProps) {
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
</SearchDialogContent>
</SearchDialog>
);
+2 -2
View File
@@ -21,8 +21,8 @@
--animate-fd-fade-in: fd-fade-in 300ms ease;
--animate-fd-fade-out: fd-fade-out 300ms ease;
--animate-fd-dialog-in: fd-dialog-in 240ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-fd-dialog-out: fd-dialog-out 240ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-fd-dialog-in: fd-dialog-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-fd-dialog-out: fd-dialog-out 260ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-fd-popover-in: fd-popover-in 150ms ease;
--animate-fd-popover-out: fd-popover-out 150ms ease;
@@ -65,16 +65,15 @@ export default function AlgoliaSearchDialog({
locale,
...searchOptions,
});
const defaultItems = useMemo<SortedResult[]>(
() =>
links.map(([name, link]) => ({
type: 'page',
id: name,
content: name,
url: link,
})),
[links],
);
const defaultItems = useMemo<SortedResult[] | null>(() => {
if (links.length === 0) return null;
return links.map(([name, link]) => ({
type: 'page',
id: name,
content: name,
url: link,
}));
}, [links]);
useOnChange(defaultTag, (v) => {
setTag(v);
@@ -96,12 +95,9 @@ export default function AlgoliaSearchDialog({
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
{query.data === 'empty' && defaultItems.length > 0 && (
<SearchDialogList items={defaultItems} />
)}
<SearchDialogList
items={query.data !== 'empty' ? query.data : defaultItems}
/>
</SearchDialogContent>
<SearchDialogFooter>
{tags.length > 0 ? (
@@ -81,16 +81,15 @@ export default function DefaultSearchDialog({
delayMs,
},
);
const defaultItems = useMemo<SortedResult[]>(
() =>
links.map(([name, link]) => ({
type: 'page',
id: name,
content: name,
url: link,
})),
[links],
);
const defaultItems = useMemo<SortedResult[] | null>(() => {
if (links.length === 0) return null;
return links.map(([name, link]) => ({
type: 'page',
id: name,
content: name,
url: link,
}));
}, [links]);
useOnChange(defaultTag, (v) => {
setTag(v);
@@ -110,12 +109,9 @@ export default function DefaultSearchDialog({
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
{query.data === 'empty' && defaultItems.length > 0 && (
<SearchDialogList items={defaultItems} />
)}
<SearchDialogList
items={query.data !== 'empty' ? query.data : defaultItems}
/>
</SearchDialogContent>
<SearchDialogFooter>
{tags.length > 0 && (
@@ -75,16 +75,16 @@ export default function OramaSearchDialog({
tag,
});
const defaultItems = useMemo<SortedResult[]>(
() =>
links.map(([name, link]) => ({
type: 'page',
id: name,
content: name,
url: link,
})),
[links],
);
const defaultItems = useMemo<SortedResult[] | null>(() => {
if (links.length === 0) return null;
return links.map(([name, link]) => ({
type: 'page',
id: name,
content: name,
url: link,
}));
}, [links]);
useOnChange(defaultTag, (v) => {
setTag(v);
@@ -106,12 +106,9 @@ export default function OramaSearchDialog({
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
{query.data !== 'empty' && query.data && (
<SearchDialogList items={query.data} />
)}
{query.data === 'empty' && defaultItems.length > 0 && (
<SearchDialogList items={defaultItems} />
)}
<SearchDialogList
items={query.data !== 'empty' ? query.data : defaultItems}
/>
<SearchDialogFooter>
{tags.length > 0 ? (
<TagsList tag={tag} onTagChange={setTag} allowClear={allowClear}>
+77 -52
View File
@@ -1,12 +1,6 @@
'use client';
import {
FileText,
Hash,
LoaderCircle,
Search as SearchIcon,
Text,
} from 'lucide-react';
import { FileText, Hash, Search as SearchIcon, Text, X } from 'lucide-react';
import {
type ComponentProps,
createContext,
@@ -16,6 +10,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { I18nLabel, useI18n } from '@/contexts/i18n';
@@ -121,13 +116,13 @@ export function SearchDialogInput(props: ComponentProps<'input'>) {
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={text.search}
className="w-0 flex-1 bg-transparent py-3 text-base placeholder:text-fd-muted-foreground focus-visible:outline-none"
className="w-0 flex-1 bg-transparent py-3 text-lg placeholder:text-fd-muted-foreground focus-visible:outline-none"
/>
);
}
export function SearchDialogClose({
children = 'Esc',
children = <X />,
className,
...props
}: ComponentProps<'button'>) {
@@ -136,11 +131,13 @@ export function SearchDialogClose({
return (
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className={cn(
buttonVariants({
color: 'outline',
className: 'text-xs p-1.5',
color: 'ghost',
size: 'icon',
className: 'text-fd-muted-foreground -me-1.5',
}),
className,
)}
@@ -166,10 +163,7 @@ export function SearchDialogOverlay(
return (
<DialogOverlay
{...props}
className={cn(
'fixed inset-0 z-50 bg-black/30 backdrop-blur-sm data-[state=closed]:animate-fd-fade-out data-[state=open]:animate-fd-fade-in',
props.className,
)}
className={cn('fixed inset-0 z-50', props.className)}
/>
);
}
@@ -185,7 +179,7 @@ export function SearchDialogContent({
aria-describedby={undefined}
{...props}
className={cn(
'fixed left-1/2 top-[10vh] z-50 w-[98vw] max-w-screen-sm -translate-x-1/2 rounded-lg border bg-fd-popover text-fd-popover-foreground shadow-lg data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in',
'fixed left-1/2 top-[10vh] z-50 w-[98vw] max-w-screen-sm -translate-x-1/2 rounded-xl border bg-fd-popover/50 backdrop-blur-sm text-fd-popover-foreground shadow-2xl shadow-black/30 data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in',
props.className,
)}
>
@@ -202,16 +196,16 @@ const icons = {
};
export function SearchDialogList({
items,
items = null,
Empty = () => (
<div className="py-12 text-center text-sm">
<div className="py-12 text-center text-sm text-fd-muted-foreground">
<I18nLabel label="searchNoResult" />
</div>
),
Item = (props) => <SearchDialogListItem {...props} />,
...props
}: Omit<ComponentProps<'div'>, 'children'> & {
items: ReactSortedResult[];
items: ReactSortedResult[] | null | undefined;
/**
* Renderer for empty list UI
*/
@@ -221,7 +215,10 @@ export function SearchDialogList({
*/
Item?: (props: { item: ReactSortedResult; onClick: () => void }) => ReactNode;
}) {
const [active, setActive] = useState<string | null>(items.at(0)?.id ?? null);
const ref = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<string | null>(() =>
items && items.length > 0 ? items[0].id : null,
);
const { onOpenChange } = useSearch();
const router = useRouter();
@@ -232,6 +229,8 @@ export function SearchDialogList({
};
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (!items) return;
if (e.key === 'ArrowDown' || e.key == 'ArrowUp') {
let idx = items.findIndex((item) => item.id === active);
if (idx === -1) idx = 0;
@@ -251,41 +250,73 @@ export function SearchDialogList({
});
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(() => {
const viewport = element.firstElementChild!;
element.style.setProperty(
'--fd-animated-height',
`${viewport.clientHeight}px`,
);
});
const viewport = element.firstElementChild;
if (viewport) observer.observe(viewport);
window.addEventListener('keydown', onKey);
return () => {
observer.disconnect();
window.removeEventListener('keydown', onKey);
};
}, [onKey]);
useOnChange(items, () => {
if (items.length > 0) setActive(items[0].id);
if (items && items.length > 0) {
setActive(items[0].id);
}
});
return (
<div
{...props}
ref={ref}
className={cn(
'flex max-h-[460px] flex-col overflow-y-auto border-t p-2',
'overflow-hidden h-(--fd-animated-height) transition-[height]',
props.className,
)}
style={
{
...props.style,
'--fd-animated-height': '0px',
} as object
}
>
<ListContext.Provider
value={useMemo(
() => ({
active,
setActive,
}),
[active],
<div
className={cn(
'w-full flex flex-col overflow-y-auto max-h-[460px] border-t',
!items && 'hidden',
)}
>
{items.length === 0 && Empty()}
<ListContext.Provider
value={useMemo(
() => ({
active,
setActive,
}),
[active],
)}
>
{items?.length === 0 && Empty()}
{items.map((item) => (
<Fragment key={item.id}>
{Item({ item, onClick: () => onOpen(item) })}
</Fragment>
))}
</ListContext.Provider>
{items?.map((item) => (
<Fragment key={item.id}>
{Item({ item, onClick: () => onOpen(item) })}
</Fragment>
))}
</ListContext.Provider>
</div>
</div>
);
}
@@ -316,7 +347,7 @@ export function SearchDialogListItem({
)}
aria-selected={active}
className={cn(
'flex min-h-10 select-none flex-row items-center gap-2.5 rounded-lg px-2 text-start text-sm',
'flex min-h-10 select-none flex-row items-center gap-2.5 px-3 text-start text-sm',
active && 'bg-fd-accent text-fd-accent-foreground',
className,
)}
@@ -332,24 +363,18 @@ export function SearchDialogListItem({
);
}
export function SearchDialogIcon(props: ComponentProps<'div'>) {
export function SearchDialogIcon(props: ComponentProps<'svg'>) {
const { isLoading } = useSearch();
return (
<div {...props} className={cn('relative size-4', props.className)}>
<LoaderCircle
className={cn(
'absolute size-full animate-spin text-fd-primary transition-opacity',
!isLoading && 'opacity-0',
)}
/>
<SearchIcon
className={cn(
'absolute size-full text-fd-muted-foreground transition-opacity',
isLoading && 'opacity-0',
)}
/>
</div>
<SearchIcon
{...props}
className={cn(
'size-5 text-fd-muted-foreground',
isLoading && 'animate-pulse duration-300',
props.className,
)}
/>
);
}