loading state

This commit is contained in:
Nate Kelley 2025-10-07 09:32:56 -06:00
parent 75e0d1bb42
commit 3e606bff40
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
10 changed files with 123 additions and 62 deletions

View File

@ -8,8 +8,6 @@ export const IndeterminateLinearLoader: React.FC<{
trackColor?: string;
valueColor?: string;
}> = React.memo(({ className = '', trackColor, valueColor, style, height = 2 }) => {
// const { styles, cx } = useStyles();
return (
<div
className={`h-1.5 w-full overflow-hidden bg-gray-200 ${className}`}

View File

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

View File

@ -1,21 +1,27 @@
import { Command } from 'cmdk';
import React from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { cn } from '@/lib/utils';
import type { SearchModalContentProps } from './search-modal.types';
export const SearchInput: React.FC<
Pick<SearchModalContentProps, 'onSearchChange' | 'placeholder' | 'filterContent'> & {
Pick<
SearchModalContentProps,
'placeholder' | 'filterContent' | 'isModalOpen' | 'onChangeValue'
> & {
searchValue: string;
}
> = React.memo(({ searchValue, onSearchChange, placeholder, filterContent }) => {
> = React.memo(({ searchValue, onChangeValue, placeholder, filterContent, isModalOpen }) => {
const debouncedAutoFocus = useDebounce(isModalOpen, { wait: 100 });
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
onValueChange={onChangeValue}
autoFocus={debouncedAutoFocus}
/>
{filterContent}
</div>

View File

@ -0,0 +1,16 @@
import { Command } from 'cmdk';
import { cn } from '@/lib/utils';
import { IndeterminateLinearLoader } from '../../loaders';
export const SearchLoading = ({ loading = false }: { loading?: boolean }) => {
return (
<Command.Loading className="w-full border-b swag relative">
{loading && (
<IndeterminateLinearLoader
className={cn('w-full absolute top-0 left-0 right-0')}
height={0.5}
/>
)}
</Command.Loading>
);
};

View File

@ -1,5 +1,16 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../modal/ModalBase';
import { SearchModalContent } from './SearchModalContent';
import type { SearchModalProps } from './search-modal.types';
export const SearchModal = () => {
return <div>SearchModal</div>;
export const SearchModal: React.FC<SearchModalProps> = ({ open, onClose, ...props }) => {
return (
<Dialog open={open} onOpenChange={(x) => x}>
<DialogTitle hidden>{'Search Modal'}</DialogTitle>
<DialogDescription hidden>{'This modal is used to search for items'}</DialogDescription>
<DialogContent showClose={false} className="overflow-hidden max-w-fit max-h-fit">
<SearchModalContent {...props} isModalOpen={open} />
</DialogContent>
</Dialog>
);
};

View File

@ -1,10 +1,10 @@
import { faker } from '@faker-js/faker';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { fn } from 'storybook/test';
import HouseIcon from '@/components/ui/icons/NucleoIconOutlined/house';
import { createDayjsDate } from '@/lib/date';
import { SearchModal } from './SearchModal';
import { SearchModalContent } from './SearchModalContent';
import type { SearchItem, SearchItems } from './search-modal.types';
@ -52,46 +52,6 @@ const createMockSearchItems = (includeSecondary: boolean): SearchItems[] => [
})),
],
},
// {
// icon: <HouseIcon />,
// label: 'Search Result 1',
// secondaryLabel: 'This is a secondary label',
// tertiaryLabel: createDayjsDate(new Date()).format('LL'),
// value: 'result-1',
// keywords: ['search', 'result', 'example'],
// type: 'item',
// },
// {
// icon: <HouseIcon />,
// label: 'Document',
// secondaryLabel: 'A document file',
// tertiaryLabel: createDayjsDate(new Date()).format('LL'),
// value: 'document-1',
// keywords: ['document', 'file', 'pdf'],
// type: 'item',
// onSelect: fn(),
// },
// {
// icon: <HouseIcon />,
// label: 'Dashboard',
// secondaryLabel: 'Analytics dashboard',
// tertiaryLabel: createDayjsDate(new Date()).format('LL'),
// value: 'dashboard-1',
// keywords: ['dashboard', 'analytics', 'charts'],
// type: 'item',
// onSelect: fn(),
// },
// ...Array.from({ length: 10 }).map<SearchItem>((_, index) => ({
// icon: <HouseIcon />,
// label: `Dashboard ${index} with a super long label that will be truncated`,
// secondaryLabel: `Analytics dashboard ${index}`,
// tertiaryLabel: createDayjsDate(new Date()).format('LL'),
// value: `testing-${index}`,
// keywords: ['dashboard', 'analytics', 'charts'],
// type: 'item' as const,
// onSelect: fn(),
// })),
];
export const Default: Story = {
@ -110,6 +70,7 @@ export const Default: Story = {
const [addInSecondaryLabel, setAddInSecondaryLabel] = useState(false);
const [open, setOpen] = useState(false);
const [secondaryContent, setSecondaryContent] = useState<React.ReactNode>(null);
const [loading, setLoading] = useState(false);
const onViewSearchItem = (item: SearchItem) => {
setSecondaryContent(<div>Secondary Content {item.label}</div>);
@ -122,6 +83,9 @@ export const Default: Story = {
useHotkeys('x', () => {
setAddInSecondaryLabel((x) => !x);
});
useHotkeys('l', () => {
setLoading((x) => !x);
});
return (
<SearchModalContent
@ -135,6 +99,64 @@ export const Default: Story = {
}}
openSecondaryContent={open}
secondaryContent={secondaryContent}
loading={loading}
/>
);
},
};
export const SearchModalStory: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
const [openSecondaryContent, setOpenSecondaryContent] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [addInSecondaryLabel, setAddInSecondaryLabel] = useState(false);
const [secondaryContent, setSecondaryContent] = useState<React.ReactNode>(null);
const [loading, setLoading] = useState(false);
const onViewSearchItem = (item: SearchItem) => {
setSecondaryContent(<div>Secondary Content {item.label}</div>);
setOpen(true);
setOpenSecondaryContent(true);
setAddInSecondaryLabel(true);
};
const searchItems = createMockSearchItems(addInSecondaryLabel);
useHotkeys('x', () => {
setAddInSecondaryLabel((x) => !x);
});
useHotkeys('l', () => {
setLoading((x) => !x);
});
useHotkeys(
'o',
(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
},
{
preventDefault: true,
}
);
return (
<SearchModal
{...args}
searchItems={searchItems}
onViewSearchItem={onViewSearchItem}
value={searchValue}
onChangeValue={(v) => {
setOpenSecondaryContent(false);
setSearchValue(v);
}}
openSecondaryContent={openSecondaryContent}
secondaryContent={secondaryContent}
loading={loading}
open={open}
onClose={() => setOpen(false)}
/>
);
},

View File

@ -1,15 +1,15 @@
import { Command } from 'cmdk';
import React, { useRef, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import React, { useRef } from 'react';
import { SearchEmptyState } from './SearchEmptyState';
import { SearchFooter } from './SearchFooter';
import { SearchInput } from './SearchInput';
import { SearchModalContentItems } from './SearchModalContentItems';
import { SearchLoading } from './SearchLoading';
import { SearchModalItemsContainer } from './SearchModalItemsContainer';
import type { SearchItem, SearchModalContentProps } from './search-modal.types';
import { useViewSearchItem } from './useViewSearchItem';
export const SearchModalContent = <M, T extends string>({
isModalOpen,
searchItems,
onChangeValue,
onSelect,
@ -22,6 +22,8 @@ export const SearchModalContent = <M, T extends string>({
value: searchValue,
secondaryContent,
openSecondaryContent,
shouldFilter = true,
filter,
}: SearchModalContentProps<M, T>) => {
const { handleKeyDown, focusedValue, setFocusedValue } = useViewSearchItem({
searchItems,
@ -29,10 +31,6 @@ export const SearchModalContent = <M, T extends string>({
});
const isCommandKeyPressedRef = useRef(false);
const onSearchChangePreflight = (searchValue: string) => {
onChangeValue(searchValue);
};
const handleKeyDownGlobal = (e: React.KeyboardEvent) => {
if (e.metaKey || e.ctrlKey) {
isCommandKeyPressedRef.current = true;
@ -60,15 +58,19 @@ export const SearchModalContent = <M, T extends string>({
onValueChange={setFocusedValue}
onKeyDown={handleKeyDownGlobal}
onKeyUp={handleKeyUpGlobal}
shouldFilter={true}
shouldFilter={shouldFilter}
filter={filter}
>
<SearchInput
searchValue={searchValue}
onSearchChange={onSearchChangePreflight}
onChangeValue={onChangeValue}
filterContent={filterContent}
placeholder={placeholder}
isModalOpen={isModalOpen}
/>
<div className="border-b" />
<SearchLoading loading={loading} />
<SearchModalItemsContainer
searchItems={searchItems}
secondaryContent={secondaryContent}
@ -76,6 +78,7 @@ export const SearchModalContent = <M, T extends string>({
onSelectGlobal={onSelectGlobal}
onViewSearchItem={onViewSearchItem}
/>
<SearchEmptyState emptyState={emptyState} />
<SearchFooter />
</Command>

View File

@ -2,6 +2,7 @@ import { useCommandState } from 'cmdk';
import { AnimatePresence, motion } from 'framer-motion';
import React from 'react';
import { cn } from '@/lib/utils';
import { SearchLoading } from './SearchLoading';
import { SearchModalContentItems } from './SearchModalContentItems';
import type { SearchItem, SearchItems, SearchModalContentProps } from './search-modal.types';
@ -23,7 +24,7 @@ export const SearchModalItemsContainer = <M, T extends string>({
const hasResults = useCommandState((x) => x.filtered.count) > 0;
return (
<div className={cn('flex w-full overflow-hidden flex-1', !hasResults && 'hidden')}>
<div className={cn('flex w-full overflow-hidden flex-1 relative', !hasResults && 'hidden')}>
<motion.div
className="overflow-y-auto flex flex-col shrink-0"
initial={false}

View File

@ -1,3 +1,5 @@
import type { Command } from 'cmdk';
export type SearchItem<M = unknown, T extends string = string> = {
icon?: React.ReactNode;
label: string | React.ReactNode;
@ -30,6 +32,7 @@ export type SearchItems<M = unknown, T extends string = string> =
| SearchItemSeperator;
export type SearchModalContentProps<M = unknown, T extends string = string> = {
isModalOpen: boolean;
value: string;
filterDropdownContent?: React.ReactNode;
filterContent?: React.ReactNode;
@ -42,7 +45,7 @@ export type SearchModalContentProps<M = unknown, T extends string = string> = {
loading?: boolean;
secondaryContent?: React.ReactNode | null;
openSecondaryContent?: boolean; //if undefined it will close and open with the secondary content
};
} & Pick<React.ComponentProps<typeof Command>, 'filter' | 'shouldFilter'>;
export type SearchModalProps = SearchModalContentProps & {
open: boolean;