Skip to content

Commit

Permalink
Feature search results (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
nicklitvin authored Feb 11, 2025
1 parent 3424ed4 commit c7951da
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 0 deletions.
86 changes: 86 additions & 0 deletions src/SearchResults/SearchResults.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from '@storybook/react';

import { SearchResults } from './SearchResults';

const meta = {
title: 'Commons/SearchResults',
component: SearchResults,
tags: ['autodocs'],
args: {
data: [
{
id: '6',
type: 'type-3',
value: 'value-3-1',
},
{
id: '4',
type: 'type-2',
value: 'value-2-2',
},
{
id: '1',
type: 'type-1',
value: 'value-1-1',
},
{
id: '2',
type: 'type-1',
value: 'value-1-2',
},
{
id: '3',
type: 'type-2',
value: 'value-2-1',
},
{
id: '5',
type: 'type-2',
value: 'value-2-3',
},
],
type: 'single',
},
} satisfies Meta<typeof SearchResults>;
export default meta;

type Story = StoryObj<typeof meta>;

export const NoExtras: Story = {};

export const Stylized: Story = {
args: {
getLabel: ({ category }) => (
<h1 className="w-full bg-slate-400 font-4xl font-bold italic">
{category}
</h1>
),
getContent: ({ results }) => (
<div className="flex flex-row gap-2">
{results.map((result) => (
<p key={result.id} className="rounded-md bg-slate-400 p-2">
{result.value}
</p>
))}
</div>
),
},
};

export const Grouped: Story = {
args: {
groupBy: (item) => item.value.at(-1) ?? 'undefined',
},
};

export const Sorted: Story = {
args: {
sortBy: (a, b) => a[1].length - b[1].length,
},
};

export const NoResults: Story = {
args: {
data: [],
},
};
104 changes: 104 additions & 0 deletions src/SearchResults/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
type ComponentPropsWithoutRef,
type ElementRef,
type ReactElement,
type ReactNode,
forwardRef,
} from 'react';

import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/lib/components/ui/accordion';
import type { SearchResult } from '@/types';

export interface SearchResultsProps<Category extends string> {
data: SearchResult[];
groupBy?: (item: SearchResult) => Category;
getLabel?: ({
category,
results,
}: {
category: Category;
results: SearchResult[];
}) => ReactNode;
getContent?: ({
category,
results,
}: {
category: Category;
results: SearchResult[];
}) => ReactNode;
sortBy?: (
a: [Category, SearchResult[]],
b: [Category, SearchResult[]],
) => number;
onNoResults?: () => ReactNode;
}

export const SearchResultsNoForwardRef = <T extends string>(
{
data,
groupBy = (item) => item.type as T,
getLabel = ({ category }) => (
<h1 className="font-bold text-xl">{category}</h1>
),
getContent = ({ results }) => (
<div className="flex flex-wrap">
{results.map((result) => (
<p key={result.id} className="w-full">
{result.value}
</p>
))}
</div>
),
onNoResults = () => (
<h1 className="w-full text-center font-bold">No results</h1>
),
sortBy = (a, b) => a[0].localeCompare(b[0]),
...props
}: SearchResultsProps<T> & ComponentPropsWithoutRef<typeof Accordion>,
ref: React.Ref<ElementRef<typeof Accordion>>,
) => {
const categorizedResults: Map<T, SearchResult[]> = new Map();

for (const item of data) {
const category = groupBy(item);

if (!categorizedResults.has(category)) {
categorizedResults.set(category, []);
}
categorizedResults.get(category)?.push(item);
}

if (data.length === 0) return onNoResults();

return (
<Accordion {...props} ref={ref}>
{categorizedResults
.entries()
.toArray()
.sort(sortBy)
.map(([category, results], index) => (
<AccordionItem key={`item-${category}`} value={category}>
<AccordionTrigger>
{getLabel({ category, results })}
</AccordionTrigger>
<AccordionContent>
{getContent({ category, results })}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};

export const SearchResults = forwardRef(SearchResultsNoForwardRef) as <
Category extends string,
>(
props: SearchResultsProps<Category> &
ComponentPropsWithoutRef<typeof Accordion>,
ref: ElementRef<typeof Accordion>,
) => ReactElement;
58 changes: 58 additions & 0 deletions src/lib/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as React from 'react';

import { ChevronDown } from 'lucide-react';

import * as AccordionPrimitive from '@radix-ui/react-accordion';

import { cn } from '@/lib/utils';

const Accordion = AccordionPrimitive.Root;

const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';

const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;

const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
));

AccordionContent.displayName = AccordionPrimitive.Content.displayName;

export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
8 changes: 8 additions & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ module.exports = {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
Expand Down

0 comments on commit c7951da

Please sign in to comment.