├── src ├── vite-env.d.ts ├── lib │ └── utils.ts ├── main.tsx ├── pages │ ├── orama │ │ ├── oramaWorkerInstance.ts │ │ ├── main.tsx │ │ ├── App.tsx │ │ ├── useSearch.ts │ │ ├── oramaWorker.ts │ │ └── useDataTableOrama.ts │ ├── facets │ │ ├── main.tsx │ │ ├── App.tsx │ │ └── useDataTableFacets.ts │ ├── itemsjs │ │ ├── main.tsx │ │ ├── App.tsx │ │ └── useDataTableItemsjs.ts │ └── tanstack │ │ ├── main.tsx │ │ ├── App.tsx │ │ └── useDataTable.ts ├── components │ ├── useData.ts │ ├── ui │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── slider.tsx │ │ ├── scroll-area.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ ├── select.tsx │ │ ├── command.tsx │ │ └── dropdown-menu.tsx │ ├── FacetedFilterSlider.tsx │ ├── data-table-toolbar.tsx │ ├── data-table-view-options.tsx │ ├── columns.tsx │ ├── data-table-column-header.tsx │ ├── data-table.tsx │ ├── FacetedFilterCheckboxes.tsx │ ├── sidebar.tsx │ ├── data-table-pagination.tsx │ └── data-table-faceted-filter.tsx ├── index.css ├── App.tsx └── data │ └── schema.ts ├── postcss.config.js ├── tsconfig.node.json ├── .gitignore ├── components.json ├── index.html ├── pages ├── facets │ └── index.html ├── orama │ └── index.html ├── itemsjs │ └── index.html └── tanstack │ └── index.html ├── .eslintrc.cjs ├── vite.config.ts ├── tsconfig.json ├── README.md ├── README_vite.md ├── public └── vite.svg ├── package.json └── tailwind.config.js /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/pages/orama/oramaWorkerInstance.ts: -------------------------------------------------------------------------------- 1 | import { wrap } from "comlink"; 2 | import { OramaWorker } from "./oramaWorker"; 3 | 4 | const worker = new Worker(new URL("./oramaWorker.ts", import.meta.url), { 5 | type: "module", 6 | }); 7 | 8 | export const oramaWorker = wrap(worker); 9 | -------------------------------------------------------------------------------- /src/pages/facets/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import '@/index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/pages/itemsjs/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import '@/index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/pages/orama/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import '@/index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/pages/tanstack/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import '@/index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/components/useData.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Product, defaultLimit, loadData } from "../data/schema"; 3 | 4 | export const useData = () => { 5 | const [data, setData] = useState([] as Product[]); 6 | useEffect(() => { 7 | loadData(defaultLimit).then(setData); 8 | }, []); 9 | return data; 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Faceted search demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /pages/facets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tanstack table with Facets 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /pages/orama/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tanstack table with Orama 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /pages/itemsjs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tanstack table with ItemsJS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /pages/tanstack/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tanstack table with native faceting functionality 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | "@": path.resolve(__dirname, "./src"), 10 | }, 11 | }, 12 | build: { 13 | sourcemap: true, 14 | rollupOptions: { 15 | input: { 16 | main: path.resolve(__dirname, "index.html"), 17 | facets: path.resolve(__dirname, "pages/facets/index.html"), 18 | tanstack: path.resolve(__dirname, "pages/tanstack/index.html"), 19 | itemsjs: path.resolve(__dirname, "pages/itemsjs/index.html"), 20 | orama: path.resolve(__dirname, "pages/orama/index.html"), 21 | }, 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["./src/*"] 26 | } 27 | }, 28 | "include": ["src"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/orama/App.tsx: -------------------------------------------------------------------------------- 1 | import { columnsProduct } from "@/components/columns"; 2 | import { DataTable } from "@/components/data-table"; 3 | import { Sidebar } from "@/components/sidebar"; 4 | 5 | import { useDataTableOrama } from "@/pages/orama/useDataTableOrama"; 6 | 7 | export default function App() { 8 | const table = useDataTableOrama({ columns: columnsProduct }); 9 | 10 | return ( 11 |
12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ) 27 | Separator.displayName = SeparatorPrimitive.Root.displayName 28 | 29 | export { Separator } 30 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/pages/tanstack/App.tsx: -------------------------------------------------------------------------------- 1 | import { columnsProduct } from "@/components/columns"; 2 | import { DataTable } from "@/components/data-table"; 3 | import { Sidebar } from "@/components/sidebar"; 4 | 5 | import { useDataTable } from "@/pages/tanstack/useDataTable"; 6 | import { useData } from "@/components/useData"; 7 | 8 | export default function App() { 9 | const data = useData(); 10 | const table = useDataTable({ data, columns: columnsProduct }); 11 | 12 | return ( 13 |
14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/facets/App.tsx: -------------------------------------------------------------------------------- 1 | import { columnsProduct } from "@/components/columns"; 2 | import { DataTable } from "@/components/data-table"; 3 | import { Sidebar } from "@/components/sidebar"; 4 | 5 | import { useDataTableFacets } from "@/pages/facets/useDataTableFacets"; 6 | import { useData } from "@/components/useData"; 7 | 8 | export default function App() { 9 | const data = useData(); 10 | const table = useDataTableFacets({ data, columns: columnsProduct }); 11 | 12 | return ( 13 |
14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/itemsjs/App.tsx: -------------------------------------------------------------------------------- 1 | import { columnsProduct } from "@/components/columns"; 2 | import { DataTable } from "@/components/data-table"; 3 | import { Sidebar } from "@/components/sidebar"; 4 | 5 | import { useDataTableItemsjs } from "@/pages/itemsjs/useDataTableItemsjs"; 6 | import { useData } from "@/components/useData"; 7 | 8 | export default function App() { 9 | const data = useData(); 10 | const table = useDataTableItemsjs({ data, columns: columnsProduct }); 11 | 12 | return ( 13 |
14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/FacetedFilterSlider.tsx: -------------------------------------------------------------------------------- 1 | import { Column } from "@tanstack/react-table"; 2 | import { Slider } from "@/components/ui/slider"; 3 | import { useMemo } from "react"; 4 | 5 | interface FacetedFilterSliderProps { 6 | column?: Column; 7 | title?: string; 8 | } 9 | 10 | export function FacetedFilterSlider({ 11 | column, 12 | }: FacetedFilterSliderProps) { 13 | const facetsMemo = useMemo( 14 | () => column?.getFacetedMinMaxValues(), 15 | [column?.getFacetedMinMaxValues()?.length] 16 | ); 17 | const facets = column?.getFacetedMinMaxValues(); 18 | if (!facets || !facetsMemo) return null; 19 | 20 | const [min, max] = facetsMemo; 21 | return ( 22 | { 26 | column?.setFilterValue(x); 27 | }} 28 | max={max} 29 | min={min} 30 | step={0.01} 31 | defaultValue={facets} 32 | minStepsBetweenThumbs={100} 33 | /> 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Faceted search experiments 2 | 3 | - Read more about experiment [here](https://stereobooster.com/posts/faceted-search/) 4 | - Checkout online demo [here](https://faceted.stereobooster.com/) 5 | 6 | ## Development 7 | 8 | ``` 9 | pnpm i 10 | pnpm run dev 11 | ``` 12 | 13 | ## Experiments 14 | 15 | ### Tanstack 16 | 17 | - [source code](src/pages/tanstack/) 18 | - [demo](https://faceted.stereobooster.com/pages/tanstack/) 19 | 20 | ### Orama 21 | 22 | - [source code](src/pages/orama/) 23 | - [demo](https://faceted.stereobooster.com/pages/orama/) 24 | 25 | ### ItemsJS 26 | 27 | - [source code](src/pages/itemsjs/) 28 | - [demo](https://faceted.stereobooster.com/pages/itemsjs/) 29 | 30 | **Note**: production build for ItemsJS is broken, but it work in development mode. Most likely it is brokwn here: 31 | 32 | ```ts 33 | chain(items) 34 | .map((item) => { 35 | fields.forEach((field) => { 36 | ``` 37 | 38 | This demo is very similar to Facets demo. So use it meantime 39 | 40 | ### Facets 41 | 42 | - [source code](src/pages/facets/) 43 | - [demo](https://faceted.stereobooster.com/pages/facets/) 44 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { Check } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Popover = PopoverPrimitive.Root 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger 9 | 10 | const PopoverContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 15 | 25 | 26 | )) 27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 28 | 29 | export { Popover, PopoverTrigger, PopoverContent } 30 | -------------------------------------------------------------------------------- /README_vite.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SliderPrimitive from "@radix-ui/react-slider" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Slider = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | )) 25 | Slider.displayName = SliderPrimitive.Root.displayName 26 | 27 | export { Slider } 28 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/data-table-toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Cross2Icon } from "@radix-ui/react-icons" 2 | import { Table } from "@tanstack/react-table" 3 | 4 | import { Button } from "@/components/ui/button" 5 | import { Input } from "@/components/ui/input" 6 | import { DataTableViewOptions } from "./data-table-view-options" 7 | 8 | interface DataTableToolbarProps { 9 | table: Table 10 | } 11 | 12 | export function DataTableToolbar({ 13 | table, 14 | }: DataTableToolbarProps) { 15 | const isFiltered = table.getState().columnFilters.length > 0 16 | 17 | return ( 18 |
19 |
20 | 24 | table.getColumn("name")?.setFilterValue(event.target.value) 25 | } 26 | className="h-8 w-[150px] lg:w-[250px]" 27 | /> 28 | {isFiltered && ( 29 | 37 | )} 38 |
39 | 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/orama/useSearch.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { oramaWorker } from "./oramaWorkerInstance"; 3 | import { proxy } from "comlink"; 4 | import { ResultsOramaWorker, SearchParamsOramaWorker } from "./oramaWorker"; 5 | 6 | export const useSearch = ({ 7 | term, 8 | where, 9 | sortBy, 10 | limit, 11 | offset, 12 | }: SearchParamsOramaWorker) => { 13 | const [results, setResults] = useState(null); 14 | const counter = useRef(0); 15 | 16 | useEffect(() => { 17 | oramaWorker.load(); 18 | 19 | oramaWorker.onLoadProgress( 20 | proxy((percentage, total) => { 21 | console.log(percentage, total); 22 | if (counter.current > 1) return; 23 | oramaWorker 24 | .search({ term, where, signalId: -1 }) 25 | .then(({ signalId, ...rest }) => { 26 | if (signalId === -1) setResults(rest); 27 | }); 28 | }) 29 | ); 30 | 31 | return () => { 32 | // @ts-expect-error Comlink TS signature doesn't support optional params 33 | oramaWorker.onLoadProgress(); 34 | counter.current = -1; 35 | }; 36 | }, []); 37 | 38 | useEffect(() => { 39 | counter.current += 1; 40 | oramaWorker 41 | .search({ term, where, sortBy, limit, offset, signalId: counter.current }) 42 | .then(({ signalId, ...rest }) => { 43 | if (signalId === counter.current) setResults(rest); 44 | }); 45 | }, [term, where, sortBy, limit, offset]); 46 | 47 | return results; 48 | }; 49 | -------------------------------------------------------------------------------- /src/pages/tanstack/useDataTable.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | ColumnDef, 4 | ColumnFiltersState, 5 | SortingState, 6 | VisibilityState, 7 | getCoreRowModel, 8 | getFacetedRowModel, 9 | getFacetedUniqueValues, 10 | getFilteredRowModel, 11 | getPaginationRowModel, 12 | getSortedRowModel, 13 | getFacetedMinMaxValues, 14 | useReactTable, 15 | } from "@tanstack/react-table"; 16 | import { defaultVisibilityFilter } from "@/data/schema"; 17 | 18 | interface DataTableProps { 19 | data: TData[]; 20 | columns: ColumnDef[]; 21 | } 22 | 23 | export const useDataTable = ({ 24 | columns, 25 | data, 26 | }: DataTableProps) => { 27 | const [columnVisibility, setColumnVisibility] = useState(defaultVisibilityFilter); 28 | const [columnFilters, setColumnFilters] = useState([]); 29 | const [sorting, setSorting] = useState([]); 30 | 31 | return useReactTable({ 32 | data, 33 | columns, 34 | state: { 35 | sorting, 36 | columnVisibility, 37 | columnFilters, 38 | }, 39 | onSortingChange: setSorting, 40 | onColumnFiltersChange: setColumnFilters, 41 | onColumnVisibilityChange: setColumnVisibility, 42 | getCoreRowModel: getCoreRowModel(), 43 | getFilteredRowModel: getFilteredRowModel(), 44 | getPaginationRowModel: getPaginationRowModel(), 45 | getSortedRowModel: getSortedRowModel(), 46 | getFacetedRowModel: getFacetedRowModel(), 47 | getFacetedUniqueValues: getFacetedUniqueValues(), 48 | getFacetedMinMaxValues: getFacetedMinMaxValues(), 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /src/components/data-table-view-options.tsx: -------------------------------------------------------------------------------- 1 | import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; 2 | import { MixerHorizontalIcon } from "@radix-ui/react-icons"; 3 | import { Table } from "@tanstack/react-table"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuCheckboxItem, 9 | DropdownMenuContent, 10 | DropdownMenuLabel, 11 | DropdownMenuSeparator, 12 | } from "@/components/ui/dropdown-menu"; 13 | 14 | interface DataTableViewOptionsProps { 15 | table: Table; 16 | } 17 | 18 | export function DataTableViewOptions({ 19 | table, 20 | }: DataTableViewOptionsProps) { 21 | return ( 22 | 23 | 24 | 32 | 33 | 34 | Toggle columns 35 | 36 | {table 37 | .getAllColumns() 38 | .filter( 39 | (column) => 40 | typeof column.accessorFn !== "undefined" && column.getCanHide() 41 | ) 42 | .map((column) => { 43 | return ( 44 | column.toggleVisibility(!!value)} 49 | > 50 | {column.id} 51 | 52 | ); 53 | })} 54 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/columns.tsx: -------------------------------------------------------------------------------- 1 | import { ColumnDef } from "@tanstack/react-table"; 2 | import { Product } from "../data/schema"; 3 | import { DataTableColumnHeader } from "./data-table-column-header"; 4 | 5 | export const columnsProduct: ColumnDef[] = [ 6 | { 7 | accessorKey: "name", 8 | header: ({ column }) => ( 9 | 10 | ), 11 | }, 12 | { 13 | accessorKey: "shortDescription", 14 | header: ({ column }) => ( 15 | 16 | ), 17 | }, 18 | { 19 | accessorKey: "salePrice", 20 | header: ({ column }) => ( 21 | 22 | ), 23 | }, 24 | { 25 | accessorKey: "bestSellingRank", 26 | header: ({ column }) => ( 27 | 28 | ), 29 | }, 30 | { 31 | accessorKey: "manufacturer", 32 | header: ({ column }) => ( 33 | 34 | ), 35 | filterFn: (row, id, value) => { 36 | return value.includes(row.getValue(id)); 37 | }, 38 | }, 39 | { 40 | accessorKey: "type", 41 | header: ({ column }) => ( 42 | 43 | ), 44 | filterFn: (row, id, value) => { 45 | return value.includes(row.getValue(id)); 46 | }, 47 | }, 48 | { 49 | accessorKey: "categories", 50 | header: ({ column }) => ( 51 | 52 | ), 53 | filterFn: (row, id, value) => { 54 | const rowValue = row.getValue(id) as string[]; 55 | if (value.length === 0 || rowValue.length === 0) return false; 56 | return rowValue.some((x) => value.includes(x)); 57 | }, 58 | getUniqueValues: (rowData) => rowData["categories"], 59 | }, 60 | ]; 61 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )) 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 47 | 48 | )) 49 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 50 | 51 | export { ScrollArea, ScrollBar } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "faceted-search", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@orama/orama": "2.0.0-beta.1", 14 | "@radix-ui/react-avatar": "^1.0.4", 15 | "@radix-ui/react-checkbox": "^1.0.4", 16 | "@radix-ui/react-dialog": "^1.0.5", 17 | "@radix-ui/react-dropdown-menu": "^2.0.6", 18 | "@radix-ui/react-icons": "^1.3.0", 19 | "@radix-ui/react-popover": "^1.0.7", 20 | "@radix-ui/react-scroll-area": "^1.0.5", 21 | "@radix-ui/react-select": "^2.0.0", 22 | "@radix-ui/react-separator": "^1.0.3", 23 | "@radix-ui/react-slider": "^1.1.2", 24 | "@radix-ui/react-slot": "^1.0.2", 25 | "@stereobooster/facets": "^0.1.1", 26 | "@stereobooster/itemsjs": "^2.1.22", 27 | "@tanstack/react-table": "^8.10.7", 28 | "class-variance-authority": "^0.7.0", 29 | "clsx": "^2.0.0", 30 | "cmdk": "^0.2.0", 31 | "comlink": "^4.4.1", 32 | "lucide-react": "^0.289.0", 33 | "quick-score": "^0.2.0", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "zod": "^3.22.4" 37 | }, 38 | "devDependencies": { 39 | "@types/fastbitset": "^0.2.2", 40 | "@types/node": "^20.8.6", 41 | "@types/react": "^18.2.15", 42 | "@types/react-dom": "^18.2.7", 43 | "@typescript-eslint/eslint-plugin": "^6.0.0", 44 | "@typescript-eslint/parser": "^6.0.0", 45 | "@vitejs/plugin-react": "^4.3.0", 46 | "autoprefixer": "^10.4.16", 47 | "eslint": "^8.45.0", 48 | "eslint-plugin-react-hooks": "^4.6.0", 49 | "eslint-plugin-react-refresh": "^0.4.3", 50 | "postcss": "^8.4.31", 51 | "tailwind-merge": "^1.14.0", 52 | "tailwindcss": "^3.3.5", 53 | "tailwindcss-animate": "^1.0.7", 54 | "typescript": "^5.4.5", 55 | "vite": "^5.2.11" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/pages/orama/oramaWorker.ts: -------------------------------------------------------------------------------- 1 | import { expose } from "comlink"; 2 | import { 3 | Product, 4 | defaultLimit, 5 | loadData, 6 | oramaFacets, 7 | oramaSchema, 8 | searchFields, 9 | } from "@/data/schema"; 10 | import { 11 | SearchParams, 12 | TypedDocument, 13 | Results, 14 | create, 15 | insertMultiple, 16 | search, 17 | Orama, 18 | } from "@orama/orama"; 19 | 20 | // Module format "iife" does not support top-level await. Use the "es" or "system" output formats rather. 21 | let ecomerceDB: Orama; 22 | const ecomerceDBPromise = create({ schema: oramaSchema }).then((x) => { 23 | ecomerceDB = x; 24 | }); 25 | 26 | type ProgressCB = (percentage: number, total: number) => void; 27 | 28 | const batch = 200; 29 | let total = 0; 30 | let loading = 0; 31 | let callback: ProgressCB | undefined; 32 | 33 | const load = (limit = defaultLimit) => { 34 | if (loading > 0) return false; 35 | loading = 1; 36 | 37 | return ecomerceDBPromise.then(() => 38 | loadData(limit).then(async (data) => { 39 | let tmp = []; 40 | for (let i = 0; i < limit; i++) { 41 | tmp.push(data[i]); 42 | if (tmp.length >= batch || i === limit - 1) { 43 | // @ts-expect-error Orama TS signature is wrong 44 | await insertMultiple(ecomerceDB, tmp); 45 | total += tmp.length; 46 | tmp = []; 47 | if (callback) callback(i / (limit - 1), total); 48 | } 49 | } 50 | loading = 2; 51 | 52 | return true; 53 | }) 54 | ); 55 | }; 56 | 57 | type EcomerceDB = typeof ecomerceDB; 58 | 59 | export type SearchParamsOramaWorker = SearchParams< 60 | EcomerceDB, 61 | TypedDocument 62 | > & { 63 | signalId?: number; 64 | }; 65 | 66 | export type ResultsOramaWorker = Results & { 67 | signalId?: number; 68 | }; 69 | 70 | const api = { 71 | load, 72 | onLoadProgress(cb: ProgressCB) { 73 | callback = cb; 74 | }, 75 | search({ signalId, ...rest }: SearchParamsOramaWorker) { 76 | return ecomerceDBPromise.then(() => { 77 | return search(ecomerceDB, { 78 | ...rest, 79 | properties: searchFields, 80 | facets: oramaFacets, 81 | }).then((res: ResultsOramaWorker) => { 82 | res.signalId = signalId; 83 | return res; 84 | }); 85 | }); 86 | }, 87 | }; 88 | 89 | export type OramaWorker = typeof api; 90 | 91 | expose(api); 92 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: "var(--radius)", 56 | md: "calc(var(--radius) - 2px)", 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | keyframes: { 60 | "accordion-down": { 61 | from: { height: 0 }, 62 | to: { height: "var(--radix-accordion-content-height)" }, 63 | }, 64 | "accordion-up": { 65 | from: { height: "var(--radix-accordion-content-height)" }, 66 | to: { height: 0 }, 67 | }, 68 | }, 69 | animation: { 70 | "accordion-down": "accordion-down 0.2s ease-out", 71 | "accordion-up": "accordion-up 0.2s ease-out", 72 | }, 73 | }, 74 | }, 75 | plugins: [require("tailwindcss-animate")], 76 | } -------------------------------------------------------------------------------- /src/components/data-table-column-header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ArrowDownIcon, 3 | ArrowUpIcon, 4 | CaretSortIcon, 5 | EyeNoneIcon, 6 | } from "@radix-ui/react-icons"; 7 | import { Column } from "@tanstack/react-table"; 8 | 9 | import { cn } from "@/lib/utils"; 10 | import { Button } from "@/components/ui/button"; 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuItem, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "@/components/ui/dropdown-menu"; 18 | 19 | interface DataTableColumnHeaderProps 20 | extends React.HTMLAttributes { 21 | column: Column; 22 | title: string; 23 | } 24 | 25 | export function DataTableColumnHeader({ 26 | column, 27 | title, 28 | className, 29 | }: DataTableColumnHeaderProps) { 30 | if (!column.getCanSort()) { 31 | return
{title}
; 32 | } 33 | 34 | return ( 35 |
36 | 37 | 38 | 52 | 53 | 54 | column.toggleSorting(false)}> 55 | 56 | Asc 57 | 58 | column.toggleSorting(true)}> 59 | 60 | Desc 61 | 62 | 63 | column.toggleVisibility(false)}> 64 | 65 | Hide 66 | 67 | 68 | 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/data-table.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ColumnDef, 3 | flexRender, 4 | Table as TableType, 5 | } from "@tanstack/react-table"; 6 | 7 | import { 8 | Table, 9 | TableBody, 10 | TableCell, 11 | TableHead, 12 | TableHeader, 13 | TableRow, 14 | } from "@/components/ui/table"; 15 | 16 | import { DataTablePagination } from "./data-table-pagination"; 17 | import { DataTableToolbar } from "./data-table-toolbar"; 18 | 19 | interface DataTableProps { 20 | table: TableType; 21 | columns: ColumnDef[]; 22 | } 23 | 24 | export function DataTable({ 25 | table, 26 | columns, 27 | }: DataTableProps) { 28 | return ( 29 |
30 | 31 |
32 | 33 | 34 | {table.getHeaderGroups().map((headerGroup) => ( 35 | 36 | {headerGroup.headers.map((header) => { 37 | return ( 38 | 39 | {header.isPlaceholder 40 | ? null 41 | : flexRender( 42 | header.column.columnDef.header, 43 | header.getContext() 44 | )} 45 | 46 | ); 47 | })} 48 | 49 | ))} 50 | 51 | 52 | {table.getRowModel().rows?.length ? ( 53 | table.getRowModel().rows.map((row) => ( 54 | 58 | {row.getVisibleCells().map((cell) => ( 59 | 60 | {flexRender( 61 | cell.column.columnDef.cell, 62 | cell.getContext() 63 | )} 64 | 65 | ))} 66 | 67 | )) 68 | ) : ( 69 | 70 | 74 | No results. 75 | 76 | 77 | )} 78 | 79 |
80 |
81 | 82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { Separator } from "@/components/ui/separator"; 3 | 4 | export default function App() { 5 | return ( 6 |
7 | 8 | 9 | Faceted search experiments 10 | 11 | 12 |

13 | 14 | Facets 15 | 16 |

17 |

18 | Tanstack table with{" "} 19 | 23 | Facets for faceting 24 | 25 |

26 | 27 |

28 | 29 | Tanstack 30 | 31 |

32 |

33 | Tanstack table with{" "} 34 | 38 | native faceting functionality 39 | 40 |

41 | 42 |

43 | 44 | Orama 45 | 46 |

47 |

48 | Tanstack table with Orama{" "} 49 | 53 | full text search 54 | {" "} 55 | and{" "} 56 | 60 | faceting 61 | 62 |

63 | 64 |

65 | 66 | ItemsJS 67 | 68 |

69 |

70 | Tanstack table with{" "} 71 | 72 | ItemsJS for faceting 73 | 74 |

75 |
76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/pages/orama/useDataTableOrama.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useState } from "react"; 2 | import { 3 | ColumnDef, 4 | ColumnFiltersState, 5 | SortingState, 6 | VisibilityState, 7 | PaginationState, 8 | getCoreRowModel, 9 | getFacetedRowModel, 10 | getFacetedMinMaxValues, 11 | useReactTable, 12 | Table, 13 | } from "@tanstack/react-table"; 14 | import { useSearch } from "./useSearch"; 15 | import { Product, defaultVisibilityFilter } from "@/data/schema"; 16 | import { Orama, SorterParams } from "@orama/orama"; 17 | 18 | interface DataTableProps { 19 | columns: ColumnDef[]; 20 | } 21 | 22 | export const useDataTableOrama = ({ columns }: DataTableProps) => { 23 | const [columnVisibility, setColumnVisibility] = useState( 24 | defaultVisibilityFilter 25 | ); 26 | const [columnFilters, setColumnFilters] = useState([]); 27 | const [sorting, setSorting] = useState([]); 28 | const [pagination, setPagination] = useState({ 29 | pageIndex: 0, 30 | pageSize: 10, 31 | }); 32 | 33 | const where = useMemo( 34 | () => 35 | columnFilters 36 | .filter((x) => x.id !== "name") 37 | .reduce((prev, next) => { 38 | prev[next.id] = next.value as string[]; 39 | return prev; 40 | }, {} as Record), 41 | [columnFilters] 42 | ); 43 | 44 | const sortBy = useMemo(() => { 45 | if (sorting.length === 0) return; 46 | return { 47 | property: sorting[0].id, 48 | order: sorting[0].desc ? "DESC" : "ASC", 49 | } as SorterParams>; 50 | }, [sorting]); 51 | 52 | const term = useMemo( 53 | () => (columnFilters.find((x) => x.id === "name")?.value as string) || "", 54 | [columnFilters] 55 | ); 56 | 57 | const result = useSearch({ 58 | term, 59 | where, 60 | sortBy, 61 | limit: pagination.pageSize, 62 | offset: pagination.pageIndex, 63 | }); 64 | const resultRef = useRef(result); 65 | try { 66 | resultRef.current = result; 67 | } catch (e) { 68 | // do nothing 69 | } 70 | const data = useMemo( 71 | () => result?.hits?.map((x) => x.document) || [], 72 | [result] 73 | ); 74 | const count = result?.count; 75 | const pageCount = 76 | count !== undefined ? Math.ceil(count / pagination.pageSize) : -1; 77 | 78 | return useReactTable({ 79 | data, 80 | columns, 81 | state: { 82 | sorting, 83 | columnVisibility, 84 | columnFilters, 85 | pagination, 86 | }, 87 | pageCount, 88 | manualPagination: true, 89 | onPaginationChange: setPagination, 90 | onSortingChange: setSorting, 91 | onColumnFiltersChange: setColumnFilters, 92 | onColumnVisibilityChange: setColumnVisibility, 93 | getCoreRowModel: getCoreRowModel(), 94 | getFacetedUniqueValues: 95 | (_table: Table, columnId: string) => 96 | () => 97 | new Map( 98 | Object.entries(resultRef.current?.facets![columnId]?.values || {}) 99 | ), 100 | // TODO: replace those 101 | getFacetedRowModel: getFacetedRowModel(), 102 | getFacetedMinMaxValues: getFacetedMinMaxValues(), 103 | }); 104 | }; 105 | -------------------------------------------------------------------------------- /src/components/FacetedFilterCheckboxes.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from "@radix-ui/react-icons"; 2 | import { Column } from "@tanstack/react-table"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { 6 | Command, 7 | CommandEmpty, 8 | CommandGroup, 9 | CommandInput, 10 | CommandItem, 11 | CommandList, 12 | CommandSeparator, 13 | } from "@/components/ui/command"; 14 | 15 | interface FacetedFilterCheckboxesProps { 16 | column?: Column; 17 | title?: string; 18 | } 19 | 20 | export function FacetedFilterCheckboxes({ 21 | column, 22 | title, 23 | }: FacetedFilterCheckboxesProps) { 24 | // const facets = useMemo( 25 | // () => 26 | // [...(column?.getFacetedUniqueValues().entries() || [])].sort( 27 | // (a, b) => b[1] - a[1] 28 | // ), 29 | // [column] 30 | // ); 31 | const facets = [...(column?.getFacetedUniqueValues().entries() || [])]; 32 | 33 | const selectedValuesArr = column?.getFilterValue() as string[]; 34 | const selectedValues = new Set(selectedValuesArr); 35 | 36 | return ( 37 | 38 | {facets.length > 5 && } 39 | 40 | No results found. 41 | 42 | {facets.map(([option, count]) => { 43 | const isSelected = selectedValues.has(option); 44 | return ( 45 | { 49 | if (isSelected) { 50 | selectedValues.delete(option); 51 | } else { 52 | selectedValues.add(option); 53 | } 54 | const filterValues = Array.from(selectedValues); 55 | column?.setFilterValue( 56 | filterValues.length ? filterValues : undefined 57 | ); 58 | }} 59 | > 60 |
68 | 69 |
70 | {option} 71 | 72 | {count} 73 | 74 |
75 | ); 76 | })} 77 |
78 | {selectedValues.size > 0 && ( 79 | <> 80 | 81 | 82 | column?.setFilterValue(undefined)} 84 | className="justify-center text-center" 85 | > 86 | Clear filters 87 | 88 | 89 | 90 | )} 91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | 48 | )) 49 | TableFooter.displayName = "TableFooter" 50 | 51 | const TableRow = React.forwardRef< 52 | HTMLTableRowElement, 53 | React.HTMLAttributes 54 | >(({ className, ...props }, ref) => ( 55 | 63 | )) 64 | TableRow.displayName = "TableRow" 65 | 66 | const TableHead = React.forwardRef< 67 | HTMLTableCellElement, 68 | React.ThHTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
78 | )) 79 | TableHead.displayName = "TableHead" 80 | 81 | const TableCell = React.forwardRef< 82 | HTMLTableCellElement, 83 | React.TdHTMLAttributes 84 | >(({ className, ...props }, ref) => ( 85 | 90 | )) 91 | TableCell.displayName = "TableCell" 92 | 93 | const TableCaption = React.forwardRef< 94 | HTMLTableCaptionElement, 95 | React.HTMLAttributes 96 | >(({ className, ...props }, ref) => ( 97 |
102 | )) 103 | TableCaption.displayName = "TableCaption" 104 | 105 | export { 106 | Table, 107 | TableHeader, 108 | TableBody, 109 | TableFooter, 110 | TableHead, 111 | TableRow, 112 | TableCell, 113 | TableCaption, 114 | } 115 | -------------------------------------------------------------------------------- /src/components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | // import { ScrollArea } from "@/components/ui/scroll-area"; 3 | import { Table as TableType } from "@tanstack/react-table"; 4 | import { FacetedFilterCheckboxes } from "./FacetedFilterCheckboxes"; 5 | import { FacetedFilterSlider } from "./FacetedFilterSlider"; 6 | import { useEffect, useState } from "react"; 7 | 8 | interface SidebarProps { 9 | className?: string; 10 | table: TableType; 11 | } 12 | 13 | export function Sidebar({ className, table }: SidebarProps) { 14 | const [loaded, setLoaded] = useState(false); 15 | const length = table.getRowModel().rows.length; 16 | useEffect(() => { 17 | if (length > 0) setLoaded(true) 18 | }, [length]); 19 | 20 | return ( 21 |
22 |
23 | {table.getColumn("salePrice") && ( 24 |
25 |

26 | Price 27 |

28 |
29 | {loaded && ( 30 | 34 | )} 35 |
36 |
37 | )} 38 | {table.getColumn("manufacturer") && ( 39 |
40 |

41 | Manufacturer 42 |

43 |
44 | {loaded && ( 45 | 49 | )} 50 |
51 |
52 | )} 53 | {table.getColumn("type") && ( 54 |
55 |

56 | Type 57 |

58 |
59 | {loaded && ( 60 | 64 | )} 65 |
66 |
67 | )} 68 | {table.getColumn("categories") && ( 69 |
70 |

71 | Category 72 |

73 |
74 | {length != 0 && ( 75 | 79 | )} 80 |
81 |
82 | )} 83 | {/*
84 |

85 | Categories 86 |

87 | 88 |
Checkboxes
89 |
90 |
*/} 91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/data-table-pagination.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChevronLeftIcon, 3 | ChevronRightIcon, 4 | DoubleArrowLeftIcon, 5 | DoubleArrowRightIcon, 6 | } from "@radix-ui/react-icons"; 7 | import { Table } from "@tanstack/react-table"; 8 | 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | Select, 12 | SelectContent, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from "@/components/ui/select"; 17 | 18 | interface DataTablePaginationProps { 19 | table: Table; 20 | } 21 | 22 | export function DataTablePagination({ 23 | table, 24 | }: DataTablePaginationProps) { 25 | return ( 26 |
27 |
28 |
29 |
30 |

Rows per page

31 | 48 |
49 |
50 | Page {table.getState().pagination.pageIndex + 1} of{" "} 51 | {table.getPageCount()} 52 |
53 |
54 | 63 | 72 | 81 | 90 |
91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/data/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Schema } from "@stereobooster/facets"; 3 | 4 | export const productSchema = z.object({ 5 | name: z.string(), 6 | shortDescription: z.string(), 7 | salePrice: z.number(), 8 | bestSellingRank: z.number(), 9 | manufacturer: z.string(), 10 | type: z.string(), 11 | categories: z.array(z.string()), 12 | // salePrice_range: z.string(), 13 | // thumbnailImage: z.string(), 14 | // url: z.string(), 15 | // image: z.string(), 16 | // objectID: z.string(), 17 | // shipping: z.string(), 18 | // customerReviewCount: z.number(), 19 | }); 20 | 21 | export type Product = z.infer; 22 | 23 | export const defaultVisibilityFilter = { 24 | name: true, 25 | shortDescription: true, 26 | salePrice: true, 27 | bestSellingRank: false, 28 | manufacturer: false, 29 | type: false, 30 | categories: false, 31 | }; 32 | 33 | export const oramaSchema = { 34 | name: "string", 35 | shortDescription: "string", 36 | salePrice: "number", 37 | bestSellingRank: "number", 38 | manufacturer: "string", 39 | type: "string", 40 | categories: "string[]", 41 | }; 42 | 43 | export const searchFields = [ 44 | "name", 45 | "shortDescription", 46 | "manufacturer", 47 | ] as Array; 48 | 49 | export const itemsJsFacets = { 50 | manufacturer: { 51 | title: "Manufacturer", 52 | size: 10, 53 | conjunction: false, 54 | }, 55 | type: { 56 | title: "Type", 57 | size: 10, 58 | conjunction: false, 59 | }, 60 | categories: { 61 | title: "Categories", 62 | size: 10, 63 | conjunction: false, 64 | }, 65 | salePrice: { 66 | title: "Price", 67 | show_facet_stats: true, 68 | }, 69 | }; 70 | 71 | export const facetsSchema = { 72 | name: { 73 | type: "string", 74 | text: true, 75 | }, 76 | shortDescription: { 77 | type: "string", 78 | text: true, 79 | }, 80 | salePrice: { 81 | type: "number", 82 | facet: true, 83 | }, 84 | bestSellingRank: { 85 | type: "number", 86 | facet: true, 87 | }, 88 | manufacturer: { 89 | type: "string", 90 | facet: true, 91 | }, 92 | type: { 93 | type: "string", 94 | facet: true, 95 | }, 96 | categories: { 97 | type: "string", 98 | facet: true, 99 | isArray: true, 100 | }, 101 | } satisfies Schema; 102 | 103 | export const oramaFacets = { 104 | manufacturer: {}, 105 | type: {}, 106 | categories: {}, 107 | }; 108 | 109 | export const defaultLimit = 1000; 110 | 111 | export const loadData = (limit = -1) => 112 | fetch("/ecommerce/bestbuy_seo.json") 113 | .then((res) => res.json()) 114 | .then((data) => { 115 | if (!Array.isArray(data)) return []; 116 | const result = []; 117 | for (const i in data) { 118 | try { 119 | const { 120 | name, 121 | shortDescription, 122 | salePrice, 123 | bestSellingRank, 124 | manufacturer, 125 | type, 126 | categories, 127 | } = productSchema.parse(data[i]); 128 | 129 | result.push({ 130 | name, 131 | shortDescription, 132 | salePrice, 133 | bestSellingRank, 134 | manufacturer, 135 | type, 136 | categories, 137 | }); 138 | } catch (e) { 139 | // do nothing 140 | } 141 | if (result.length === limit) break; 142 | } 143 | 144 | return result; 145 | }); 146 | -------------------------------------------------------------------------------- /src/pages/facets/useDataTableFacets.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useState } from "react"; 2 | import { 3 | ColumnDef, 4 | ColumnFiltersState, 5 | PaginationState, 6 | SortingState, 7 | VisibilityState, 8 | getCoreRowModel, 9 | useReactTable, 10 | Table, 11 | } from "@tanstack/react-table"; 12 | import { defaultVisibilityFilter, facetsSchema } from "@/data/schema"; 13 | import { Facets, TQuickscoreIndex } from "@stereobooster/facets"; 14 | 15 | interface DataTableProps { 16 | data: TData[]; 17 | columns: ColumnDef[]; 18 | } 19 | 20 | export const useDataTableFacets = < 21 | TData extends Record, 22 | TValue 23 | >({ 24 | columns, 25 | data, 26 | }: DataTableProps) => { 27 | const [columnVisibility, setColumnVisibility] = useState( 28 | defaultVisibilityFilter 29 | ); 30 | const [columnFilters, setColumnFilters] = useState([]); 31 | const [sorting, setSorting] = useState([]); 32 | const [pagination, setPagination] = useState({ 33 | pageIndex: 0, 34 | pageSize: 10, 35 | }); 36 | 37 | const where = useMemo( 38 | () => 39 | columnFilters 40 | .filter((x) => x.id !== "name") 41 | .reduce((prev, next) => { 42 | if (next.id === "salePrice" && Array.isArray(next.value)) { 43 | // @ts-expect-error xxx 44 | prev[next.id] = { from: next.value[0], to: next.value[1] }; 45 | } else { 46 | prev[next.id] = next.value as string[]; 47 | } 48 | return prev; 49 | }, {} as Record), 50 | [columnFilters] 51 | ); 52 | 53 | const term = useMemo( 54 | () => (columnFilters.find((x) => x.id === "name")?.value as string) || "", 55 | [columnFilters] 56 | ); 57 | 58 | const facets = useMemo( 59 | () => 60 | new Facets( 61 | { 62 | textIndex: TQuickscoreIndex, 63 | schema: facetsSchema, 64 | }, 65 | data 66 | ), 67 | [data] 68 | ); 69 | 70 | const result = useMemo( 71 | () => 72 | facets.search({ 73 | query: term, 74 | facetFilter: where, 75 | sort: sorting.length 76 | ? [sorting[0].id, sorting[0].desc ? "desc" : "asc"] 77 | : undefined, 78 | page: pagination.pageIndex, 79 | perPage: pagination.pageSize, 80 | }), 81 | [term, where, sorting, pagination.pageSize, pagination.pageIndex, facets] 82 | ); 83 | 84 | const resultRef = useRef(result); 85 | try { 86 | resultRef.current = result; 87 | } catch (e) { 88 | // do nothing 89 | } 90 | 91 | const dataNew = useMemo( 92 | () => result.items || [], 93 | [result] 94 | ) as unknown as TData[]; 95 | const count = result?.pagination.total; 96 | const pageCount = 97 | count !== undefined ? Math.ceil(count / pagination.pageSize) : -1; 98 | 99 | return useReactTable({ 100 | data: dataNew, 101 | columns, 102 | state: { 103 | sorting, 104 | columnVisibility, 105 | columnFilters, 106 | pagination, 107 | }, 108 | pageCount, 109 | manualPagination: true, 110 | onPaginationChange: setPagination, 111 | onSortingChange: setSorting, 112 | onColumnFiltersChange: setColumnFilters, 113 | onColumnVisibilityChange: setColumnVisibility, 114 | getCoreRowModel: getCoreRowModel(), 115 | getFacetedUniqueValues: 116 | (_table: Table, columnId: keyof TData & string) => 117 | () => 118 | new Map( 119 | resultRef.current?.facets[columnId]?.items.map((x) => [x[0], x[1]]) 120 | ), 121 | getFacetedMinMaxValues: 122 | (_table: Table, columnId: keyof TData & string) => 123 | () => { 124 | const facet_stats = resultRef.current?.facets[columnId]?.stats; 125 | // @ts-expect-error xxx 126 | if (!facet_stats) return [] as [number, number]; 127 | return [facet_stats.min, facet_stats.max]; 128 | }, 129 | }); 130 | }; 131 | -------------------------------------------------------------------------------- /src/pages/itemsjs/useDataTableItemsjs.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useState } from "react"; 2 | import { 3 | ColumnDef, 4 | ColumnFiltersState, 5 | PaginationState, 6 | SortingState, 7 | VisibilityState, 8 | getCoreRowModel, 9 | useReactTable, 10 | Table, 11 | } from "@tanstack/react-table"; 12 | import ItemsJS from "@stereobooster/itemsjs"; 13 | import { 14 | defaultVisibilityFilter, 15 | itemsJsFacets, 16 | searchFields, 17 | } from "@/data/schema"; 18 | 19 | interface DataTableProps { 20 | data: TData[]; 21 | columns: ColumnDef[]; 22 | } 23 | 24 | export const useDataTableItemsjs = < 25 | TData extends Record, 26 | TValue 27 | >({ 28 | columns, 29 | data, 30 | }: DataTableProps) => { 31 | const [columnVisibility, setColumnVisibility] = useState( 32 | defaultVisibilityFilter 33 | ); 34 | const [columnFilters, setColumnFilters] = useState([]); 35 | const [sorting, setSorting] = useState([]); 36 | const [pagination, setPagination] = useState({ 37 | pageIndex: 0, 38 | pageSize: 10, 39 | }); 40 | 41 | const where = useMemo( 42 | () => 43 | columnFilters 44 | .filter((x) => x.id !== "name") 45 | .reduce((prev, next) => { 46 | prev[next.id] = next.value as string[]; 47 | return prev; 48 | }, {} as Record), 49 | [columnFilters] 50 | ); 51 | 52 | const term = useMemo( 53 | () => (columnFilters.find((x) => x.id === "name")?.value as string) || "", 54 | [columnFilters] 55 | ); 56 | 57 | const itemsjs = useMemo( 58 | () => 59 | ItemsJS(data, { 60 | searchableFields: searchFields, 61 | aggregations: itemsJsFacets, 62 | }), 63 | [data] 64 | ); 65 | 66 | const result = useMemo( 67 | () => 68 | itemsjs.search({ 69 | query: term.length < 2 ? "" : term, 70 | filters: where, 71 | sort: sorting.length 72 | ? { 73 | field: sorting[0].id, 74 | order: sorting[0].desc ? "desc" : "asc", 75 | } 76 | : undefined, 77 | page: pagination.pageIndex + 1, 78 | per_page: pagination.pageSize, 79 | }), 80 | [term, where, sorting, pagination.pageSize, pagination.pageIndex, itemsjs] 81 | ); 82 | 83 | const resultRef = useRef(result); 84 | try { 85 | resultRef.current = result; 86 | } catch (e) { 87 | // do nothing 88 | } 89 | 90 | const dataNew = useMemo( 91 | () => result.data.items || [], 92 | [result] 93 | ) as unknown as TData[]; 94 | const count = result?.pagination.total; 95 | const pageCount = 96 | count !== undefined ? Math.ceil(count / pagination.pageSize) : -1; 97 | 98 | return useReactTable({ 99 | data: dataNew, 100 | columns, 101 | state: { 102 | sorting, 103 | columnVisibility, 104 | columnFilters, 105 | pagination, 106 | }, 107 | pageCount, 108 | manualPagination: true, 109 | onPaginationChange: setPagination, 110 | onSortingChange: setSorting, 111 | onColumnFiltersChange: setColumnFilters, 112 | onColumnVisibilityChange: setColumnVisibility, 113 | getCoreRowModel: getCoreRowModel(), 114 | getFacetedUniqueValues: 115 | (_table: Table, columnId: keyof TData & string) => 116 | () => 117 | new Map( 118 | // @ts-expect-error xxx 119 | resultRef.current?.data.aggregations[columnId]?.buckets.map((x) => [ 120 | x["key"], 121 | x["doc_count"], 122 | ]) 123 | ), 124 | getFacetedMinMaxValues: 125 | (_table: Table, columnId: keyof TData & string) => 126 | () => { 127 | const facet_stats = 128 | // @ts-expect-error xxx 129 | resultRef.current?.data.aggregations[columnId]?.facet_stats; 130 | // @ts-expect-error xxx 131 | if (!facet_stats) return [] as [number, number]; 132 | return [facet_stats.min, facet_stats.max]; 133 | }, 134 | }); 135 | }; 136 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { X } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogClose, 114 | DialogTrigger, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { Check, ChevronDown } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | 25 | {children} 26 | 27 | 28 | 29 | 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectContent = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, children, position = "popper", ...props }, ref) => ( 37 | 38 | 49 | 56 | {children} 57 | 58 | 59 | 60 | )) 61 | SelectContent.displayName = SelectPrimitive.Content.displayName 62 | 63 | const SelectLabel = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, ...props }, ref) => ( 67 | 72 | )) 73 | SelectLabel.displayName = SelectPrimitive.Label.displayName 74 | 75 | const SelectItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef 78 | >(({ className, children, ...props }, ref) => ( 79 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | {children} 94 | 95 | )) 96 | SelectItem.displayName = SelectPrimitive.Item.displayName 97 | 98 | const SelectSeparator = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, ...props }, ref) => ( 102 | 107 | )) 108 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 109 | 110 | export { 111 | Select, 112 | SelectGroup, 113 | SelectValue, 114 | SelectTrigger, 115 | SelectContent, 116 | SelectLabel, 117 | SelectItem, 118 | SelectSeparator, 119 | } 120 | -------------------------------------------------------------------------------- /src/components/data-table-faceted-filter.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"; 2 | import { Column } from "@tanstack/react-table"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { Badge } from "@/components/ui/badge"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | Command, 9 | CommandEmpty, 10 | CommandGroup, 11 | CommandInput, 12 | CommandItem, 13 | CommandList, 14 | CommandSeparator, 15 | } from "@/components/ui/command"; 16 | import { 17 | Popover, 18 | PopoverContent, 19 | PopoverTrigger, 20 | } from "@/components/ui/popover"; 21 | import { Separator } from "@/components/ui/separator"; 22 | 23 | interface DataTableFacetedFilterProps { 24 | column?: Column; 25 | title?: string; 26 | } 27 | 28 | export function DataTableFacetedFilter({ 29 | column, 30 | title, 31 | }: DataTableFacetedFilterProps) { 32 | const facets = [...(column?.getFacetedUniqueValues()?.entries() || [])]; 33 | const selectedValuesArr = column?.getFilterValue() as string[]; 34 | const selectedValues = new Set(selectedValuesArr); 35 | 36 | return ( 37 | 38 | 39 | 74 | 75 | 76 | 77 | 78 | 79 | No results found. 80 | 81 | {facets.map(([option, count]) => { 82 | const isSelected = selectedValues.has(option); 83 | return ( 84 | { 87 | if (isSelected) { 88 | selectedValues.delete(option); 89 | } else { 90 | selectedValues.add(option); 91 | } 92 | const filterValues = Array.from(selectedValues); 93 | column?.setFilterValue( 94 | filterValues.length ? filterValues : undefined 95 | ); 96 | }} 97 | > 98 |
106 | 107 |
108 | {option} 109 | 110 | {count} 111 | 112 |
113 | ); 114 | })} 115 |
116 | {selectedValues.size > 0 && ( 117 | <> 118 | 119 | 120 | column?.setFilterValue(undefined)} 122 | className="justify-center text-center" 123 | > 124 | Clear filters 125 | 126 | 127 | 128 | )} 129 |
130 |
131 |
132 |
133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { DialogProps } from "@radix-ui/react-dialog" 3 | import { Command as CommandPrimitive } from "cmdk" 4 | import { Search } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Dialog, DialogContent } from "@/components/ui/dialog" 8 | 9 | const Command = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | Command.displayName = CommandPrimitive.displayName 23 | 24 | interface CommandDialogProps extends DialogProps {} 25 | 26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { Check, ChevronRight, Circle } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )) 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )) 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )) 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )) 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )) 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )) 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )) 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )) 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ) 179 | } 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuTrigger, 185 | DropdownMenuContent, 186 | DropdownMenuItem, 187 | DropdownMenuCheckboxItem, 188 | DropdownMenuRadioItem, 189 | DropdownMenuLabel, 190 | DropdownMenuSeparator, 191 | DropdownMenuShortcut, 192 | DropdownMenuGroup, 193 | DropdownMenuPortal, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuRadioGroup, 198 | } 199 | --------------------------------------------------------------------------------