mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Redesign search dialog style
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Redesign search dialog style
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user