mirror of https://github.com/buster-so/buster.git
search modal content
This commit is contained in:
parent
108d0a5a17
commit
0d98535936
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
import React from 'react';
|
||||
|
||||
export const SearchModal = () => {
|
||||
return <div>SearchModal</div>;
|
||||
};
|
|
@ -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>,
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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" />;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './SearchModal';
|
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue