search modal content

This commit is contained in:
Nate Kelley 2025-10-06 14:25:11 -06:00
parent 108d0a5a17
commit 0d98535936
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
9 changed files with 344 additions and 0 deletions

View File

@ -0,0 +1,13 @@
import { Command } from 'cmdk';
import React from 'react';
import type { SearchModalContentProps } from './search-modal.types';
export const SearchEmptyState: React.FC<Pick<SearchModalContentProps, 'emptyState'>> = React.memo(
({ emptyState }) => {
return (
<Command.Empty className="text-gray-light h-full w-full flex-1 flex justify-center items-center text-lg">
{emptyState}
</Command.Empty>
);
}
);

View File

@ -0,0 +1,42 @@
import React from 'react';
import ArrowsOppositeDirectionY from '@/components/ui/icons/NucleoIconOutlined/arrows-opposite-direction-y';
import CommandIcon from '@/components/ui/icons/NucleoIconOutlined/command';
import ReturnKey from '@/components/ui/icons/NucleoIconOutlined/return-key';
export const SearchFooter: React.FC = React.memo(() => {
const footerItems = [
{
text: 'Select',
icons: [<ArrowsOppositeDirectionY key="arrows-opposite-direction-y" />],
},
{
text: 'Open',
icons: [<ReturnKey key="return-key" />],
},
{
text: 'Open in new tab',
icons: [<CommandIcon key="command-icon" />, <ReturnKey key="return-key" />],
},
];
return (
<div className="flex space-x-4.5 border-t min-h-12 items-center px-6">
{footerItems.map((item, index) => (
<FooterItem key={index} text={item.text} icons={item.icons} />
))}
</div>
);
});
const FooterItem = ({ text, icons }: { text: string; icons: React.ReactNode[] }) => {
return (
<div className="text-xs text-gray-light space-x-1 flex items-center justify-between hover:text-foreground transition-all duration-100">
<div className="flex items-center space-x-0.5">
{icons.map((icon, index) => (
<div key={index}>{icon}</div>
))}
</div>
<span className="leading-none">{text}</span>
</div>
);
};

View File

@ -0,0 +1,23 @@
import { Command } from 'cmdk';
import React from 'react';
import { cn } from '@/lib/utils';
import type { SearchModalContentProps } from './search-modal.types';
export const SearchInput: React.FC<
Pick<SearchModalContentProps, 'onSearchChange' | 'placeholder' | 'filterContent'> & {
searchValue: string;
}
> = React.memo(({ searchValue, onSearchChange, placeholder, filterContent }) => {
return (
<div className="flex min-h-12 items-center space-x-3 justify-between mx-6">
<Command.Input
className={cn('text-md placeholder:text-gray-light')}
value={searchValue}
placeholder={placeholder}
onValueChange={onSearchChange}
autoFocus
/>
{filterContent}
</div>
);
});

View File

@ -0,0 +1,5 @@
import React from 'react';
export const SearchModal = () => {
return <div>SearchModal</div>;
};

View File

@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { SearchModalContent } from './SearchModalContent';
import type { SearchItem } from './search-modal.types';
const meta: Meta<typeof SearchModalContent> = {
title: 'UI/Search/SearchModalContent',
component: SearchModalContent,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof meta>;
const mockSearchItems: SearchItem[] = [
{
icon: '🔍',
label: 'Search Result 1',
secondaryLabel: 'This is a secondary label',
value: 'result-1',
keywords: ['search', 'result', 'example'],
type: 'item',
},
{
icon: '📄',
label: 'Document',
secondaryLabel: 'A document file',
value: 'document-1',
keywords: ['document', 'file', 'pdf'],
type: 'item',
},
{
icon: '📊',
label: 'Dashboard',
secondaryLabel: 'Analytics dashboard',
value: 'dashboard-1',
keywords: ['dashboard', 'analytics', 'charts'],
type: 'item',
},
...Array.from({ length: 10 }).map<SearchItem>((_, index) => ({
icon: '📊',
label: `Dashboard ${index}`,
secondaryLabel: `Analytics dashboard ${index}`,
value: `testing-${index}`,
keywords: ['dashboard', 'analytics', 'charts'],
type: 'item' as const,
})),
];
export const Default: Story = {
args: {
searchItems: mockSearchItems,
onSearchChange: fn(),
onSelect: fn(),
onViewSearchItem: fn(),
defaulSearchValue: 'it',
emptyState: 'No results found',
placeholder: 'Search for something',
filterContent: <div>Filter</div>,
filterDropdownContent: <div>Filter Dropdown</div>,
},
};

View File

@ -0,0 +1,51 @@
import { Command } from 'cmdk';
import React, { useState } from 'react';
import { SearchEmptyState } from './SearchEmptyState';
import { SearchFooter } from './SearchFooter';
import { SearchInput } from './SearchInput';
import { SearchModalContentItems } from './SearchModalContentItems';
import type { SearchItem, SearchModalContentProps } from './search-modal.types';
export const SearchModalContent = <M, T extends string>({
searchItems,
onSearchChange,
onSelect,
onViewSearchItem,
emptyState,
defaulSearchValue = '',
filterContent,
placeholder,
filterDropdownContent,
loading,
secondaryContent,
openSecondaryContent,
}: SearchModalContentProps<M, T>) => {
const [searchValue, setSearchValue] = useState<string>(defaulSearchValue);
const onSearchChangePreflight = (searchValue: string) => {
setSearchValue(searchValue);
onSearchChange(searchValue);
};
return (
<Command
className="min-w-[650px] min-h-[450px] max-h-[75vh] bg-background flex flex-col"
value={searchValue}
>
<SearchInput
searchValue={searchValue}
onSearchChange={onSearchChangePreflight}
filterContent={filterContent}
placeholder={placeholder}
/>
<div className="border-b" />
<SearchModalContentItems
searchItems={searchItems}
onSelect={onSelect}
onViewSearchItem={onViewSearchItem}
/>
<SearchEmptyState emptyState={emptyState} />
<SearchFooter />
</Command>
);
};

View File

@ -0,0 +1,96 @@
import { Command } from 'cmdk';
import { cn } from '@/lib/utils';
import type {
SearchItem,
SearchItemGroup,
SearchItemSeperator,
SearchItems,
SearchModalContentProps,
} from './search-modal.types';
export const SearchModalContentItems = <M, T extends string>({
searchItems,
onSelect,
onViewSearchItem,
}: Pick<SearchModalContentProps<M, T>, 'searchItems' | 'onSelect' | 'onViewSearchItem'>) => {
return (
<Command.List className="flex flex-col overflow-y-auto flex-1">
{searchItems.map((item, index) => (
<ItemsSelecter key={keyExtractor(item, index)} item={item} />
))}
</Command.List>
);
};
const keyExtractor = <M, T extends string>(item: SearchItems<M, T>, index: number): string => {
if (item.type === 'item') {
return String(item.value);
}
return item.type + index;
};
const ItemsSelecter = <M, T extends string>({ item }: { item: SearchItems<M, T> }) => {
const type = item.type;
if (type === 'item') {
return <SearchItemComponent {...item} />;
}
if (type === 'group') {
return <SearchItemGroupComponent item={item} />;
}
if (type === 'seperator') {
return <SearchItemSeperatorComponent item={item} />;
}
const _exhaustiveCheck: never = type;
return null;
};
const SearchItemComponent = <M, T extends string>({
value,
label,
secondaryLabel,
tertiaryLabel,
icon,
onSelect,
loading,
disabled,
}: SearchItem<M, T>) => {
return (
<Command.Item
className={cn(
'min-h-9 px-4 flex items-center',
secondaryLabel && 'min-h-13.5',
'data-[selected=true]:bg-item-hover data-[selected=true]:text-foreground',
!disabled ? 'cursor-pointer' : 'cursor-not-allowed'
)}
value={value}
disabled={disabled}
onSelect={() => {
onSelect?.();
}}
>
{label}
</Command.Item>
);
};
const SearchItemGroupComponent = <M, T extends string>({
item,
}: {
item: SearchItemGroup<M, T>;
}) => {
return (
<Command.Group>
{item.items.map((item, index) => (
<ItemsSelecter key={keyExtractor(item, index)} item={item} />
))}
</Command.Group>
);
};
const SearchItemSeperatorComponent = ({ item }: { item: SearchItemSeperator }) => {
return <Command.Separator className="border-t w-full" />;
};

View File

@ -0,0 +1 @@
export * from './SearchModal';

View File

@ -0,0 +1,48 @@
export type SearchItem<M = unknown, T extends string = string> = {
icon?: React.ReactNode;
label: string | React.ReactNode;
secondaryLabel?: string | React.ReactNode; //displayed below the label
tertiaryLabel?: string | React.ReactNode; //displayed to the right of the label
value: T;
keywords?: string[];
meta?: M;
onSelect?: () => void;
loading?: boolean;
disabled?: boolean;
type: 'item';
};
export type SearchItemGroup<M = unknown, T extends string = string> = {
label: string | React.ReactNode;
items: SearchItem<M, T>[];
type: 'group';
};
export type SearchItemSeperator = {
type: 'seperator';
};
export type SearchItems<M = unknown, T extends string = string> =
| SearchItem<M, T>
| SearchItemGroup<M, T>
| SearchItemSeperator;
export type SearchModalContentProps<M = unknown, T extends string = string> = {
defaulSearchValue?: string;
filterDropdownContent?: React.ReactNode;
filterContent?: React.ReactNode;
searchItems: SearchItems<M, T>[];
onSearchChange: (searchValue: string) => void;
onSelect: (item: SearchItem<M, T>) => void;
onViewSearchItem: (item: SearchItem<M, T>) => void;
emptyState?: React.ReactNode | string;
placeholder?: string;
loading?: boolean;
secondaryContent?: React.ReactNode | null;
openSecondaryContent?: boolean; //if undefined it will close and open with the secondary content
};
export type SearchModalProps = SearchModalContentProps & {
open: boolean;
onClose: () => void;
};