📦 feat: Move Shared Components to @librechat/client (#8685)

* feat: init @librechat/client

* feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools

* feat: Add jotai as a peer dependency

* fix build client package

* feat: cleanup unused types from common/index.ts

- Remove 104 unused type exports from packages/client/src/common/index.ts
- Keep only 7 actually used exports (93% reduction)
- Add cleanup script with enhanced import pattern detection
- Support both named imports and namespace imports (* as t)
- Create automatic backups and comprehensive documentation
- Maintain type safety with build verification
- No breaking changes to existing code

Kept exports:
- TShowToast, Option, OptionWithIcon, DropdownValueSetter
- MentionOption, NotificationSeverity, MenuItemProps

Scripts: cleanup-common-types-safe.js, README-CLEANUP.md

* fix: cleanup

* fix: package; refactor: tsconfig

* feat: add back `recoil`

* fix: move dependencies to peerDependencies in client package

* feat: add @librechat/client as a dependency in package.json and package-lock.json

* feat: update client package configuration and dependencies

- Added new dependencies for Rollup plugins and updated existing ones in package.json and package-lock.json.
- Introduced a new Rollup configuration file for building the client package.
- Refactored build scripts to include a dedicated build command for the client.
- Updated TypeScript configuration for improved module resolution and type declaration output.
- Integrated a Toast component from the client package into the main App component.

* feat: enhance Rollup configuration for client package

- Updated terser plugin settings to preserve directives like 'use client'.
- Added custom warning handler to ignore "use client" directive warnings during the build process.

* chore: rename package/client build script command

* feat: update client package dependencies and Rollup configuration

- Added rollup-plugin-postcss to package.json and updated package-lock.json.
- Enhanced Rollup configuration to include postcss plugin for CSS handling.
- Updated index.ts to export all components from the components directory for better modularity.

* feat: add client package directory to update configuration

- Included the 'client' package directory in the update.js configuration to ensure it is recognized during updates.

* feat: export Toast component in client package

- Added export for the Toast component in index.ts to enhance modularity and accessibility of components.

* feat: /client transition to @librechat/client

* chore: fixed formatting issues

* fix: update peer dependencies in @librechat/client to prevent bundling them

* fix: correct useSprings implementation in SplitText component

* fix: circular dependencies in DataTable

* fix: add remaining peer dependencies and match actual versions previously used in `client/package.json`

* fix: correct frontend:ci script to include client package build

* chore: enhance unused package detection for @librechat/client and improve dependency extraction

* fix: add missing peer dependency for @radix-ui/react-collapsible

* chore: include "packages/client" in unused i18next keys detection

* test: update AgentFooter tests to use document.querySelector for spinner checks
test: mock window.matchMedia in setupTests.js for consistent test environment

* feat: add react-hook-form dependency and update FormInput component to use its types

* chore: linting

* refactor: remove unused defaultSelectedValues prop from MCPSelect and MultiSelect components

* chore: linting

* feat: update GitHub Actions workflow to publish @librechat/client

* chore: update GitHub Actions workflow to install and build data-provider and client dependencies

* chore: add missing @testing-library/react dependency to client package

* chore: update tsconfig.json to exclude additional test files

* chore: fix build issues, resolve latest LC changes

* chore: move MCP components outside of `~/components/ui`

* feat: implement dynamic theme system with environment variable support and Tailwind CSS integration

* chore: remove unnecessary logging of sttExternal and ttsExternal in Speech component

* chore: squashed cleanup commits

chore: move @tanstack/react-virtual to dependencies and remove recoil from package.json

chore: move dependencies to peerDependencies in package.json

feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration

feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration

refactor: reorganize exports in index.ts for improved clarity

refactor: remove unused types and interfaces from common files

refactor: update peer dependencies and improve component typings

- Removed duplicate peer dependencies from package.json and organized them.
- Updated rollup.config.js to disable TypeScript checking during the build process.
- Modified AnimatedTabs component to use React.ReactNode for label and content types, and added TypeScript workarounds for compatibility.
- Enhanced Label and Separator components to accept an optional className prop and improved prop spreading.
- Updated Slider component to include an optional className prop and refined prop handling for better type safety.

refactor: clean up client workflow and update package dependencies

refactor: update package dependencies and improve PostCSS and Rollup configurations

chore: bump version to 0.1.2 in package.json

chore: bump client version to 0.1.2 in package-lock.json

chore: bump client version to 0.1.3 and update dependencies

chore: bump client version to 0.1.4 and update @react-spring dependencies

chore: update package version to 0.1.5 and adjust peer dependencies

- Bump version in package.json from 0.1.4 to 0.1.5.
- Update peer dependency for @tanstack/react-query to allow version 5.0.0.
- Add @tanstack/react-table and @tanstack/react-virtual as dependencies.
- Update various dependencies to their latest compatible versions.
- Simplify postcss.config.js by removing unnecessary options.
- Clean up rollup.config.js by removing ignored PostCSS warnings.
- Update CheckboxButton component to cast icon as React JSX element.
- Adjust Combobox component's class names for better styling.
- Change DropdownPopup component to use React's namespace import.
- Modify InputOTP component to use 'any' type for OTPInputContext.
- Ensure displayLabel and value in ModelParameters are converted to strings.
- Update MultiSearch component's placeholder to ensure it's a string.
- Cast selectIcon in MultiSelect as React JSX element for consistency.
- Update OGDialogTemplate to cast selectText as React JSX element.
- Initialize animationRef in PixelCard with undefined for clarity.
- Add TypeScript ignore comments in Select and SelectDropDown components for Radix UI type conflicts.
- Ensure title in SelectDropDown is a string and adjust rendering of options.
- Update useLocalize hook to cast options as any for compatibility.

refactor: code structure; chore: translations cleanup

chore: remove unused imports and clean up code in NewChat component

refactor: enhance Menu component to support custom render functions for menu items

style: update itemClassName in ToolsDropdown for improved UI consistency

fix: merge conflicts

chore: update @radix-ui/react-accordion to version 1.2.11

* refactor: remove unnecessary TypeScript type assertions in AnimatedTabs, Label, Separator, and Slider components

* feat: enhance theme system with localStorage persistence and new theme atoms

* chore: bump version of @librechat/client to 0.1.7

* chore: fix ci/cd warnings/errors related to linting and unused localization keys

* chore: update dependencies for class-variance-authority, clsx, and match-sorter

* chore: bump @librechat/client to v0.1.8

* feat: add utility colors for theme customization and remove unused tailwindConfig

* v0.1.9

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Danny Avila 2025-07-27 12:19:01 -04:00 committed by GitHub
parent 97e1cdd224
commit 79197454f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
569 changed files with 7010 additions and 1848 deletions

View file

@ -0,0 +1,92 @@
{
"name": "@librechat/client",
"version": "0.1.9",
"description": "React components for LibreChat",
"main": "dist/index.js",
"module": "dist/index.es.js",
"types": "dist/types/index.d.ts",
"exports": {
".": {
"import": "./dist/index.es.js",
"require": "./dist/index.js",
"types": "./dist/types/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && rollup -c --bundleConfigAsCjs",
"build:watch": "rollup -c -w --bundleConfigAsCjs",
"dev": "rollup -c -w --bundleConfigAsCjs"
},
"peerDependencies": {
"@tanstack/react-query": "^4.28.0 || ^5.0.0",
"i18next": "^24.2.2 || ^25.3.2",
"jotai": "^2.12.5",
"react": "^18.2.0 || ^19.1.0",
"react-dom": "^18.2.0 || ^19.1.0",
"react-i18next": "^15.4.0 || ^15.6.0",
"@tanstack/react-table": "^8.11.7",
"@tanstack/react-virtual": "^3.0.0",
"@ariakit/react": "^0.4.16",
"@ariakit/react-core": "^0.4.17",
"@headlessui/react": "^2.1.2",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.0.0",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.5",
"@react-spring/web": "^10.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.6",
"i18next-browser-languagedetector": "^8.2.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"match-sorter": "^8.1.0",
"rc-input-number": "^7.4.2",
"react-hook-form": "^7.56.4",
"react-resizable-panels": "^3.0.2",
"react-textarea-autosize": "^8.4.0",
"tailwind-merge": "^1.9.1"
},
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4",
"@tanstack/react-query": "^4.28.0",
"@testing-library/react": "^14.0.0",
"@types/react": "^18.2.11",
"@types/react-dom": "^18.2.4",
"concat-with-sourcemaps": "^1.1.0",
"i18next": "^24.2.3",
"jotai": "^2.12.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.0",
"rimraf": "^5.0.1",
"rollup": "^4.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-typescript2": "^0.35.0",
"tailwindcss-radix": "^2.8.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,84 @@
// ESM bundler config for React components
import { fileURLToPath } from 'url';
import alias from '@rollup/plugin-alias';
import terser from '@rollup/plugin-terser';
import postcss from 'rollup-plugin-postcss';
import replace from '@rollup/plugin-replace';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from 'rollup-plugin-typescript2';
import { dirname, resolve as pathResolve } from 'path';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import pkg from './package.json';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const plugins = [
peerDepsExternal(),
alias({
entries: [{ find: '~', replacement: pathResolve(__dirname, 'src') }],
}),
resolve({
extensions: ['.js', '.jsx', '.ts', '.tsx'],
browser: true,
preferBuiltins: false,
}),
replace({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
preventAssignment: true,
}),
commonjs(),
postcss({
extract: false,
inject: true,
minimize: process.env.NODE_ENV === 'production',
modules: false,
config: {
path: './postcss.config.js',
},
}),
typescript({
tsconfig: './tsconfig.json',
useTsconfigDeclarationDir: true,
clean: true,
check: false,
}),
terser({
compress: {
directives: false,
},
}),
];
export default {
input: 'src/index.ts',
output: [
{
file: pkg.main,
format: 'cjs',
sourcemap: true,
exports: 'named',
},
{
file: pkg.module,
format: 'esm',
sourcemap: true,
exports: 'named',
},
],
external: [
...Object.keys(pkg.peerDependencies || {}),
'react/jsx-runtime',
'react/jsx-dev-runtime',
],
preserveSymlinks: true,
plugins,
onwarn(warning, warn) {
// Ignore "use client" directive warnings
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
return;
}
warn(warning);
},
};

View file

@ -0,0 +1,21 @@
import { createContext, useContext, ReactNode } from 'react';
import type { TShowToast } from '~/common';
import useToast from '~/hooks/useToast';
type ToastContextType = {
showToast: ({ message, severity, showIcon, duration }: TShowToast) => void;
};
export const ToastContext = createContext<ToastContextType>({
showToast: () => ({}),
});
export function useToastContext() {
return useContext(ToastContext);
}
export default function ToastProvider({ children }: { children: ReactNode }) {
const { showToast } = useToast();
return <ToastContext.Provider value={{ showToast }}>{children}</ToastContext.Provider>;
}

View file

@ -0,0 +1,2 @@
export { default as ToastProvider } from './ToastContext';
export * from './ToastContext';

View file

@ -0,0 +1,11 @@
export type {
TShowToast,
Option,
OptionWithIcon,
DropdownValueSetter,
MentionOption,
} from './types';
export { NotificationSeverity } from './types';
export type { MenuItemProps } from './menus';

View file

@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type RenderProp<
P = React.HTMLAttributes<any> & {
ref?: React.Ref<any>;
},
> = (props: P) => React.ReactNode;
export interface MenuItemProps {
id?: string;
label?: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => void;
icon?: React.ReactNode;
kbd?: string;
show?: boolean;
disabled?: boolean;
separate?: boolean;
hideOnClick?: boolean;
dialog?: React.ReactElement;
ref?: React.Ref<any>;
render?:
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
| undefined;
}

View file

@ -0,0 +1,33 @@
export enum NotificationSeverity {
INFO = 'info',
SUCCESS = 'success',
WARNING = 'warning',
ERROR = 'error',
}
export type TShowToast = {
message: string;
severity?: NotificationSeverity;
showIcon?: boolean;
duration?: number;
status?: 'error' | 'success' | 'warning' | 'info';
};
export type Option = Record<string, unknown> & {
label?: string;
value: string | number | null;
};
export type OptionWithIcon = Option & { icon?: React.ReactNode };
export type DropdownValueSetter = (value: string | Option | OptionWithIcon) => void;
export type MentionOption = OptionWithIcon & {
type: string;
value: string;
description?: string;
};
export interface SelectedValues {
endpoint: string | null;
model: string | null;
modelSpec: string | null;
}

View file

@ -0,0 +1,50 @@
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { cn } from '~/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 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground 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 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View file

@ -0,0 +1,131 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '~/utils';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
type AlertPortalProps = AlertDialogPrimitive.AlertDialogPortalProps & { className?: string };
const AlertDialogPortal = ({ className = '', children, ...props }: AlertPortalProps) => (
<AlertDialogPrimitive.Portal className={cn(className)} {...(props as AlertPortalProps)}>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
{children}
</div>
</AlertDialogPrimitive.Portal>
);
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className = '', ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-gray-500/90 transition-opacity animate-in fade-in dark:bg-gray-800/90',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className = '', ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 grid w-full max-w-lg scale-100 gap-4 bg-white p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full',
'dark:bg-gray-900',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className = '', ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-gray-900', 'dark:text-gray-50', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className = '', ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-gray-500', 'dark:text-gray-400', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className = '', ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-gray-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-100 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
className,
)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className = '', ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
'mt-2 inline-flex h-10 items-center justify-center rounded-md border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -0,0 +1,79 @@
import React from 'react';
import { Search } from 'lucide-react';
import { cn } from '~/utils';
const AnimatedSearchInput = ({
value,
onChange,
isSearching: searching,
placeholder,
}: {
value?: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
isSearching?: boolean;
placeholder: string;
}) => {
const isSearching = searching === true;
const hasValue = value != null && value.length > 0;
return (
<div className="relative w-full">
<div className="relative rounded-lg transition-all duration-500 ease-in-out">
<div className="relative">
{/* Icon on the left */}
<div className="absolute left-3 top-1/2 z-50 -translate-y-1/2">
<Search
className={cn(
`h-4 w-4 transition-all duration-500 ease-in-out`,
isSearching && hasValue ? 'text-blue-400' : 'text-gray-400',
)}
/>
</div>
{/* Input field */}
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
className={`peer relative z-20 w-full rounded-lg bg-surface-secondary px-10 py-2 outline-none ring-0 backdrop-blur-sm transition-all duration-500 ease-in-out placeholder:text-gray-400 focus:outline-none focus:ring-0`}
/>
{/* Gradient overlay */}
<div
className={`pointer-events-none absolute inset-0 z-20 rounded-lg bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20 transition-all duration-500 ease-in-out ${isSearching && hasValue ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'} `}
/>
{/* Animated loading indicator */}
<div
className={`absolute right-3 top-1/2 z-20 -translate-y-1/2 transition-all duration-500 ease-in-out ${isSearching && hasValue ? 'scale-100 opacity-100' : 'scale-0 opacity-0'} `}
>
<div className="relative h-2 w-2">
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
<div className="absolute inset-0 rounded-full bg-blue-500" />
</div>
</div>
</div>
</div>
{/* Outer glow effect */}
<div
className={`absolute -inset-8 -z-10 transition-all duration-700 ease-in-out ${isSearching && hasValue ? 'scale-105 opacity-100' : 'scale-100 opacity-0'} `}
>
<div className="absolute inset-0">
<div
className={`bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent transition-opacity duration-700 ease-in-out ${isSearching && hasValue ? 'animate-pulse-slow opacity-100' : 'opacity-0'} `}
/>
<div
className={`absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5 blur-xl transition-all duration-700 ease-in-out ${isSearching && hasValue ? 'animate-gradient-x opacity-100' : 'opacity-0'} `}
/>
</div>
</div>
<div
className={`absolute inset-0 -z-20 scale-100 bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-blue-500/10 opacity-0 blur-xl transition-all duration-500 ease-in-out peer-focus:scale-105 peer-focus:opacity-100`}
/>
</div>
);
};
export default AnimatedSearchInput;

View file

@ -0,0 +1,57 @@
/* AnimatedTabs.css */
.animated-tab-panel {
transition-property: opacity, translate;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
animation-duration: 300ms;
}
/* Sliding underline animation for tabs */
.animated-tab-list {
position: relative;
}
.animated-tab-list::after {
content: '';
position: absolute;
bottom: 0;
height: 2px;
background-color: currentColor; /* Inherit color from active tab */
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
left: var(--tab-left, 0);
width: var(--tab-width, 0);
}
.animated-tab {
position: relative;
}
.animated-tab[data-state='active'] {
border-bottom-color: transparent !important;
}
.animated-tab-panel[data-enter] {
opacity: 1 !important;
translate: 0 !important;
}
@media (prefers-reduced-motion: reduce) {
.animated-tab-panel {
transition: none;
}
}
.animated-tab-panel:not([data-open]) {
position: absolute;
top: 0px;
}
.animated-panels:has(> [data-was-open]) > .animated-tab-panel {
opacity: 0;
translate: -100%;
}
.animated-panels [data-was-open] ~ .animated-tab-panel,
.animated-panels [data-open] ~ .animated-tab-panel {
translate: 100%;
}

View file

@ -0,0 +1,162 @@
import * as Ariakit from '@ariakit/react';
import { forwardRef, useEffect, useRef } from 'react';
import type { ElementRef } from 'react';
import { cn } from '~/utils';
import './AnimatedTabs.css';
export interface TabItem {
id?: string;
label: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
}
export interface AnimatedTabsProps {
tabs: TabItem[];
className?: string;
tabListClassName?: string;
tabClassName?: string;
tabPanelClassName?: string;
tabListProps?: Ariakit.TabListProps;
containerClassName?: string;
defaultSelectedId?: string;
}
function usePrevious<T>(value: T) {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
const Tab = forwardRef<ElementRef<typeof Ariakit.Tab>, Ariakit.TabProps>(function Tab(props, ref) {
const tabRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const tabElement = tabRef.current;
if (!tabElement) return;
const updateState = () => {
const isSelected = tabElement.getAttribute('aria-selected') === 'true';
tabElement.setAttribute('data-state', isSelected ? 'active' : 'inactive');
};
updateState();
const observer = new MutationObserver(updateState);
observer.observe(tabElement, { attributes: true, attributeFilter: ['aria-selected'] });
return () => observer.disconnect();
}, []);
return (
<Ariakit.Tab
ref={(node) => {
// Forward the ref to both our local ref and the provided ref
tabRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
{...props}
className={`animated-tab aria-selected:text-token-text-primary flex select-none items-center justify-center gap-2 whitespace-nowrap border-none text-sm font-medium outline-none transition-colors aria-disabled:opacity-50 ${props.className || ''}`}
/>
);
});
const TabPanel = forwardRef<ElementRef<typeof Ariakit.TabPanel>, Ariakit.TabPanelProps>(
function TabPanel(props, ref) {
const tab = Ariakit.useTabContext();
const previousTabId = usePrevious(Ariakit.useStoreState(tab, 'selectedId'));
const wasOpen = props.tabId && previousTabId === props.tabId;
return (
<Ariakit.TabPanel
ref={ref}
{...props}
data-was-open={wasOpen || undefined}
className={`animated-tab-panel max-w-full ${props.className || ''}`}
/>
);
},
);
export function AnimatedTabs({
tabs,
className = '',
tabListClassName = '',
tabClassName = '',
tabPanelClassName = '',
containerClassName = '',
tabListProps = {},
defaultSelectedId,
}: AnimatedTabsProps) {
const tabIds = tabs.map((tab, index) => tab.id || `tab-${index}`);
const firstTabId = defaultSelectedId || tabIds[0];
const tabListRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const tabList = tabListRef.current;
if (!tabList) return;
// Function to update the underline position
const updateUnderline = () => {
const activeTab = tabList.querySelector('[data-state="active"]') as HTMLElement;
if (!activeTab) return;
tabList.style.setProperty('--tab-left', `${activeTab.offsetLeft}px`);
tabList.style.setProperty('--tab-width', `${activeTab.offsetWidth}px`);
};
updateUnderline();
const observer = new MutationObserver(updateUnderline);
observer.observe(tabList, { attributes: true, subtree: true, attributeFilter: ['data-state'] });
return () => observer.disconnect();
}, [tabs]);
return (
<div className={`w-full ${className}`}>
<Ariakit.TabProvider defaultSelectedId={firstTabId}>
<Ariakit.TabList
ref={tabListRef}
aria-label="Tabs"
className={`animated-tab-list flex py-1 ${tabListClassName}`}
{...tabListProps}
>
{tabs.map((tab, index) => (
<Tab
key={tabIds[index]}
id={tabIds[index]}
disabled={tab.disabled}
className={tabClassName}
data-state={tabIds[index] === firstTabId ? 'active' : 'inactive'}
>
{/* TypeScript workaround for React i18next children type compatibility */}
{tab.label}
</Tab>
))}
</Ariakit.TabList>
<div
className={cn(
'animated-panels relative flex w-full flex-col items-center overflow-hidden p-0',
containerClassName,
)}
>
{tabs.map((tab, index) => (
<TabPanel
key={`panel-${tabIds[index]}`}
id={`panel-${tabIds[index]}`}
tabId={tabIds[index]}
className={tabPanelClassName}
>
{/* TypeScript workaround for React i18next children type compatibility */}
{tab.content}
</TabPanel>
))}
</div>
</Ariakit.TabProvider>
</div>
);
}

View file

@ -0,0 +1,100 @@
import type React from 'react';
import { X, Plus } from 'lucide-react';
import { motion } from 'framer-motion';
import type { ButtonHTMLAttributes } from 'react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '~/utils';
interface BadgeProps
extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'
> {
icon?: LucideIcon;
label: string;
id?: string;
isActive?: boolean;
isEditing?: boolean;
isDragging?: boolean;
isAvailable: boolean;
isInChat?: boolean;
onBadgeAction?: () => void;
onToggle?: () => void;
}
export default function Badge({
icon: Icon,
label,
id,
isActive = false,
isEditing = false,
isDragging = false,
isAvailable = true,
isInChat = false,
onBadgeAction,
onToggle,
className,
...props
}: BadgeProps) {
const isMoveable = isEditing && isAvailable;
const isDisabled = id === '1' && isInChat;
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (!isEditing && onToggle) {
e.preventDefault();
e.stopPropagation();
onToggle();
}
};
return (
<motion.button
onClick={handleClick}
className={cn(
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
'border border-border-medium text-sm font-medium transition-shadow md:w-full',
'size-9 p-2 md:p-3',
isActive
? 'bg-surface-active shadow-md'
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:scale-95 active:shadow-inner',
isMoveable && 'cursor-move',
isDisabled && 'cursor-not-allowed opacity-50 hover:shadow-sm',
className,
)}
animate={{
scale: isDragging ? 1.1 : 1,
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
}}
whileTap={{ scale: isDragging ? 1.1 : isDisabled ? 1 : 0.97 }}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
{...(props as React.ComponentProps<typeof motion.button>)}
>
{Icon && <Icon className={cn('relative h-5 w-5 md:h-4 md:w-4', !label && 'mx-auto')} />}
<span className="relative hidden md:inline">{label}</span>
{isEditing && !isDragging && (
<motion.button
className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm md:h-5 md:w-5"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
whileTap={{ scale: 0.9 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onBadgeAction?.();
}}
>
{isAvailable ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</motion.button>
)}
</motion.button>
);
}

View file

@ -0,0 +1,100 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '~/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
),
);
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View file

@ -0,0 +1,52 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg 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',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover',
outline:
'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-surface-hover hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
// hardcoded text color because of WCAG contrast issues (text-white)
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-lg px-3',
lg: 'h-11 rounded-lg px-8',
icon: 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View file

@ -0,0 +1,25 @@
import * as React from 'react';
import { Check } from 'lucide-react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from '~/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className = '', ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View file

@ -0,0 +1,76 @@
import * as React from 'react';
import { useEffect } from 'react';
import { Checkbox, useStoreState, useCheckboxStore } from '@ariakit/react';
import { cn } from '~/utils';
const CheckboxButton = React.forwardRef<
HTMLInputElement,
{
icon?: React.ReactNode;
label: string;
className?: string;
checked?: boolean;
defaultChecked?: boolean;
isCheckedClassName?: string;
setValue?: (values: {
e?: React.ChangeEvent<HTMLInputElement>;
value: boolean | string;
}) => void;
}
>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => {
const checkbox = useCheckboxStore();
const isChecked = useStoreState(checkbox, (state) => state?.value);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
if (typeof isChecked !== 'boolean') {
return;
}
setValue?.({ e, value: !isChecked });
};
// Sync with controlled checked prop
useEffect(() => {
if (checked !== undefined) {
checkbox.setValue(checked);
}
}, [checked, checkbox]);
// Set initial value from defaultChecked
useEffect(() => {
if (defaultChecked !== undefined && checked === undefined) {
checkbox.setValue(defaultChecked);
}
}, [defaultChecked, checked, checkbox]);
return (
<Checkbox
ref={ref}
store={checkbox}
onChange={onChange}
className={cn(
// Base styling from MultiSelect's selectClassName
'group relative inline-flex items-center justify-center gap-1.5',
'rounded-full border border-border-medium text-sm font-medium',
'size-9 p-2 transition-all md:w-full md:p-3',
'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
// Checked state styling
isChecked && isCheckedClassName && isCheckedClassName,
// Additional custom classes
className,
)}
render={<button type="button" aria-label={label} />}
>
{/* Icon if provided */}
{icon && <span className="icon-md text-text-primary">{icon as React.JSX.Element}</span>}
{/* Show the label on larger screens */}
<span className="hidden truncate md:block">{label}</span>
</Checkbox>
);
});
CheckboxButton.displayName = 'CheckboxButton';
export default CheckboxButton;

View file

@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -0,0 +1,176 @@
import { startTransition } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import * as RadixSelect from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
import {
Combobox,
ComboboxItem,
ComboboxList,
ComboboxProvider,
ComboboxCancel,
} from '@ariakit/react';
import type { OptionWithIcon } from '~/common';
import { SelectTrigger, SelectValue, SelectScrollDownButton } from './Select';
import { useCombobox } from '~/hooks';
import { cn } from '~/utils';
export default function ComboboxComponent({
selectedValue,
displayValue,
items,
setValue,
ariaLabel,
searchPlaceholder,
selectPlaceholder,
isCollapsed,
SelectIcon,
}: {
ariaLabel: string;
displayValue?: string;
selectedValue: string;
searchPlaceholder?: string;
selectPlaceholder?: string;
items: OptionWithIcon[] | string[];
setValue: (value: string) => void;
isCollapsed: boolean;
SelectIcon?: React.ReactNode;
}) {
const options: OptionWithIcon[] = (items ?? []).map((option: string | OptionWithIcon) => {
if (typeof option === 'string') {
return { label: option, value: option };
}
return option;
});
const { open, setOpen, setSearchValue, matches } = useCombobox({
value: selectedValue,
options,
});
return (
<RadixSelect.Root
value={selectedValue}
onValueChange={setValue}
open={open}
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
onOpenChange={() => {
if (open === true) {
setOpen(false);
return;
}
setTimeout(() => {
setOpen(!open);
}, 75);
}}
>
<ComboboxProvider
open={open}
setOpen={setOpen}
resetValueOnHide
includesBaseElement={false}
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
>
<SelectTrigger
aria-label={ariaLabel}
className={cn(
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
isCollapsed
? 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
: '',
'bg-white text-black hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-gray-500 dark:bg-gray-850 dark:text-white',
)}
>
<SelectValue placeholder={selectPlaceholder}>
<div className="assistant-item flex items-center justify-center overflow-hidden rounded-full">
{SelectIcon ? SelectIcon : <ChevronDownIcon />}
</div>
<span
className={cn('ml-2', isCollapsed ? 'hidden' : '')}
style={{ userSelect: 'none' }}
>
{selectedValue
? (displayValue ?? selectedValue)
: selectPlaceholder && selectPlaceholder}
</span>
</SelectValue>
</SelectTrigger>
<RadixSelect.Portal>
<RadixSelect.Content
role="dialog"
aria-label={ariaLabel + 's'}
position="popper"
className={cn(
'bg-popover text-popover-foreground relative z-50 max-h-[52vh] min-w-[8rem] overflow-hidden rounded-md border border-gray-200 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-600',
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
'bg-white dark:bg-gray-700',
)}
>
<RadixSelect.Viewport className="mb-5 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
<div className="group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-white px-3 py-2 text-black duration-300 dark:bg-gray-700 dark:text-white">
<SearchIcon className="h-4 w-4 text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300" />
<Combobox
autoSelect
placeholder={searchPlaceholder}
className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-700/10 dark:focus:ring-gray-200/10"
// Ariakit's Combobox manually triggers a blur event on virtually
// blurred items, making them work as if they had actual DOM
// focus. These blur events might happen after the corresponding
// focus events in the capture phase, leading Radix Select to
// close the popover. This happens because Radix Select relies on
// the order of these captured events to discern if the focus was
// outside the element. Since we don't have access to the
// onInteractOutside prop in the Radix SelectContent component to
// stop this behavior, we can turn off Ariakit's behavior here.
onBlurCapture={(event) => {
event.preventDefault();
event.stopPropagation();
}}
/>
<ComboboxCancel
hideWhenEmpty={true}
className="relative flex h-5 w-5 items-center justify-end text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300"
/>
</div>
<ComboboxList className="overflow-y-auto p-1 py-2">
{matches.map(({ label, value, icon }) => (
<RadixSelect.Item key={value} value={`${value ?? ''}`} asChild>
<ComboboxItem
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'rounded-lg hover:bg-gray-100/50 hover:bg-gray-50 dark:text-white dark:hover:bg-gray-600',
)}
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
onTouchEnd={() => {
setValue(`${value ?? ''}`);
setOpen(false);
}}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</RadixSelect.ItemIndicator>
</span>
<RadixSelect.ItemText>
<div className="flex items-center justify-center gap-3 dark:text-white [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
<div className="assistant-item overflow-hidden rounded-full">
{icon && icon}
</div>
{label}
</div>
</RadixSelect.ItemText>
</ComboboxItem>
</RadixSelect.Item>
))}
</ComboboxList>
</RadixSelect.Viewport>
<SelectScrollDownButton className="absolute bottom-0 left-0 right-0" />
</RadixSelect.Content>
</RadixSelect.Portal>
</ComboboxProvider>
</RadixSelect.Root>
);
}

View file

@ -0,0 +1,182 @@
import * as Ariakit from '@ariakit/react';
import { matchSorter } from 'match-sorter';
import { Search, ChevronDown } from 'lucide-react';
import { useMemo, useState, useRef, memo, useEffect } from 'react';
import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
import type { OptionWithIcon } from '~/common';
import { cn } from '~/utils';
interface ControlComboboxProps {
selectedValue: string;
displayValue?: string;
items: OptionWithIcon[];
setValue: (value: string) => void;
ariaLabel: string;
searchPlaceholder?: string;
selectPlaceholder?: string;
isCollapsed: boolean;
SelectIcon?: React.ReactNode;
containerClassName?: string;
iconClassName?: string;
showCarat?: boolean;
className?: string;
disabled?: boolean;
iconSide?: 'left' | 'right';
selectId?: string;
}
const ROW_HEIGHT = 36;
function ControlCombobox({
selectedValue,
displayValue,
items,
setValue,
ariaLabel,
searchPlaceholder,
selectPlaceholder,
containerClassName,
isCollapsed,
SelectIcon,
showCarat,
className,
disabled,
iconClassName,
iconSide = 'left',
selectId,
}: ControlComboboxProps) {
const [searchValue, setSearchValue] = useState('');
const buttonRef = useRef<HTMLButtonElement>(null);
const [buttonWidth, setButtonWidth] = useState<number | null>(null);
const getItem = (option: OptionWithIcon) => ({
id: `item-${option.value}`,
value: option.value as string | undefined,
label: option.label,
icon: option.icon,
});
const combobox = Ariakit.useComboboxStore({
defaultItems: items.map(getItem),
resetValueOnHide: true,
value: searchValue,
setValue: setSearchValue,
});
const select = Ariakit.useSelectStore({
combobox,
defaultItems: items.map(getItem),
value: selectedValue,
setValue,
});
const matches = useMemo(() => {
const filteredItems = matchSorter(items, searchValue, {
keys: ['value', 'label'],
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
});
return filteredItems.map(getItem);
}, [searchValue, items]);
useEffect(() => {
if (buttonRef.current && !isCollapsed) {
setButtonWidth(buttonRef.current.offsetWidth);
}
}, [isCollapsed]);
const selectIconClassName = cn(
'flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
iconClassName,
);
const optionIconClassName = cn(
'mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
iconClassName,
);
return (
<div className={cn('flex w-full items-center justify-center px-1', containerClassName)}>
<Ariakit.SelectLabel store={select} className="sr-only">
{ariaLabel}
</Ariakit.SelectLabel>
<Ariakit.Select
ref={buttonRef}
store={select}
id={selectId}
disabled={disabled}
className={cn(
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
'text-text-primary hover:bg-surface-tertiary',
'border border-border-light',
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-xl px-3 py-2 text-sm',
className,
)}
>
{SelectIcon != null && iconSide === 'left' && (
<div className={selectIconClassName}>{SelectIcon}</div>
)}
{!isCollapsed && (
<>
<span className="flex-grow truncate text-left">
{displayValue != null
? displayValue || selectPlaceholder
: selectedValue || selectPlaceholder}
</span>
{SelectIcon != null && iconSide === 'right' && (
<div className={selectIconClassName}>{SelectIcon}</div>
)}
{showCarat && <ChevronDown className="h-4 w-4 text-text-secondary" />}
</>
)}
</Ariakit.Select>
<Ariakit.SelectPopover
store={select}
gutter={4}
portal
className={cn(
'animate-popover z-50 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
)}
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
>
<div className="py-1.5">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
<Ariakit.Combobox
store={combobox}
autoSelect
placeholder={searchPlaceholder}
className="w-full rounded-md bg-surface-secondary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
/>
</div>
</div>
<div className="max-h-[300px] overflow-auto">
<Ariakit.ComboboxList store={combobox}>
<SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}>
{({ value, icon, label, ...item }) => (
<Ariakit.ComboboxItem
key={item.id}
{...item}
className={cn(
'flex w-full cursor-pointer items-center px-3 text-sm',
'text-text-primary hover:bg-surface-tertiary',
'data-[active-item]:bg-surface-tertiary',
)}
render={<Ariakit.SelectItem value={value} />}
>
{icon != null && iconSide === 'left' && (
<div className={optionIconClassName}>{icon}</div>
)}
<span className="flex-grow truncate text-left">{label}</span>
{icon != null && iconSide === 'right' && (
<div className={optionIconClassName}>{icon}</div>
)}
</Ariakit.ComboboxItem>
)}
</SelectRenderer>
</Ariakit.ComboboxList>
</div>
</Ariakit.SelectPopover>
</div>
);
}
export default memo(ControlCombobox);

View file

@ -0,0 +1,468 @@
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Row,
ColumnDef,
flexRender,
SortingState,
useReactTable,
getCoreRowModel,
VisibilityState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from '@tanstack/react-table';
import type { Table as TTable } from '@tanstack/react-table';
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
import AnimatedSearchInput from './AnimatedSearchInput';
import { TrashIcon, Spinner } from '~/svgs';
import { useMediaQuery } from '~/hooks';
import { Skeleton } from './Skeleton';
import { Checkbox } from './Checkbox';
import { Button } from './Button';
import { cn } from '~/utils';
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
meta?: {
size?: string | number;
mobileSize?: string | number;
minWidth?: string | number;
};
};
const SelectionCheckbox = memo(
({
checked,
onChange,
ariaLabel,
}: {
checked: boolean;
onChange: (value: boolean) => void;
ariaLabel: string;
}) => (
<div
role="button"
tabIndex={0}
onKeyDown={(e) => e.stopPropagation()}
className="flex h-full w-[30px] items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
</div>
),
);
SelectionCheckbox.displayName = 'SelectionCheckbox';
interface DataTableProps<TData, TValue> {
columns: TableColumn<TData, TValue>[];
data: TData[];
onDelete?: (selectedRows: TData[]) => Promise<void>;
filterColumn?: string;
defaultSort?: SortingState;
columnVisibilityMap?: Record<string, string>;
className?: string;
pageSize?: number;
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage?: (options?: unknown) => Promise<unknown>;
enableRowSelection?: boolean;
showCheckboxes?: boolean;
onFilterChange?: (value: string) => void;
filterValue?: string;
isLoading?: boolean;
enableSearch?: boolean;
}
const TableRowComponent = <TData, TValue>({
row,
isSmallScreen,
onSelectionChange,
index,
isSearching,
}: {
row: Row<TData>;
isSmallScreen: boolean;
onSelectionChange?: (rowId: string, selected: boolean) => void;
index: number;
isSearching: boolean;
}) => {
const handleSelection = useCallback(
(value: boolean) => {
row.toggleSelected(value);
onSelectionChange?.(row.id, value);
},
[row, onSelectionChange],
);
return (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
style={{
animationDelay: `${index * 20}ms`,
transform: `translateY(${isSearching ? '4px' : '0'})`,
opacity: isSearching ? 0.5 : 1,
}}
>
{row.getVisibleCells().map((cell) => {
if (cell.column.id === 'select') {
return (
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={handleSelection}
ariaLabel="Select row"
/>
</TableCell>
);
}
return (
<TableCell
key={cell.id}
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
style={getColumnStyle(
cell.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
>
<div className="overflow-hidden text-ellipsis">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
);
})}
</TableRow>
);
};
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
function getColumnStyle<TData, TValue>(
column: TableColumn<TData, TValue>,
isSmallScreen: boolean,
): React.CSSProperties {
return {
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
minWidth: column.meta?.minWidth,
maxWidth: column.meta?.size,
};
}
const DeleteButton = memo(
({
onDelete,
isDeleting,
disabled,
isSmallScreen,
}: {
onDelete?: () => Promise<void>;
isDeleting: boolean;
disabled: boolean;
isSmallScreen: boolean;
}) => {
if (!onDelete) {
return null;
}
return (
<Button
variant="outline"
onClick={onDelete}
disabled={disabled}
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
>
{isDeleting ? (
<Spinner className="size-4" />
) : (
<>
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
{!isSmallScreen && <span className="ml-2">Delete</span>}
</>
)}
</Button>
);
},
);
export default function DataTable<TData, TValue>({
columns,
data,
onDelete,
filterColumn,
defaultSort = [],
className = '',
isFetchingNextPage = false,
hasNextPage = false,
fetchNextPage,
enableRowSelection = true,
showCheckboxes = true,
onFilterChange,
filterValue,
isLoading,
enableSearch = true,
}: DataTableProps<TData, TValue>) {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const tableContainerRef = useRef<HTMLDivElement>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [sorting, setSorting] = useState<SortingState>(defaultSort);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
const [isSearching, setIsSearching] = useState(false);
const tableColumns = useMemo(() => {
if (!enableRowSelection || !showCheckboxes) {
return columns;
}
const selectColumn = {
id: 'select',
header: ({ table }: { table: TTable<TData> }) => (
<div className="flex h-full w-[30px] items-center justify-center">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
aria-label="Select all"
/>
</div>
),
cell: ({ row }: { row: Row<TData> }) => (
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={(value) => row.toggleSelected(value)}
ariaLabel="Select row"
/>
),
meta: { size: '50px' },
};
return [selectColumn, ...columns];
}, [columns, enableRowSelection, showCheckboxes]);
const table = useReactTable({
data,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection,
enableMultiRowSelection: true,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: useCallback(() => 48, []),
overscan: 10,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
useEffect(() => {
const scrollElement = tableContainerRef.current;
if (!scrollElement) {
return;
}
const handleScroll = async () => {
if (!hasNextPage || isFetchingNextPage) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
try {
// Safely fetch next page without breaking if lastPage is undefined
await fetchNextPage?.();
} catch (error) {
console.error('Unable to fetch next page:', error);
}
}
};
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
return () => scrollElement.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
useEffect(() => {
setIsSearching(true);
const timeout = setTimeout(() => {
onFilterChange?.(searchTerm);
setIsSearching(false);
}, 300);
return () => clearTimeout(timeout);
}, [searchTerm, onFilterChange]);
const handleDelete = useCallback(async () => {
if (!onDelete) {
return;
}
setIsDeleting(true);
try {
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
await onDelete(itemsToDelete);
setRowSelection({});
} finally {
setIsDeleting(false);
}
}, [onDelete, table]);
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 13 }, (_, index) => {
const randomWidth = getRandomWidth();
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
return (
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
{tableColumns.map((column, columnIndex) => {
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
return (
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
<Skeleton
className="h-6"
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
/>
</TableCell>
);
})}
</TableRow>
);
});
return (
<div className={cn('flex h-full flex-col gap-4', className)}>
{/* Table controls */}
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
{enableRowSelection && showCheckboxes && (
<DeleteButton
onDelete={handleDelete}
isDeleting={isDeleting}
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
isSmallScreen={isSmallScreen}
/>
)}
{filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && (
<div className="relative flex-1">
<AnimatedSearchInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
isSearching={isSearching}
placeholder="Search..."
/>
</div>
)}
</div>
{/* Virtualized table */}
<div
ref={tableContainerRef}
className={cn(
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
'transition-all duration-300 ease-out',
isSearching && 'bg-surface-secondary/50',
className,
)}
>
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-border-light">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
style={getColumnStyle(
header.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{isLoading && skeletons}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<MemoizedTableRow
key={row.id}
row={row}
isSmallScreen={isSmallScreen}
index={virtualRow.index}
isSearching={isSearching}
/>
);
})}
{!virtualRows.length && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4 text-center">
No data available
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
{/* Loading indicator */}
{(isFetchingNextPage || hasNextPage) && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4">
<div className="flex h-full items-center justify-center">
{isFetchingNextPage ? (
<Spinner className="size-4" />
) : (
hasNextPage && <div className="h-6" />
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { Column } from '@tanstack/react-table';
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './DropdownMenu';
import { Button } from './Button';
import { cn } from '~/utils';
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className = '',
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className="ml-2 h-4 w-4" />
) : (
<CaretSortIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[1001]">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,12 @@
import React from 'react';
import { useDelayedRender } from '~/hooks';
interface DelayedRenderProps {
delay: number;
children: React.ReactNode;
}
const DelayedRender = ({ delay, children }: DelayedRenderProps) =>
useDelayedRender(delay)(() => children);
export default DelayedRender;

View file

@ -0,0 +1,170 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { useMediaQuery } from '~/hooks';
import { Button } from './Button';
import { X } from 'lucide-react';
import { cn } from '~/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
type DialogPortalProps = DialogPrimitive.DialogPortalProps & { className?: string };
const DialogPortal = ({ className = '', children, ...props }: DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...(props as DialogPortalProps)}>
<div className="fixed inset-0 z-[999] flex items-start justify-center sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[999] bg-gray-600/65 transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in dark:bg-black/80',
className ?? '',
)}
{...props}
ref={ref}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
disableScroll?: boolean;
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(
(
{ className, children = true, showCloseButton = true, disableScroll = false, ...props },
ref,
) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-[999] grid w-full gap-4 rounded-b-lg bg-white pb-6 animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:rounded-lg',
'dark:bg-gray-700',
isSmallScreen
? 'fixed left-1/2 top-1/2 z-[999] m-auto grid w-11/12 -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-white pb-6'
: '',
disableScroll ? 'overflow-hidden' : '',
className ?? '',
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-6 top-[1.6rem] rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-100 dark:focus:ring-white dark:focus:ring-offset-gray-700 dark:data-[state=open]:bg-gray-800">
<X className="h-5 w-5 text-black dark:text-white" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
},
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 border-b border-black/10 p-6 pb-4 text-left dark:border-white/10',
className ?? '',
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-row justify-between space-x-2 px-6 py-4', className ?? '')}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-gray-900', 'dark:text-gray-50', className ?? '')}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-gray-500', 'dark:text-gray-400', className ?? '')}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
const DialogClose = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Close
ref={ref}
className={cn(
'mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 sm:mt-0',
className ?? '',
/* Important: for accessibility */
'focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
)}
{...props}
/>
));
DialogClose.displayName = DialogPrimitive.Title.displayName;
const DialogButton = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentPropsWithoutRef<typeof Button>
>(({ className, ...props }, ref) => (
<Button
ref={ref}
variant="outline"
className={cn(
'mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 sm:mt-0',
className ?? '',
/* Important: for accessibility */
'focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
)}
{...props}
/>
));
DialogButton.displayName = DialogPrimitive.Title.displayName;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
DialogButton,
};

View file

@ -0,0 +1,88 @@
import 'test/matchMedia.mock';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import DialogTemplate from './DialogTemplate';
import { Dialog } from '@radix-ui/react-dialog';
import { Provider } from 'jotai';
describe('DialogTemplate', () => {
let mockSelectHandler: jest.Mock;
beforeEach(() => {
mockSelectHandler = jest.fn();
});
it('renders correctly with all props', () => {
const { getByText } = render(
<Provider>
<Dialog
open
data-testid="test-dialog"
onOpenChange={() => {
return;
}}
>
<DialogTemplate
title="Test Dialog"
description="Test Description"
main={<div>Main Content</div>}
buttons={<button>Button</button>}
leftButtons={<button>Left Button</button>}
selection={{ selectHandler: mockSelectHandler, selectText: 'Select' }}
/>
</Dialog>
</Provider>,
);
expect(getByText('Test Dialog')).toBeInTheDocument();
expect(getByText('Test Description')).toBeInTheDocument();
expect(getByText('Main Content')).toBeInTheDocument();
expect(getByText('Button')).toBeInTheDocument();
expect(getByText('Left Button')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
expect(getByText('Select')).toBeInTheDocument();
});
it('renders correctly without optional props', () => {
const { queryByText } = render(
<Provider>
<Dialog
open
onOpenChange={() => {
return;
}}
></Dialog>
</Provider>,
);
expect(queryByText('Test Dialog')).toBeNull();
expect(queryByText('Test Description')).not.toBeInTheDocument();
expect(queryByText('Main Content')).not.toBeInTheDocument();
expect(queryByText('Button')).not.toBeInTheDocument();
expect(queryByText('Left Button')).not.toBeInTheDocument();
expect(queryByText('Cancel')).not.toBeInTheDocument();
expect(queryByText('Select')).not.toBeInTheDocument();
});
it('calls selectHandler when the select button is clicked', () => {
const { getByText } = render(
<Provider>
<Dialog
open
onOpenChange={() => {
return;
}}
>
<DialogTemplate
title="Test Dialog"
selection={{ selectHandler: mockSelectHandler, selectText: 'Select' }}
/>
</Dialog>
</Provider>,
);
fireEvent.click(getByText('Select'));
expect(mockSelectHandler).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,94 @@
import { forwardRef, ReactNode, Ref } from 'react';
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './Dialog';
import { cn } from '~/utils/';
type SelectionProps = {
selectHandler?: () => void;
selectClasses?: string;
selectText?: string;
};
type DialogTemplateProps = {
title: string;
description?: string;
main?: ReactNode;
buttons?: ReactNode;
leftButtons?: ReactNode;
selection?: SelectionProps;
className?: string;
headerClassName?: string;
footerClassName?: string;
showCloseButton?: boolean;
showCancelButton?: boolean;
};
const DialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDivElement>) => {
const {
title,
description,
main,
buttons,
leftButtons,
selection,
className,
headerClassName,
footerClassName,
showCloseButton,
showCancelButton = true,
} = props;
const { selectHandler, selectClasses, selectText } = selection || {};
const Cancel = 'cancel';
const defaultSelect =
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
return (
<DialogContent
showCloseButton={showCloseButton}
ref={ref}
className={cn('shadow-2xl dark:bg-gray-700', className || '')}
onClick={(e) => e.stopPropagation()}
>
<DialogHeader className={cn(headerClassName ?? '')}>
<DialogTitle className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200">
{title}
</DialogTitle>
{description && (
<DialogDescription className="text-gray-600 dark:text-gray-300">
{description}
</DialogDescription>
)}
</DialogHeader>
<div className="px-6">{main ? main : null}</div>
<DialogFooter className={footerClassName}>
<div>{leftButtons ? leftButtons : null}</div>
<div className="flex h-auto gap-3">
{showCancelButton && (
<DialogClose className="border-gray-100 hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-600">
{Cancel}
</DialogClose>
)}
{buttons ? buttons : null}
{selection ? (
<DialogClose
onClick={selectHandler}
className={`${
selectClasses || defaultSelect
} inline-flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm`}
>
{selectText}
</DialogClose>
) : null}
</div>
</DialogFooter>
</DialogContent>
);
});
export default DialogTemplate;

View file

@ -0,0 +1,150 @@
import React from 'react';
import * as Select from '@ariakit/react/select';
import type { Option } from '~/common';
import { cn } from '~/utils/';
interface DropdownProps {
value?: string;
label?: string;
onChange: (value: string) => void;
options: (string | Option | { divider: true })[];
className?: string;
sizeClasses?: string;
testId?: string;
icon?: React.ReactNode;
iconOnly?: boolean;
renderValue?: (option: Option) => React.ReactNode;
ariaLabel?: string;
portal?: boolean;
}
const isDivider = (item: string | Option | { divider: true }): item is { divider: true } =>
typeof item === 'object' && 'divider' in item;
const isOption = (item: string | Option | { divider: true }): item is Option =>
typeof item === 'object' && 'value' in item && 'label' in item;
const Dropdown: React.FC<DropdownProps> = ({
value: selectedValue,
label = '',
onChange,
options,
className = '',
sizeClasses,
testId = 'dropdown-menu',
icon,
iconOnly = false,
renderValue,
ariaLabel,
portal = true,
}) => {
const handleChange = (value: string) => {
onChange(value);
};
const selectProps = Select.useSelectStore({
value: selectedValue,
setValue: handleChange,
});
const getOptionObject = (val: string | undefined): Option | undefined => {
if (val == null || val === '') {
return undefined;
}
return options
.filter((o) => !isDivider(o))
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
.find((o) => isOption(o) && o.value === val) as Option | undefined;
};
const getOptionLabel = (currentValue: string | undefined) => {
if (currentValue == null || currentValue === '') {
return '';
}
const option = getOptionObject(currentValue);
return option ? option.label : currentValue;
};
return (
<div className={cn('relative', className)}>
<Select.Select
store={selectProps}
className={cn(
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
iconOnly ? 'h-full w-10' : 'w-fit gap-2',
className,
)}
data-testid={testId}
aria-label={ariaLabel}
>
<div className="flex w-full items-center gap-2">
{icon}
{!iconOnly && (
<span className="block truncate">
{label}
{(() => {
const matchedOption = getOptionObject(selectedValue);
if (matchedOption && renderValue) {
return renderValue(matchedOption);
}
return getOptionLabel(selectedValue);
})()}
</span>
)}
</div>
{!iconOnly && <Select.SelectArrow />}
</Select.Select>
<Select.SelectPopover
portal={portal}
store={selectProps}
className={cn('popover-ui', sizeClasses, className, 'max-h-[80vh] overflow-y-auto')}
>
{options.map((item, index) => {
if (isDivider(item)) {
return <div key={`divider-${index}`} className="my-1 border-t border-border-heavy" />;
}
const option = typeof item === 'string' ? { value: item, label: item } : item;
if (!isOption(option)) {
return null;
}
return (
<Select.SelectItem
key={`option-${index}`}
value={String(option.value)}
className="select-item"
data-theme={option.value}
>
<div className="flex w-full items-center gap-2">
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span className="block truncate">{option.label}</span>
{selectedValue === option.value && (
<span className="ml-auto pl-2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block group-hover:hidden"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
</span>
)}
</div>
</Select.SelectItem>
);
})}
</Select.SelectPopover>
</div>
);
};
export default Dropdown;

View file

@ -0,0 +1,191 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '~/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className = '', inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-900 dark:data-[state=open]:bg-gray-900',
inset ? 'pl-8' : '',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className = '', ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in slide-in-from-left-1 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className = '', sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className = '', inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
inset ? 'pl-8' : '',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className = '', children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className = '', children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
className,
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800',
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className = '', inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-300',
inset ? 'pl-8' : '',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className = '', ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border-medium', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className = '',
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest text-gray-500', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View file

@ -0,0 +1,136 @@
import React, { FC } from 'react';
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
Transition,
} from '@headlessui/react';
import type { Option } from '~/common';
import { cn } from '~/utils/';
interface DropdownProps {
value?: string | Option;
label?: string;
onChange: (value: string | Option) => void;
options: (string | Option)[];
className?: string;
sizeClasses?: string;
testId?: string;
}
/*
* Mainly used for the Speech Voice Selection Dropdown
*/
const Dropdown: FC<DropdownProps> = ({
value,
label = '',
onChange,
options,
className = '',
sizeClasses,
testId = 'dropdown-menu',
}) => {
const getValue = (option?: string | Option) =>
typeof option === 'string' ? option : option?.value;
const getDisplay = (option?: string | Option) =>
typeof option === 'string' ? option : (option?.label ?? option?.value);
const isEqual = (a: string | Option, b: string | Option): boolean => getValue(a) === getValue(b);
const selectedOption = options.find((option) => isEqual(option, value ?? '')) ?? value;
const handleChange = (newValue: string | Option) => {
onChange(newValue);
};
return (
<div className={cn('relative', className)}>
<Listbox value={selectedOption} onChange={handleChange}>
<div className={cn('relative', className)}>
<ListboxButton
data-testid={testId}
className={cn(
'relative inline-flex items-center justify-between rounded-md border-gray-50 bg-white py-2 pl-3 pr-8 text-black transition-all duration-100 ease-in-out hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white dark:focus:ring-offset-gray-700',
'w-auto',
className,
)}
aria-label="Select an option"
>
<span className="block truncate">
{label}
{getDisplay(selectedOption)}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
className="h-4 w-5 rotate-0 transform text-black transition-transform duration-300 ease-in-out dark:text-gray-50"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</span>
</ListboxButton>
<Transition
leave="transition ease-in duration-50"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
className={cn(
'absolute z-50 mt-1 flex flex-col items-start gap-1 overflow-auto rounded-lg border border-gray-300 bg-white p-1.5 text-gray-700 shadow-lg transition-opacity focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white',
sizeClasses,
className,
)}
aria-label="List of options"
>
{options.map((item, index) => (
<ListboxOption
key={index}
value={item}
className={cn(
'relative cursor-pointer select-none rounded border-gray-300 bg-white py-2.5 pl-3 pr-3 text-sm text-gray-700 hover:bg-gray-100 dark:border-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600',
)}
style={{ width: '100%' }}
data-theme={getValue(item)}
>
{({ selected }) => (
<div className="flex w-full items-center justify-between">
<span className="block truncate">{getDisplay(item)}</span>
{selected && (
<span className="ml-auto pl-2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block group-hover:hidden"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
</span>
)}
</div>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
</Listbox>
</div>
);
};
export default Dropdown;

View file

@ -0,0 +1,133 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import type * as t from '~/common';
import { cn } from '~/utils';
interface DropdownProps {
keyPrefix?: string;
trigger: React.ReactNode;
items: t.MenuItemProps[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
className?: string;
iconClassName?: string;
itemClassName?: string;
sameWidth?: boolean;
anchor?: { x: string; y: string };
gutter?: number;
modal?: boolean;
portal?: boolean;
preserveTabOrder?: boolean;
focusLoop?: boolean;
menuId: string;
mountByState?: boolean;
unmountOnHide?: boolean;
finalFocus?: React.RefObject<HTMLElement>;
}
type MenuProps = Omit<
DropdownProps,
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
>;
const DropdownPopup: React.FC<DropdownProps> = ({
trigger,
isOpen,
setIsOpen,
focusLoop,
mountByState,
...props
}) => {
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen, focusLoop });
if (mountByState) {
return (
<Ariakit.MenuProvider store={menu}>
{trigger}
{isOpen && <Menu {...props} />}
</Ariakit.MenuProvider>
);
}
return (
<Ariakit.MenuProvider store={menu}>
{trigger}
<Menu {...props} />
</Ariakit.MenuProvider>
);
};
const Menu: React.FC<MenuProps> = ({
items,
menuId,
keyPrefix,
className,
iconClassName,
itemClassName,
modal,
portal,
sameWidth,
gutter = 8,
finalFocus,
unmountOnHide,
preserveTabOrder,
}) => {
const menu = Ariakit.useMenuContext();
return (
<Ariakit.Menu
id={menuId}
modal={modal}
gutter={gutter}
portal={portal}
sameWidth={sameWidth}
finalFocus={finalFocus}
unmountOnHide={unmountOnHide}
preserveTabOrder={preserveTabOrder}
className={cn('popover-ui z-50', className)}
>
{items
.filter((item) => item.show !== false)
.map((item, index) => {
if (item.separate === true) {
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
}
return (
<Ariakit.MenuItem
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
id={item.id}
className={cn(
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
itemClassName,
)}
disabled={item.disabled}
render={item.render}
ref={item.ref}
hideOnClick={item.hideOnClick}
onClick={(event) => {
event.preventDefault();
if (item.onClick) {
item.onClick(event);
}
if (item.hideOnClick === false) {
return;
}
menu?.hide();
}}
>
{item.icon != null && (
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
{item.kbd != null && (
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
{item.kbd}
</kbd>
)}
</Ariakit.MenuItem>
);
})}
</Ariakit.Menu>
);
};
export default DropdownPopup;

View file

@ -0,0 +1,29 @@
import React, { forwardRef } from 'react';
type FileUploadProps = {
className?: string;
onClick?: () => void;
children: React.ReactNode;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
({ children, handleFileChange }, ref) => {
return (
<>
{children}
<input
ref={ref}
multiple
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</>
);
},
);
FileUpload.displayName = 'FileUpload';
export default FileUpload;

View file

@ -0,0 +1,67 @@
import React from 'react';
import type { ControllerRenderProps, FieldValues, FieldPath } from 'react-hook-form';
import { Label } from './Label';
import { Input } from './Input';
import { cn } from '~/utils';
export default function FormInput<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
field,
label,
labelClass,
inputClass,
containerClass,
labelAdjacent,
placeholder = '',
type = 'string',
}: {
field: ControllerRenderProps<TFieldValues, TName>;
label: string;
labelClass?: string;
inputClass?: string;
placeholder?: string;
containerClass?: string;
type?: 'string' | 'number';
labelAdjacent?: React.ReactNode;
}) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (type !== 'number') {
field.onChange(value);
return;
}
if (value === '') {
field.onChange(value);
} else if (!isNaN(Number(value))) {
field.onChange(Number(value));
}
};
return (
<div className={cn('flex w-full flex-col items-center gap-2', containerClass)}>
<div className="flex w-full items-center justify-start gap-2">
<Label
htmlFor={`${field.name}-input`}
className={cn('text-left text-sm font-semibold text-text-primary', labelClass)}
>
{label}
</Label>
{labelAdjacent}
</div>
<Input
id={`${field.name}-input`}
value={field.value ?? ''}
onChange={handleChange}
placeholder={placeholder}
className={cn(
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
inputClass,
)}
/>
</div>
);
}

View file

@ -0,0 +1,34 @@
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '~/utils';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardPortal = HoverCardPrimitive.Portal;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & { disabled?: boolean }
>(({ className = '', align = 'center', sideOffset = 6, disabled = false, ...props }, ref) => {
if (disabled) {
return null;
}
return (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 origin-[--radix-hover-card-content-transform-origin] rounded-xl border border-border-light bg-surface-secondary p-4 text-text-primary shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
);
});
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardPortal };

View file

@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '~/utils';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
return (
<input
className={cn(
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className ?? '',
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export { Input };

View file

@ -0,0 +1,105 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import type { OptionWithIcon } from '~/common';
import { cn } from '~/utils';
type ComboboxProps = {
label?: string;
placeholder?: string;
options: OptionWithIcon[] | string[];
className?: string;
labelClassName?: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
};
export const InputCombobox: React.FC<ComboboxProps> = ({
label,
labelClassName,
placeholder = 'Select an option',
options,
className,
value,
onChange,
onBlur,
}) => {
const isOptionObject = (option: unknown): option is OptionWithIcon => {
return option != null && typeof option === 'object' && 'value' in option;
};
const [isOpen, setIsOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState(value);
const [isKeyboardFocus, setIsKeyboardFocus] = React.useState(false);
React.useEffect(() => {
setInputValue(value);
}, [value]);
const handleChange = (newValue: string) => {
setInputValue(newValue);
onChange(newValue);
};
return (
<Ariakit.ComboboxProvider value={inputValue} setValue={handleChange}>
{label != null && (
<Ariakit.ComboboxLabel
className={cn('mb-2 block text-sm font-medium text-text-primary', labelClassName ?? '')}
>
{label}
</Ariakit.ComboboxLabel>
)}
<div className={cn('relative', isKeyboardFocus ? 'rounded-md ring-2 ring-ring-primary' : '')}>
<Ariakit.Combobox
placeholder={placeholder}
className={cn(
'h-10 w-full rounded-md border border-border-light bg-surface-primary px-3 py-2 text-sm',
'placeholder-text-secondary hover:bg-surface-hover',
'focus:outline-none',
className,
)}
onChange={(event) => handleChange(event.target.value)}
onBlur={() => {
setIsKeyboardFocus(false);
onBlur();
}}
onFocusVisible={() => {
setIsKeyboardFocus(true);
setIsOpen(true);
}}
onMouseDown={() => {
setIsKeyboardFocus(false);
}}
/>
</div>
<Ariakit.ComboboxPopover
gutter={4}
sameWidth
open={isOpen}
onClose={() => setIsOpen(false)}
className={cn(
'z-50 max-h-60 w-full overflow-auto rounded-md bg-surface-primary p-1 shadow-lg',
'animate-in fade-in-0 zoom-in-95',
)}
>
{options.map((option: string | OptionWithIcon, index: number) => (
<Ariakit.ComboboxItem
key={index}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
'cursor-pointer hover:bg-surface-tertiary hover:text-text-primary',
'data-[active-item]:bg-surface-tertiary data-[active-item]:text-text-primary',
)}
value={isOptionObject(option) ? `${option.value ?? ''}` : option}
>
{isOptionObject(option) && option.icon != null && (
<span className="mr-2 flex-shrink-0">{option.icon}</span>
)}
{isOptionObject(option) ? option.label : option}
</Ariakit.ComboboxItem>
))}
</Ariakit.ComboboxPopover>
</Ariakit.ComboboxProvider>
);
};

View file

@ -0,0 +1,45 @@
import * as React from 'react';
// import { NumericFormat } from 'react-number-format';
import RCInputNumber from 'rc-input-number';
import * as InputNumberPrimitive from 'rc-input-number';
import { cn } from '~/utils';
// TODO help needed
// React.ElementRef<typeof LabelPrimitive.Root>,
// React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
const InputNumber = React.forwardRef<
React.ElementRef<typeof RCInputNumber>,
InputNumberPrimitive.InputNumberProps
>(({ className, ...props }, ref) => {
return (
<RCInputNumber
className={cn(
'flex max-h-5 w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50',
className ?? '',
)}
ref={ref}
{...props}
/>
);
});
InputNumber.displayName = 'Input';
// console.log(_InputNumber);
// const InputNumber = React.forwardRef(({ className, ...props }, ref) => {
// return (
// <NumericFormat
// className={cn(
// 'flex h-10 w-full rounded-md border border-gray-300 bg-transparent py-2 px-3 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
// className
// )}
// ref={ref}
// {...props}
// />
// );
// });
export { InputNumber };

View file

@ -0,0 +1,73 @@
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Minus } from 'lucide-react';
import { cn } from '~/utils';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
if (!inputOTPContext) {
throw new Error('InputOTPSlot must be used within an OTPInput');
}
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
'text-md relative flex h-11 w-11 items-center justify-center border-y border-r border-input shadow-sm transition-all first:rounded-l-xl first:border-l last:rounded-r-xl',
isActive && 'z-10 ring-1 ring-ring',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -0,0 +1,154 @@
import * as React from 'react';
import { Input } from './Input';
import { cn } from '~/utils';
export type InputWithDropdownProps = React.InputHTMLAttributes<HTMLInputElement> & {
options: string[];
onSelect?: (value: string) => void;
};
const InputWithDropdown = React.forwardRef<HTMLInputElement, InputWithDropdownProps>(
({ className, options, onSelect, ...props }, ref) => {
const [isOpen, setIsOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState((props.value as string) || '');
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
const handleSelect = (value: string) => {
setInputValue(value);
setIsOpen(false);
setHighlightedIndex(-1);
if (onSelect) {
onSelect(value);
}
if (props.onChange) {
props.onChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
if (props.onChange) {
props.onChange(e);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightedIndex((prevIndex) =>
prevIndex < options.length - 1 ? prevIndex + 1 : prevIndex,
);
}
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0));
break;
case 'Enter':
e.preventDefault();
if (isOpen && highlightedIndex !== -1) {
handleSelect(options[highlightedIndex]);
}
setIsOpen(false);
break;
case 'Escape':
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
};
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
setIsOpen(false);
setHighlightedIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative" ref={inputRef}>
<div className="relative">
<Input
{...props}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-controls="dropdown-list"
className={cn('bg-surface-secondary', className ?? '')}
ref={ref}
/>
<button
type="button"
className="text-tertiary absolute inset-y-0 right-0 flex items-center rounded-md px-2 hover:text-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring-primary"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? 'Close dropdown' : 'Open dropdown'}
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
{isOpen && (
<ul
id="dropdown-list"
role="listbox"
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border-medium bg-surface-secondary shadow-lg focus:ring-1 focus:ring-inset focus:ring-ring-primary"
>
{options.map((option, index) => (
<li
key={index}
role="option"
aria-selected={index === highlightedIndex}
className={cn(
'cursor-pointer rounded-md px-3 py-2',
'focus:bg-surface-tertiary focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring-primary',
index === highlightedIndex
? 'bg-surface-active text-primary'
: 'text-secondary hover:bg-surface-tertiary',
)}
onClick={() => handleSelect(option)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(option);
}
}}
tabIndex={0}
>
{option}
</li>
))}
</ul>
)}
</div>
);
},
);
InputWithDropdown.displayName = 'InputWithDropdown';
export default InputWithDropdown;

View file

@ -0,0 +1,24 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '~/utils';
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & {
className?: string;
}
>(({ className = '', ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
{...props}
{...{
className: cn(
'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
className,
),
}}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -0,0 +1,155 @@
import { Search, X } from 'lucide-react';
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { cn } from '~/utils';
/** This is a generic that can be added to Menu and Select components */
export default function MultiSearch({
value,
onChange,
placeholder,
className = '',
}: {
value: string | null;
onChange: (filter: string) => void;
placeholder?: string;
className?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const onChangeHandler: React.ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => onChange(e.target.value),
[onChange],
);
const clearSearch = () => {
onChange('');
setTimeout(() => {
inputRef.current?.focus();
}, 0);
};
return (
<div
className={cn(
'focus:to-surface-primary/50 group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-gradient-to-b from-surface-tertiary-alt from-65% to-transparent px-3 py-2 text-text-primary transition-colors duration-300 focus:bg-gradient-to-b focus:from-surface-primary',
className,
)}
>
<Search
className="h-4 w-4 text-text-secondary-alt transition-colors duration-300"
aria-hidden={'true'}
/>
<input
ref={inputRef}
type="text"
value={value ?? ''}
onChange={onChangeHandler}
placeholder={String(placeholder ?? 'Search...')}
aria-label="Search Model"
className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm placeholder-text-secondary focus:outline-none focus:ring-1 focus:ring-ring-primary"
/>
<button
className={cn(
'relative flex h-5 w-5 items-center justify-end rounded-md text-text-secondary-alt',
(value?.length ?? 0) ? 'cursor-pointer opacity-100' : 'hidden',
)}
aria-label={'Clear search'}
onClick={clearSearch}
tabIndex={0}
>
<X
aria-hidden={'true'}
className={cn(
'text-text-secondary-alt',
(value?.length ?? 0) ? 'cursor-pointer opacity-100' : 'opacity-0',
)}
/>
</button>
</div>
);
}
/**
* Helper function that will take a multiSearch input
* @param node
*/
function defaultGetStringKey(node: unknown): string {
if (typeof node === 'string') {
// BUGFIX: Detect psedeo separators and make sure they don't appear in the list when filtering items
// it makes sure (for the most part) that the model name starts and ends with dashes
// The long-term fix here would be to enable seperators (model groupings) but there's no
// feature mocks for such a thing yet
if (node.startsWith('---') && node.endsWith('---')) {
return '';
}
return node.toUpperCase();
}
// This should be a noop, but it's here for redundancy
return '';
}
/**
* Hook for conditionally making a multi-element list component into a sortable component
* Returns a RenderNode for search input when search functionality is available
* @param availableOptions
* @param placeholder
* @param getTextKeyOverride
* @param className - Additional classnames to add to the search container
* @param disabled - If the search should be disabled
* @returns
*/
export function useMultiSearch<OptionsType extends unknown[]>({
availableOptions = [] as unknown as OptionsType,
placeholder,
getTextKeyOverride,
className,
disabled = false,
}: {
availableOptions?: OptionsType;
placeholder?: string;
getTextKeyOverride?: (node: OptionsType[0]) => string;
className?: string;
disabled?: boolean;
}): [OptionsType, React.ReactNode] {
const [filterValue, setFilterValue] = useState<string | null>(null);
// We conditionally show the search when there's more than 10 elements in the menu
const shouldShowSearch = availableOptions.length > 10 && !disabled;
// Define the helper function used to enable search
// If this is invalidly described, we will assume developer error - tf. avoid rendering
const getTextKeyHelper = getTextKeyOverride || defaultGetStringKey;
// Iterate said options
const filteredOptions = useMemo(() => {
const currentFilter = filterValue ?? '';
if (!shouldShowSearch || !currentFilter || !availableOptions.length) {
// Don't render if available options aren't present, there's no filter active
return availableOptions;
}
// Filter through the values, using a simple text-based search
// nothing too fancy, but we can add a better search algo later if we need
const upperFilterValue = currentFilter.toUpperCase();
return availableOptions.filter((value) =>
getTextKeyHelper(value).includes(upperFilterValue),
) as OptionsType;
}, [availableOptions, getTextKeyHelper, filterValue, shouldShowSearch]);
const onSearchChange = useCallback(
(nextFilterValue: string) => setFilterValue(nextFilterValue),
[],
);
const searchRender = shouldShowSearch ? (
<MultiSearch
value={filterValue}
className={className}
onChange={onSearchChange}
placeholder={placeholder}
/>
) : null;
return [filteredOptions, searchRender];
}

View file

@ -0,0 +1,146 @@
import React, { useRef } from 'react';
import {
Select,
SelectArrow,
SelectItem,
SelectItemCheck,
SelectLabel,
SelectPopover,
SelectProvider,
} from '@ariakit/react';
import { cn } from '~/utils';
interface MultiSelectProps<T extends string> {
items: T[];
label?: string;
placeholder?: string;
onSelectedValuesChange?: (values: T[]) => void;
renderSelectedValues?: (values: T[], placeholder?: string) => React.ReactNode;
className?: string;
itemClassName?: string;
labelClassName?: string;
selectClassName?: string;
selectIcon?: React.ReactNode;
popoverClassName?: string;
selectItemsClassName?: string;
selectedValues: T[];
setSelectedValues: (values: T[]) => void;
renderItemContent?: (
value: T,
defaultContent: React.ReactNode,
isSelected: boolean,
) => React.ReactNode;
}
function defaultRender<T extends string>(values: T[], placeholder?: string) {
if (values.length === 0) {
return placeholder || 'Select...';
}
if (values.length === 1) {
return values[0];
}
return `${values.length} items selected`;
}
export default function MultiSelect<T extends string>({
items,
label,
placeholder = 'Select...',
onSelectedValuesChange,
renderSelectedValues = defaultRender,
className,
selectIcon,
itemClassName,
labelClassName,
selectClassName,
popoverClassName,
selectItemsClassName,
selectedValues = [],
setSelectedValues,
renderItemContent,
}: MultiSelectProps<T>) {
const selectRef = useRef<HTMLButtonElement>(null);
const handleValueChange = (values: T[]) => {
setSelectedValues(values);
if (onSelectedValuesChange) {
onSelectedValuesChange(values);
}
};
return (
<div className={className}>
<SelectProvider value={selectedValues} setValue={handleValueChange}>
{label && (
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
{label}
</SelectLabel>
)}
<Select
ref={selectRef}
className={cn(
'flex items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm',
'bg-surface-tertiary text-text-primary shadow-sm hover:cursor-pointer hover:bg-surface-hover',
'outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75',
selectClassName,
selectedValues.length > 0 && selectItemsClassName != null && selectItemsClassName,
)}
onChange={(e) => e.stopPropagation()}
>
{selectIcon && <span>{selectIcon as React.JSX.Element}</span>}
<span className="mr-auto hidden truncate md:block">
{renderSelectedValues(selectedValues, placeholder)}
</span>
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
</Select>
<SelectPopover
gutter={4}
sameWidth
modal
unmountOnHide
finalFocus={selectRef}
className={cn(
'animate-popover z-50 flex max-h-[300px]',
'flex-col overflow-auto overscroll-contain rounded-xl',
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
'border border-border-light',
'outline-none',
popoverClassName,
)}
>
{items.map((value) => {
const defaultContent = (
<>
<SelectItemCheck className="mr-0.5 text-primary" />
<span className="truncate">{value}</span>
</>
);
const isCurrentItemSelected = selectedValues.includes(value);
return (
<SelectItem
key={value}
value={value}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 text-sm',
itemClassName,
)}
>
{renderItemContent
? (renderItemContent(
value,
defaultContent,
isCurrentItemSelected,
) as React.JSX.Element)
: (defaultContent as React.JSX.Element)}
</SelectItem>
);
})}
</SelectPopover>
</SelectProvider>
</div>
);
}

View file

@ -0,0 +1,113 @@
import { forwardRef, ReactNode, Ref } from 'react';
import {
OGDialogTitle,
OGDialogClose,
OGDialogFooter,
OGDialogHeader,
OGDialogContent,
OGDialogDescription,
} from './OriginalDialog';
import { useLocalize } from '~/hooks';
import { Button } from './Button';
import { Spinner } from '~/svgs';
import { cn } from '~/utils/';
type SelectionProps = {
selectHandler?: () => void;
selectClasses?: string;
selectText?: string | ReactNode;
isLoading?: boolean;
};
type DialogTemplateProps = {
title: string;
description?: string;
main?: ReactNode;
buttons?: ReactNode;
leftButtons?: ReactNode;
selection?: SelectionProps;
className?: string;
overlayClassName?: string;
headerClassName?: string;
mainClassName?: string;
footerClassName?: string;
showCloseButton?: boolean;
showCancelButton?: boolean;
onClose?: () => void;
};
const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDivElement>) => {
const localize = useLocalize();
const {
title,
main,
buttons,
selection,
className,
leftButtons,
description = '',
mainClassName,
headerClassName,
footerClassName,
showCloseButton,
overlayClassName,
showCancelButton = true,
} = props;
const { selectHandler, selectClasses, selectText, isLoading } = selection || {};
const defaultSelect =
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
return (
<OGDialogContent
overlayClassName={overlayClassName}
showCloseButton={showCloseButton}
ref={ref}
className={cn('w-11/12 border-none bg-background text-foreground', className ?? '')}
onClick={(e) => e.stopPropagation()}
>
<OGDialogHeader className={cn(headerClassName ?? '')}>
<OGDialogTitle>{title}</OGDialogTitle>
{description && (
<OGDialogDescription className="items-center justify-center">
{description}
</OGDialogDescription>
)}
</OGDialogHeader>
<div className={cn('px-0 py-2', mainClassName)}>{main != null ? main : null}</div>
<OGDialogFooter className={footerClassName}>
<div>
{leftButtons != null ? (
<div className="mt-3 flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:mt-0 sm:flex-row">
{leftButtons}
</div>
) : null}
</div>
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
{showCancelButton && (
<OGDialogClose asChild>
<Button variant="outline">{localize('com_ui_cancel')}</Button>
</OGDialogClose>
)}
{buttons != null ? buttons : null}
{selection ? (
<OGDialogClose
onClick={selectHandler}
disabled={isLoading}
className={`${
selectClasses ?? defaultSelect
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm disabled:opacity-80 max-sm:order-first max-sm:w-full sm:order-none`}
>
{isLoading === true ? (
<Spinner className="size-4 text-white" />
) : (
(selectText as React.JSX.Element)
)}
</OGDialogClose>
) : null}
</div>
</OGDialogFooter>
</OGDialogContent>
);
});
export default OGDialogTemplate;

View file

@ -0,0 +1,141 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '~/utils';
interface OGDialogProps extends DialogPrimitive.DialogProps {
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>;
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>[];
}
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
({ children, triggerRef, triggerRefs, onOpenChange, ...props }, _ref) => {
const handleOpenChange = (open: boolean) => {
if (!open && triggerRef?.current) {
setTimeout(() => {
triggerRef.current?.focus();
}, 0);
}
if (triggerRefs?.length) {
triggerRefs.forEach((ref) => {
if (ref?.current) {
setTimeout(() => {
ref.current?.focus();
}, 0);
}
});
}
onOpenChange?.(open);
};
return (
<DialogPrimitive.Root {...props} onOpenChange={handleOpenChange}>
{children}
</DialogPrimitive.Root>
);
},
);
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
disableScroll?: boolean;
overlayClassName?: string;
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, overlayClassName, showCloseButton = true, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(
'max-w-11/12 fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog as OGDialog,
DialogPortal as OGDialogPortal,
DialogOverlay as OGDialogOverlay,
DialogClose as OGDialogClose,
DialogTrigger as OGDialogTrigger,
DialogContent as OGDialogContent,
DialogHeader as OGDialogHeader,
DialogFooter as OGDialogFooter,
DialogTitle as OGDialogTitle,
DialogDescription as OGDialogDescription,
};

View file

@ -0,0 +1,105 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { ButtonProps, buttonVariants } from './Button';
import { cn } from '~/utils';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn('flex flex-row items-center gap-1', className)} {...props} />
),
);
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
({ className, ...props }, ref) => <li ref={ref} className={cn('', className)} {...props} />,
);
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive = false,
size = 'icon',
children,
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
>
{children || <span className="sr-only">Page link</span>}
</a>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View file

@ -0,0 +1,380 @@
import { useEffect, useRef, useCallback } from 'react';
import { cn } from '~/utils';
class Pixel {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
x: number;
y: number;
color: string;
speed: number;
size: number;
sizeStep: number;
minSize: number;
maxSizeInteger: number;
maxSize: number;
delay: number;
counter: number;
counterStep: number;
isIdle: boolean;
isReverse: boolean;
isShimmer: boolean;
activationThreshold: number;
constructor(
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
x: number,
y: number,
color: string,
speed: number,
delay: number,
activationThreshold: number,
) {
this.width = canvas.width;
this.height = canvas.height;
this.ctx = context;
this.x = x;
this.y = y;
this.color = color;
this.speed = this.random(0.1, 0.9) * speed;
this.size = 0;
this.sizeStep = Math.random() * 0.4;
this.minSize = 0.5;
this.maxSizeInteger = 2;
this.maxSize = this.random(this.minSize, this.maxSizeInteger);
this.delay = delay;
this.counter = 0;
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
this.isIdle = false;
this.isReverse = false;
this.isShimmer = false;
this.activationThreshold = activationThreshold;
}
private random(min: number, max: number) {
return Math.random() * (max - min) + min;
}
private draw() {
const offset = this.maxSizeInteger * 0.5 - this.size * 0.5;
this.ctx.fillStyle = this.color;
this.ctx.fillRect(this.x + offset, this.y + offset, this.size, this.size);
}
appear() {
this.isIdle = false;
if (this.counter <= this.delay) {
this.counter += this.counterStep;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer();
} else {
this.size += this.sizeStep;
}
this.draw();
}
appearWithProgress(progress: number) {
const diff = progress - this.activationThreshold;
if (diff <= 0) {
this.isIdle = true;
return;
}
if (this.counter <= this.delay) {
this.counter += this.counterStep;
this.isIdle = false;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer();
} else {
this.size += this.sizeStep;
}
this.isIdle = false;
this.draw();
}
disappear() {
this.isShimmer = false;
this.counter = 0;
if (this.size <= 0) {
this.isIdle = true;
return;
}
this.size -= 0.1;
this.draw();
}
private shimmer() {
if (this.size >= this.maxSize) {
this.isReverse = true;
} else if (this.size <= this.minSize) {
this.isReverse = false;
}
this.size += this.isReverse ? -this.speed : this.speed;
}
}
const getEffectiveSpeed = (value: number, reducedMotion: boolean) => {
const parsed = parseInt(String(value), 10);
const throttle = 0.001;
if (parsed <= 0 || reducedMotion) {
return 0;
}
if (parsed >= 100) {
return 100 * throttle;
}
return parsed * throttle;
};
const clamp = (n: number, min = 0, max = 1) => Math.min(Math.max(n, min), max);
const VARIANTS = {
default: { gap: 5, speed: 35, colors: '#f8fafc,#f1f5f9,#cbd5e1', noFocus: false },
blue: { gap: 10, speed: 25, colors: '#e0f2fe,#7dd3fc,#0ea5e9', noFocus: false },
yellow: { gap: 3, speed: 20, colors: '#fef08a,#fde047,#eab308', noFocus: false },
pink: { gap: 6, speed: 80, colors: '#fecdd3,#fda4af,#e11d48', noFocus: true },
} as const;
interface PixelCardProps {
variant?: keyof typeof VARIANTS;
gap?: number;
speed?: number;
colors?: string;
noFocus?: boolean;
className?: string;
progress?: number;
randomness?: number;
width?: string;
height?: string;
}
export default function PixelCard({
variant = 'default',
gap,
speed,
colors,
noFocus,
className = '',
progress,
randomness = 0.3,
width,
height,
}: PixelCardProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pixelsRef = useRef<Pixel[]>([]);
const animationRef = useRef<number | undefined>(undefined);
const timePrevRef = useRef(performance.now());
const progressRef = useRef<number | undefined>(progress);
const reducedMotion = useRef(
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
).current;
const cfg = VARIANTS[variant];
const g = gap ?? cfg.gap;
const s = speed ?? cfg.speed;
const palette = colors ?? cfg.colors;
const disableFocus = noFocus ?? cfg.noFocus;
const updateCanvasOpacity = useCallback(() => {
if (!canvasRef.current) {
return;
}
if (progressRef.current === undefined) {
canvasRef.current.style.opacity = '1';
return;
}
const fadeStart = 0.9;
const alpha =
progressRef.current >= fadeStart ? 1 - (progressRef.current - fadeStart) / 0.1 : 1;
canvasRef.current.style.opacity = String(clamp(alpha));
}, []);
const animate = useCallback(
(method: keyof Pixel) => {
animationRef.current = requestAnimationFrame(() => animate(method));
const now = performance.now();
const elapsed = now - timePrevRef.current;
if (elapsed < 1000 / 60) {
return;
}
timePrevRef.current = now - (elapsed % (1000 / 60));
const ctx = canvasRef.current?.getContext('2d');
if (!ctx || !canvasRef.current) {
return;
}
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
let idle = true;
for (const p of pixelsRef.current) {
if (method === 'appearWithProgress') {
if (progressRef.current !== undefined) {
p.appearWithProgress(progressRef.current);
} else {
p.isIdle = true;
}
} else {
// @ts-ignore dynamic dispatch
p[method]();
}
if (!p.isIdle) {
idle = false;
}
}
updateCanvasOpacity();
if (idle) {
cancelAnimationFrame(animationRef.current!);
}
},
[updateCanvasOpacity],
);
const startAnim = useCallback(
(m: keyof Pixel) => {
cancelAnimationFrame(animationRef.current!);
animationRef.current = requestAnimationFrame(() => animate(m));
},
[animate],
);
const initPixels = useCallback(() => {
if (!containerRef.current || !canvasRef.current) {
return;
}
const { width: cw, height: ch } = containerRef.current.getBoundingClientRect();
const ctx = canvasRef.current.getContext('2d');
canvasRef.current.width = Math.floor(cw);
canvasRef.current.height = Math.floor(ch);
const cols = palette.split(',');
const px: Pixel[] = [];
const cx = cw / 2;
const cy = ch / 2;
const maxDist = Math.hypot(cx, cy);
for (let x = 0; x < cw; x += g) {
for (let y = 0; y < ch; y += g) {
const color = cols[Math.floor(Math.random() * cols.length)];
const distNorm = Math.hypot(x - cx, y - cy) / maxDist;
const threshold = clamp(distNorm * (1 - randomness) + Math.random() * randomness);
const delay = reducedMotion ? 0 : distNorm * maxDist;
if (!ctx) {
continue;
}
px.push(
new Pixel(
canvasRef.current,
ctx,
x,
y,
color,
getEffectiveSpeed(s, reducedMotion),
delay,
threshold,
),
);
}
}
pixelsRef.current = px;
if (progressRef.current !== undefined) {
startAnim('appearWithProgress');
}
}, [g, palette, s, randomness, reducedMotion, startAnim]);
useEffect(() => {
progressRef.current = progress;
if (progress !== undefined) {
startAnim('appearWithProgress');
}
}, [progress, startAnim]);
useEffect(() => {
if (progress === undefined) {
cancelAnimationFrame(animationRef.current!);
}
}, [progress]);
useEffect(() => {
initPixels();
const obs = new ResizeObserver(initPixels);
if (containerRef.current) {
obs.observe(containerRef.current);
}
return () => {
obs.disconnect();
cancelAnimationFrame(animationRef.current!);
};
}, [initPixels]);
const hoverIn = () => progressRef.current === undefined && startAnim('appear');
const hoverOut = () => progressRef.current === undefined && startAnim('disappear');
const focusIn: React.FocusEventHandler<HTMLDivElement> = (e) => {
if (
!disableFocus &&
!e.currentTarget.contains(e.relatedTarget) &&
progressRef.current === undefined
) {
startAnim('appear');
}
};
const focusOut: React.FocusEventHandler<HTMLDivElement> = (e) => {
if (
!disableFocus &&
!e.currentTarget.contains(e.relatedTarget) &&
progressRef.current === undefined
) {
startAnim('disappear');
}
};
return (
<div
ref={containerRef}
style={{
width: width || '100%',
height: height || '100%',
}}
>
<div
className={cn(
'relative isolate grid select-none place-items-center overflow-hidden rounded-lg border border-border-light shadow-md transition-colors duration-200 ease-in-out',
className,
)}
style={{
width: '100%',
height: '100%',
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)',
}}
onMouseEnter={hoverIn}
onMouseLeave={hoverOut}
onFocus={disableFocus ? undefined : focusIn}
onBlur={disableFocus ? undefined : focusOut}
tabIndex={disableFocus ? -1 : 0}
>
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 block"
width={width && width !== 'auto' ? parseInt(String(width)) : undefined}
height={height && height !== 'auto' ? parseInt(String(height)) : undefined}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '~/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View file

@ -0,0 +1,16 @@
import { cn } from '~/utils';
export const QuestionMark = ({ className = '' }) => {
return (
<span>
<div
className={cn(
'border-token-border-medium text-token-text-tertiary ml-2 flex h-3.5 w-3.5 cursor-default items-center justify-center rounded-full border text-[0.5rem] font-medium leading-none',
className,
)}
>
?
</div>
</span>
);
};

View file

@ -0,0 +1,62 @@
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '~/utils';
const ResizablePanelGroup = ({
className = '',
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className = '',
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
const ResizableHandleAlt = ({
withHandle,
className = '',
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'group relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="invisible z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border group-hover:visible group-active:visible">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle, ResizableHandleAlt };

View file

@ -0,0 +1,163 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import { cn } from '~/utils';
// @ts-ignore - Radix UI type conflicts with React types
const Select = SelectPrimitive.Root;
// @ts-ignore - Radix UI type conflicts with React types
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
// @ts-ignore - Radix UI type conflicts with React types
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className = '', children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 [&>span]:line-clamp-1',
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className = '', ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 dark:text-white',
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className = '', ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 dark:text-white',
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className = '', children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-600',
position === 'popper'
? 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1'
: '',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper'
? 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
: '',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className = '', ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className = '', children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className = '', ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View file

@ -0,0 +1,282 @@
import React, { useRef } from 'react';
import {
Label,
Listbox,
Transition,
ListboxButton,
ListboxOption,
ListboxOptions,
} from '@headlessui/react';
import type { Option, OptionWithIcon, DropdownValueSetter } from '~/common';
import { useMultiSearch } from './MultiSearch';
import { CheckMark } from '~/svgs';
import { cn } from '~/utils';
type SelectDropDownProps = {
id?: string;
title?: string;
disabled?: boolean;
value: string | null | Option | OptionWithIcon;
setValue: DropdownValueSetter | ((value: string) => void);
tabIndex?: number;
availableValues?: string[] | Option[] | OptionWithIcon[];
emptyTitle?: boolean;
showAbove?: boolean;
showLabel?: boolean;
iconSide?: 'left' | 'right';
optionIconSide?: 'left' | 'right';
renderOption?: () => React.ReactNode;
containerClassName?: string;
currentValueClass?: string;
optionsListClass?: string;
optionsClass?: string;
subContainerClassName?: string;
className?: string;
placeholder?: string;
searchClassName?: string;
searchPlaceholder?: string;
showOptionIcon?: boolean;
};
function getOptionText(option: string | Option | OptionWithIcon): string {
if (typeof option === 'string') {
return option;
}
if ('label' in option) {
return option.label ?? '';
}
if ('value' in option) {
return (option.value ?? '') + '';
}
return '';
}
function SelectDropDown({
title: _title,
value,
disabled,
setValue,
availableValues,
showAbove = false,
showLabel = true,
emptyTitle = false,
iconSide = 'right',
optionIconSide = 'left',
placeholder,
containerClassName,
optionsListClass,
optionsClass,
currentValueClass,
subContainerClassName,
className,
renderOption,
searchClassName,
searchPlaceholder,
showOptionIcon = false,
}: SelectDropDownProps) {
const transitionProps = { className: 'top-full mt-3' };
if (showAbove) {
transitionProps.className = 'bottom-full mb-3';
}
let title = _title;
if (emptyTitle) {
title = '';
}
const values = availableValues ?? [];
// Enable searchable select if enough items are provided.
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>({
availableOptions: values,
placeholder: searchPlaceholder,
getTextKeyOverride: (option) => getOptionText(option).toUpperCase(),
className: searchClassName,
disabled,
});
const hasSearchRender = searchRender != null;
const options = hasSearchRender ? filteredValues : values;
const renderIcon = showOptionIcon && value != null && (value as OptionWithIcon).icon != null;
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
<div className={cn('relative w-full', subContainerClassName ?? '')}>
<Listbox value={value} onChange={setValue} disabled={disabled}>
{({ open }) => (
<>
<ListboxButton
ref={buttonRef}
data-testid="select-dropdown-button"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (!open && buttonRef.current) {
buttonRef.current.click();
}
}
}}
className={cn(
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:bg-white dark:border-gray-600 dark:bg-gray-700 sm:text-sm',
className ?? '',
)}
>
{showLabel && (
<Label
className="block text-xs text-gray-700 dark:text-gray-500"
id="headlessui-listbox-label-:r1:"
data-headlessui-state=""
>
{title}
</Label>
)}
<span className="inline-flex w-full truncate">
<span
className={cn(
'flex h-6 items-center gap-1 truncate text-sm text-gray-800 dark:text-white',
!showLabel ? 'text-xs' : '',
currentValueClass ?? '',
)}
>
{!showLabel && !emptyTitle && (
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
)}
{renderIcon && optionIconSide !== 'right' && (
<span className="icon-md flex items-center">
{(value as OptionWithIcon).icon}
</span>
)}
{renderIcon && (
<span className="icon-md absolute right-0 mr-8 flex items-center">
{(value as OptionWithIcon).icon}
</span>
)}
{(() => {
if (!value) {
return <span className="text-text-secondary">{placeholder}</span>;
}
if (typeof value !== 'string') {
return value.label ?? '';
}
return value;
})()}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-gray-400"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</span>
</ListboxButton>
<Transition
show={open}
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
{...transitionProps}
>
<ListboxOptions
className={cn(
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-xs ring-black/10 dark:border-gray-600 dark:bg-gray-700 dark:ring-white/20 md:w-[100%]',
optionsListClass ?? '',
)}
>
{renderOption && (
<ListboxOption
key={'listbox-render-option'}
value={null}
className={cn(
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-700',
optionsClass ?? '',
)}
>
{renderOption() as React.JSX.Element}
</ListboxOption>
)}
{searchRender as React.JSX.Element}
{options.map((option: string | Option, i: number) => {
if (!option) {
return null;
}
const currentLabel =
typeof option === 'string' ? option : (option.label ?? option.value ?? '');
const currentValue = typeof option === 'string' ? option : (option.value ?? '');
const currentIcon =
typeof option === 'string'
? null
: ((option.icon as React.ReactNode) ?? null);
let activeValue: string | number | null | Option = value;
if (typeof activeValue !== 'string') {
activeValue = activeValue?.value ?? '';
}
return (
<ListboxOption
key={i}
value={option}
className={({ active }) =>
cn(
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-600',
active ? 'bg-surface-active text-text-primary' : '',
optionsClass ?? '',
)
}
>
<span className="flex items-center gap-1.5 truncate">
<span
className={cn(
'flex h-6 items-center gap-1 text-gray-800 dark:text-gray-200',
option === value ? 'font-semibold' : '',
iconSide === 'left' ? 'ml-4' : '',
)}
>
{currentIcon != null && (
<span
className={cn(
'mr-1',
optionIconSide === 'right' ? 'absolute right-0 pr-2' : '',
)}
>
{currentIcon}
</span>
)}
{currentLabel}
</span>
{currentValue === activeValue && (
<span
className={cn(
'absolute inset-y-0 flex items-center text-gray-800 dark:text-gray-200',
iconSide === 'left' ? 'left-0 pl-2' : 'right-0 pr-3',
)}
>
<CheckMark />
</span>
)}
</span>
</ListboxOption>
);
})}
</ListboxOptions>
</Transition>
</>
)}
</Listbox>
</div>
</div>
);
}
export default SelectDropDown;

View file

@ -0,0 +1,28 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '~/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> & {
className?: string;
}
>(({ className = '', orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
{...props}
{...{
decorative,
orientation,
className: cn(
'shrink-0 bg-border-light',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
),
}}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View file

@ -0,0 +1,15 @@
import { cn } from '~/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-surface-tertiary opacity-50 dark:opacity-25',
className,
)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -0,0 +1,38 @@
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '~/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string;
onDoubleClick?: () => void;
}
>(({ className, onDoubleClick, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
{...props}
{...{
className: cn(
'relative flex w-full cursor-pointer touch-none select-none items-center',
className,
),
onDoubleClick,
}}
>
<SliderPrimitive.Track
{...{ className: 'relative h-2 w-full grow overflow-hidden rounded-full bg-secondary' }}
>
<SliderPrimitive.Range {...{ className: 'absolute h-full bg-primary' }} />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
{...{
className:
'block h-5 w-5 rounded-full border-2 border-primary bg-background 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',
}}
/>
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View file

@ -0,0 +1,38 @@
import { render } from '@testing-library/react';
import SplitText from './SplitText';
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
});
describe('SplitText', () => {
it('renders emojis correctly', () => {
const emojis = ['🚧', '❤️‍🔥', '💜', '🦎', '❌', '✅', '⚠️'];
const originalText = emojis.join('');
const { container } = render(<SplitText text={originalText} />);
const textSpans = container.querySelectorAll('p > span > span.inline-block');
// Reconstruct the text by joining all span contents
const reconstructedText = Array.from(textSpans)
.map((span) => span.textContent)
.join('')
.trim();
// Compare the reconstructed text with the original
expect(reconstructedText).toBe(originalText);
// Check the first character specifically as the reconstructed text could hide issues
for (let i = 0; i < emojis.length; i++) {
expect(Array.from(textSpans)[i].textContent).toBe(emojis[i]);
}
});
});

View file

@ -0,0 +1,171 @@
import { useSprings, animated, SpringConfig } from '@react-spring/web';
import { useEffect, useRef, useState } from 'react';
interface SegmenterOptions {
granularity?: 'grapheme' | 'word' | 'sentence';
localeMatcher?: 'lookup' | 'best fit';
}
interface SegmentData {
segment: string;
index: number;
input: string;
isWordLike?: boolean;
}
interface Segments {
[Symbol.iterator](): IterableIterator<SegmentData>;
}
interface IntlSegmenter {
segment(input: string): Segments;
}
interface IntlSegmenterConstructor {
new (locales?: string | string[], options?: SegmenterOptions): IntlSegmenter;
}
declare global {
interface Intl {
Segmenter: IntlSegmenterConstructor;
}
}
interface SplitTextProps {
text?: string;
className?: string;
delay?: number;
animationFrom?: { opacity: number; transform: string };
animationTo?: { opacity: number; transform: string };
easing?: SpringConfig['easing'];
threshold?: number;
rootMargin?: string;
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end';
onLetterAnimationComplete?: () => void;
onLineCountChange?: (lineCount: number) => void;
}
const splitGraphemes = (text: string): string[] => {
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
const segmenter = new (Intl as typeof Intl & { Segmenter: IntlSegmenterConstructor }).Segmenter(
'en',
{ granularity: 'grapheme' },
);
const segments = segmenter.segment(text);
return Array.from(segments).map((s: SegmentData) => s.segment);
} else {
return [...text];
}
};
const SplitText: React.FC<SplitTextProps> = ({
text = '',
className = '',
delay = 100,
animationFrom = { opacity: 0, transform: 'translate3d(0,40px,0)' },
animationTo = { opacity: 1, transform: 'translate3d(0,0,0)' },
easing = (t: number) => t,
threshold = 0.1,
rootMargin = '-100px',
textAlign = 'center',
onLetterAnimationComplete,
onLineCountChange,
}) => {
const words = text.split(' ').map(splitGraphemes);
const letters = words.flat();
const [inView, setInView] = useState(false);
const ref = useRef<HTMLParagraphElement>(null);
const animatedCount = useRef(0);
const [springs] = useSprings(
letters.length,
(i) => ({
from: animationFrom,
to: inView
? async (next) => {
await next(animationTo);
animatedCount.current += 1;
if (animatedCount.current === letters.length && onLetterAnimationComplete) {
onLetterAnimationComplete();
}
}
: animationFrom,
delay: i * delay,
config: { easing },
}),
[inView, text, delay, animationFrom, animationTo, easing, onLetterAnimationComplete],
);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
if (ref.current) {
observer.unobserve(ref.current);
}
}
},
{ threshold, rootMargin },
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [threshold, rootMargin]);
useEffect(() => {
if (ref.current && inView) {
const element = ref.current;
setTimeout(() => {
const lineHeight =
parseInt(getComputedStyle(element).lineHeight) ||
parseInt(getComputedStyle(element).fontSize) * 1.2;
const height = element.offsetHeight;
const lines = Math.round(height / lineHeight);
if (onLineCountChange) {
onLineCountChange(lines);
}
}, 100);
}
}, [inView, text, onLineCountChange]);
return (
<>
<span className="sr-only">{text}</span>
<p
ref={ref}
className={`split-parent inline overflow-hidden ${className}`}
style={{ textAlign, whiteSpace: 'normal', wordWrap: 'break-word' }}
aria-hidden="true"
>
{words.map((word, wordIndex) => (
<span key={wordIndex} style={{ display: 'inline-block', whiteSpace: 'nowrap' }}>
{word.map((letter, letterIndex) => {
const index =
words.slice(0, wordIndex).reduce((acc, w) => acc + w.length, 0) + letterIndex;
return (
<animated.span
key={index}
style={springs[index]}
className="inline-block transform transition-opacity will-change-transform"
>
{letter}
</animated.span>
);
})}
{wordIndex < words.length - 1 && (
<span style={{ display: 'inline-block', width: '0.3em' }}>&nbsp;</span>
)}
</span>
))}
</p>
</>
);
};
export default SplitText;

View file

@ -0,0 +1,26 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '~/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-unchecked',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -0,0 +1,90 @@
import * as React from 'react';
import { cn } from '~/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b border-border-light transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
),
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View file

@ -0,0 +1,45 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '~/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className = '', ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-md bg-surface-primary',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className = '', ...props }, ref) => (
<TabsPrimitive.Trigger
className={cn(
'inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-gray-700 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-800 data-[state=active]:shadow-sm dark:data-[state=active]:bg-gray-700 dark:data-[state=active]:text-gray-200',
className,
)}
{...props}
ref={ref}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className = '', ...props }, ref) => (
<TabsPrimitive.Content className={cn('mt-2 rounded-md p-6', className)} {...props} ref={ref} />
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -0,0 +1,50 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '~/utils';
type TagProps = React.ComponentPropsWithoutRef<'div'> & {
label: string;
labelClassName?: string;
CancelButton?: React.ReactNode;
LabelNode?: React.ReactNode;
onRemove?: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
const TagPrimitiveRoot = React.forwardRef<HTMLDivElement, TagProps>(
(
{ CancelButton, LabelNode, label, onRemove, className = '', labelClassName = '', ...props },
ref,
) => (
<div
ref={ref}
{...props}
className={cn(
'flex max-h-8 items-center overflow-y-hidden rounded-3xl border-2 border-green-600 bg-green-600/20 text-xs text-green-600 dark:text-white',
className,
)}
>
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>
{LabelNode ? <>{LabelNode} </> : null}
{label}
</div>
{CancelButton
? CancelButton
: onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(e);
}}
className="rounded-full bg-green-600/50"
aria-label={`Remove ${label}`}
>
<X className="m-[1.5px] p-1" />
</button>
)}
</div>
),
);
TagPrimitiveRoot.displayName = 'Tag';
export const Tag = React.memo(TagPrimitiveRoot);

View file

@ -0,0 +1,24 @@
/* eslint-disable */
import * as React from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { cn } from '~/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className = '', ...props }, ref) => {
return (
<textarea
className={cn(
'flex h-20 w-full resize-none rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

View file

@ -0,0 +1,14 @@
import { useAtomValue } from 'jotai';
import { forwardRef, useLayoutEffect, useState } from 'react';
import ReactTextareaAutosize from 'react-textarea-autosize';
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
import { chatDirectionAtom } from '~/store';
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
(props, ref) => {
const [, setIsRerendered] = useState(false);
const chatDirection = useAtomValue(chatDirectionAtom).toLowerCase();
useLayoutEffect(() => setIsRerendered(true), []);
return <ReactTextareaAutosize dir={chatDirection} {...props} ref={ref} />;
},
);

View file

@ -0,0 +1,105 @@
import { useContext, useCallback, useEffect, useState } from 'react';
import { Sun, Moon, Monitor } from 'lucide-react';
import { ThemeContext } from '../theme';
declare global {
interface Window {
lastThemeChange?: number;
}
}
type ThemeType = 'system' | 'dark' | 'light';
const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) => void }) => {
const themeIcons: Record<ThemeType, JSX.Element> = {
system: <Monitor />,
dark: <Moon color="white" />,
light: <Sun />,
};
const nextTheme = theme === 'dark' ? 'light' : 'dark';
const label = `Switch to ${nextTheme} theme`;
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault();
onChange(nextTheme);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [nextTheme, onChange]);
return (
<button
className="flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
aria-label={label}
aria-keyshortcuts="Ctrl+Shift+T"
onClick={(e) => {
e.preventDefault();
onChange(nextTheme);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(nextTheme);
}
}}
>
{themeIcons[theme as ThemeType]}
</button>
);
};
const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
const { theme, setTheme } = useContext(ThemeContext);
const [announcement, setAnnouncement] = useState('');
const changeTheme = useCallback(
(value: string) => {
const now = Date.now();
if (typeof window.lastThemeChange === 'number' && now - window.lastThemeChange < 500) {
return;
}
window.lastThemeChange = now;
setTheme(value);
setAnnouncement(value === 'dark' ? 'Dark theme enabled' : 'Light theme enabled');
},
[setTheme],
);
useEffect(() => {
if (theme === 'system') {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(prefersDarkScheme ? 'dark' : 'light');
}
}, [theme, setTheme]);
useEffect(() => {
if (announcement) {
const timeout = setTimeout(() => setAnnouncement(''), 1000);
return () => clearTimeout(timeout);
}
}, [announcement]);
if (returnThemeOnly === true) {
return <Theme theme={theme} onChange={changeTheme} />;
}
return (
<div className="flex flex-col items-center justify-center bg-white pt-6 dark:bg-gray-900 sm:pt-0">
<div className="absolute bottom-0 left-0 m-4">
<Theme theme={theme} onChange={changeTheme} />
</div>
{announcement && (
<div aria-live="polite" className="sr-only">
{announcement}
</div>
)}
</div>
);
};
export default ThemeSelector;

View file

@ -0,0 +1,57 @@
import * as RadixToast from '@radix-ui/react-toast';
import { NotificationSeverity } from '~/common/types';
import { useToast } from '~/hooks';
export function Toast() {
const { toast, onOpenChange } = useToast();
const severityClassName = {
[NotificationSeverity.INFO]: 'border-gray-500 bg-gray-500',
[NotificationSeverity.SUCCESS]: 'border-green-500 bg-green-500',
[NotificationSeverity.WARNING]: 'border-orange-500 bg-orange-500',
[NotificationSeverity.ERROR]: 'border-red-500 bg-red-500',
};
return (
<RadixToast.Root
open={toast.open}
onOpenChange={onOpenChange}
className="toast-root"
style={{
height: '74px',
marginBottom: '0px',
}}
>
<div className="w-full p-1 text-center md:w-auto md:text-justify">
<div
className={`alert-root pointer-events-auto inline-flex flex-row gap-2 rounded-md border px-3 py-2 text-white ${
severityClassName[toast.severity]
}`}
>
{toast.showIcon && (
<div className="mt-1 flex-shrink-0 flex-grow-0">
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="icon-sm"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
)}
<RadixToast.Description className="flex-1 justify-center gap-2">
<div className="whitespace-pre-wrap text-left">{toast.message}</div>
</RadixToast.Description>
</div>
</div>
</RadixToast.Root>
);
}

View file

@ -0,0 +1,75 @@
import * as Ariakit from '@ariakit/react';
import { AnimatePresence, motion } from 'framer-motion';
import { forwardRef, useMemo } from 'react';
import { cn } from '~/utils';
interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
description: string;
side?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
focusable?: boolean;
role?: string;
}
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
{ description, side = 'top', className, role, ...props },
ref,
) {
const tooltip = Ariakit.useTooltipStore({ placement: side });
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
const { x, y } = useMemo(() => {
const dir = placement.split('-')[0];
switch (dir) {
case 'top':
return { x: 0, y: -8 };
case 'bottom':
return { x: 0, y: 8 };
case 'left':
return { x: -8, y: 0 };
case 'right':
return { x: 8, y: 0 };
default:
return { x: 0, y: 0 };
}
}, [placement]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (role === 'button' && event.key === 'Enter') {
event.preventDefault();
(event.target as HTMLDivElement).click();
}
};
return (
<Ariakit.TooltipProvider store={tooltip} hideTimeout={0}>
<Ariakit.TooltipAnchor
{...props}
ref={ref}
role={role}
onKeyDown={handleKeyDown}
className={cn('cursor-pointer', className)}
/>
<AnimatePresence>
{mounted === true && (
<Ariakit.Tooltip
gutter={4}
alwaysVisible
className="tooltip"
render={
<motion.div
initial={{ opacity: 0, x, y }}
animate={{ opacity: 1, x: 0, y: 0 }}
exit={{ opacity: 0, x, y }}
/>
}
>
<Ariakit.TooltipArrow />
{description}
</Ariakit.Tooltip>
)}
</AnimatePresence>
</Ariakit.TooltipProvider>
);
});

View file

@ -0,0 +1,51 @@
export * from './Accordion';
export * from './AnimatedTabs';
export * from './AlertDialog';
export * from './Breadcrumb';
export * from './Button';
export * from './Checkbox';
export * from './DataTableColumnHeader';
export * from './Dialog';
export * from './DropdownMenu';
export * from './HoverCard';
export * from './Input';
export * from './InputNumber';
export * from './Label';
export * from './OriginalDialog';
export * from './QuestionMark';
export * from './Slider';
export * from './Separator';
export * from './InputCombobox';
export * from './Skeleton';
export * from './Switch';
export * from './Table';
export * from './Tabs';
export * from './Tag';
export * from './Textarea';
export * from './TextareaAutosize';
export * from './Toast';
export * from './Tooltip';
export * from './Pagination';
export * from './Progress';
export * from './InputOTP';
export * from './MultiSearch';
export * from './Resizable';
export { default as Badge } from './Badge';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';
export { default as SplitText } from './SplitText';
export { default as DataTable } from './DataTable';
export { default as FormInput } from './FormInput';
export { default as PixelCard } from './PixelCard';
export { default as FileUpload } from './FileUpload';
export { default as MultiSelect } from './MultiSelect';
export { default as DropdownPopup } from './DropdownPopup';
export { default as DelayedRender } from './DelayedRender';
export { default as ThemeSelector } from './ThemeSelector';
export { default as CheckboxButton } from './CheckboxButton';
export { default as DialogTemplate } from './DialogTemplate';
export { default as SelectDropDown } from './SelectDropDown';
export { default as ControlCombobox } from './ControlCombobox';
export { default as OGDialogTemplate } from './OGDialogTemplate';
export { default as InputWithDropdown } from './InputWithDropDown';
export { default as AnimatedSearchInput } from './AnimatedSearchInput';

View file

@ -0,0 +1,88 @@
//ThemeContext.js
// source: https://plainenglish.io/blog/light-and-dark-mode-in-react-web-application-with-tailwind-css-89674496b942
import { useSetAtom } from 'jotai';
import React, { createContext, useState, useEffect } from 'react';
import { getInitialTheme, applyFontSize } from '~/utils';
import { fontSizeAtom } from '~/store';
type ProviderValue = {
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
};
const defaultContextValue: ProviderValue = {
theme: getInitialTheme(),
setTheme: () => {
return;
},
};
export const isDark = (theme: string): boolean => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === 'dark';
};
export const ThemeContext = createContext<ProviderValue>(defaultContextValue);
export const ThemeProvider = ({
initialTheme,
children,
}: {
initialTheme?: string;
children: React.ReactNode;
}) => {
const [theme, setTheme] = useState(getInitialTheme);
const setFontSize = useSetAtom(fontSizeAtom);
const rawSetTheme = (rawTheme: string) => {
const root = window.document.documentElement;
const darkMode = isDark(rawTheme);
root.classList.remove(darkMode ? 'light' : 'dark');
root.classList.add(darkMode ? 'dark' : 'light');
localStorage.setItem('color-theme', rawTheme);
};
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const changeThemeOnSystemChange = () => {
rawSetTheme(mediaQuery.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', changeThemeOnSystemChange);
return () => {
mediaQuery.removeEventListener('change', changeThemeOnSystemChange);
};
}, []);
useEffect(() => {
const fontSize = localStorage.getItem('fontSize');
if (fontSize == null) {
setFontSize('text-base');
applyFontSize('text-base');
localStorage.setItem('fontSize', JSON.stringify('text-base'));
return;
}
try {
applyFontSize(JSON.parse(fontSize));
} catch (error) {
console.log(error);
}
// Reason: This effect should only run once, and `setFontSize` is a stable function
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (initialTheme) {
rawSetTheme(initialTheme);
}
useEffect(() => {
rawSetTheme(theme);
}, [theme]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};

View file

@ -0,0 +1,10 @@
// Theme exports are now handled by the theme module in the main index.ts
export type { TranslationKeys } from './useLocalize';
export { default as useToast } from './useToast';
export { default as useCombobox } from './useCombobox';
export { default as useLocalize } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';
export { default as useDelayedRender } from './useDelayedRender';
export { default as useOnClickOutside } from './useOnClickOutside';

View file

@ -0,0 +1,37 @@
import { useMemo, useState } from 'react';
import { matchSorter } from 'match-sorter';
import type { OptionWithIcon, MentionOption } from '~/common';
export default function useCombobox({
value,
options,
}: {
value: string;
options: Array<OptionWithIcon | MentionOption>;
}) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const matches = useMemo(() => {
if (!searchValue) {
return options;
}
const keys = ['label', 'value'];
const matches = matchSorter(options, searchValue, { keys });
// Radix Select does not work if we don't render the selected item, so we
// make sure to include it in the list of matches.
const selectedItem = options.find((currentItem) => currentItem.value === value);
if (selectedItem && !matches.includes(selectedItem)) {
matches.push(selectedItem);
}
return matches;
}, [searchValue, value, options]);
return {
open,
setOpen,
searchValue,
setSearchValue,
matches,
};
}

View file

@ -0,0 +1,37 @@
import { useState, useRef, useEffect } from 'react';
import type { ReactNode } from 'react';
const useDelayedRender = (delay: number) => {
const [delayed, setDelayed] = useState(true);
const timerPromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => {
if (delayed) {
const timerPromise = new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
setDelayed(false);
resolve();
}, delay);
return () => {
clearTimeout(timeout);
};
});
timerPromiseRef.current = timerPromise;
}
return () => {
timerPromiseRef.current = null;
};
}, [delay, delayed]);
return (fn: () => ReactNode) => {
if (delayed && timerPromiseRef.current) {
throw timerPromiseRef.current;
}
return fn();
};
};
export default useDelayedRender;

View file

@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { TOptions } from 'i18next';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { resources } from '~/locales/i18n';
import { langAtom } from '~/store';
export type TranslationKeys = keyof typeof resources.en.translation;
export default function useLocalize() {
const lang = useAtomValue(langAtom);
const { t, i18n } = useTranslation();
useEffect(() => {
if (i18n.language !== lang) {
i18n.changeLanguage(lang);
}
}, [lang, i18n]);
return (phraseKey: TranslationKeys, options?: TOptions) => t(phraseKey, options);
}

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export default function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}

View file

@ -0,0 +1,41 @@
import { useEffect, RefObject } from 'react';
type Handler = () => void;
export default function useOnClickOutside(
ref: RefObject<HTMLElement>,
handler: Handler,
excludeIds: string[],
customCondition?: (target: EventTarget | Element | null) => boolean,
): void {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null;
if (target && 'id' in target && excludeIds.includes((target as HTMLElement).id)) {
return;
}
if (
target?.parentNode &&
'id' in target.parentNode &&
excludeIds.includes((target.parentNode as HTMLElement).id)
) {
return;
}
if (customCondition && customCondition(target)) {
return;
}
if (ref.current && !ref.current.contains(target)) {
handler();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, handler]);
}

View file

@ -0,0 +1,58 @@
import { useAtom } from 'jotai';
import { useRef, useEffect } from 'react';
import type { TShowToast } from '~/common';
import { NotificationSeverity } from '~/common';
import { toastState, type ToastState } from '~/store';
export default function useToast(showDelay = 100) {
const [toast, setToast] = useAtom(toastState);
const showTimerRef = useRef<number | null>(null);
const hideTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (showTimerRef.current !== null) {
clearTimeout(showTimerRef.current);
}
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current);
}
};
}, []);
const showToast = ({
message,
severity = NotificationSeverity.SUCCESS,
showIcon = true,
duration = 3000, // default duration for the toast to be visible
status,
}: TShowToast) => {
// Clear existing timeouts
if (showTimerRef.current !== null) {
clearTimeout(showTimerRef.current);
}
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current);
}
// Timeout to show the toast
showTimerRef.current = window.setTimeout(() => {
setToast({
open: true,
message,
severity: (status as NotificationSeverity) ?? severity,
showIcon,
});
// Hides the toast after the specified duration
hideTimerRef.current = window.setTimeout(() => {
setToast((prevToast: ToastState) => ({ ...prevToast, open: false }));
}, duration);
}, showDelay);
};
return {
toast,
onOpenChange: (open: boolean) => setToast({ ...toast, open }),
showToast,
};
}

View file

@ -0,0 +1,24 @@
// Components
export * from './components';
// Hooks
export * from './hooks';
// Common
export * from './common';
export * from './common/types';
// Store
export * from './store';
// SVGs
export * from './svgs';
// Utils
export * from './utils';
// Providers
export * from './Providers';
// Theme
export * from './theme';

View file

@ -0,0 +1,47 @@
import i18n from './i18n';
import English from './en/translation.json';
import French from './fr/translation.json';
import Spanish from './es/translation.json';
describe('i18next translation tests', () => {
// Ensure i18next is initialized before any tests run
beforeAll(async () => {
if (!i18n.isInitialized) {
await i18n.init();
}
});
it('should return the correct translation for a valid key in English', () => {
i18n.changeLanguage('en');
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
});
it('should return the correct translation for a valid key in French', () => {
i18n.changeLanguage('fr');
expect(i18n.t('com_ui_examples')).toBe(French.com_ui_examples);
});
it('should return the correct translation for a valid key in Spanish', () => {
i18n.changeLanguage('es');
expect(i18n.t('com_ui_examples')).toBe(Spanish.com_ui_examples);
});
it('should fallback to English for an invalid language code', () => {
// When an invalid language is provided, i18next should fallback to English
i18n.changeLanguage('invalid-code');
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
});
it('should return the key itself for an invalid key', () => {
i18n.changeLanguage('en');
expect(i18n.t('invalid-key')).toBe('invalid-key'); // Returns the key itself
});
it('should correctly format placeholders in the translation', () => {
i18n.changeLanguage('en');
expect(i18n.t('com_endpoint_default_with_num', { 0: 'John' })).toBe('default: John');
i18n.changeLanguage('fr');
expect(i18n.t('com_endpoint_default_with_num', { 0: 'Marie' })).toBe('par défaut : Marie');
});
});

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "إلغاء"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Cancel·la"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Zrušit"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Annuller"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Abbrechen"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Cancel"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Cancelar"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Tühista"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "لغو کنید"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Peruuta"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Annuler"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "בטל"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Mégse"
}

View file

@ -0,0 +1,87 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import translationEn from './en/translation.json';
import translationAr from './ar/translation.json';
import translationCa from './ca/translation.json';
import translationCs from './cs/translation.json';
import translationDa from './da/translation.json';
import translationDe from './de/translation.json';
import translationEs from './es/translation.json';
import translationEt from './et/translation.json';
import translationFa from './fa/translation.json';
import translationFr from './fr/translation.json';
import translationIt from './it/translation.json';
import translationPl from './pl/translation.json';
import translationPt_BR from './pt-BR/translation.json';
import translationPt_PT from './pt-PT/translation.json';
import translationRu from './ru/translation.json';
import translationJa from './ja/translation.json';
import translationKa from './ka/translation.json';
import translationSv from './sv/translation.json';
import translationKo from './ko/translation.json';
import translationTh from './th/translation.json';
import translationTr from './tr/translation.json';
import translationVi from './vi/translation.json';
import translationNl from './nl/translation.json';
import translationId from './id/translation.json';
import translationHe from './he/translation.json';
import translationHu from './hu/translation.json';
import translationFi from './fi/translation.json';
import translationZh_Hans from './zh-Hans/translation.json';
import translationZh_Hant from './zh-Hant/translation.json';
export const defaultNS = 'translation';
export const resources = {
en: { translation: translationEn },
ar: { translation: translationAr },
ca: { translation: translationCa },
cs: { translation: translationCs },
'zh-Hans': { translation: translationZh_Hans },
'zh-Hant': { translation: translationZh_Hant },
da: { translation: translationDa },
de: { translation: translationDe },
es: { translation: translationEs },
et: { translation: translationEt },
fa: { translation: translationFa },
fr: { translation: translationFr },
it: { translation: translationIt },
pl: { translation: translationPl },
'pt-BR': { translation: translationPt_BR },
'pt-PT': { translation: translationPt_PT },
ru: { translation: translationRu },
ja: { translation: translationJa },
ka: { translation: translationKa },
sv: { translation: translationSv },
ko: { translation: translationKo },
th: { translation: translationTh },
tr: { translation: translationTr },
vi: { translation: translationVi },
nl: { translation: translationNl },
id: { translation: translationId },
he: { translation: translationHe },
hu: { translation: translationHu },
fi: { translation: translationFi },
} as const;
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: {
'zh-TW': ['zh-Hant', 'en'],
'zh-HK': ['zh-Hant', 'en'],
zh: ['zh-Hans', 'en'],
default: ['en'],
},
fallbackNS: 'translation',
ns: ['translation'],
debug: false,
defaultNS,
resources,
interpolation: { escapeValue: false },
});
export default i18n;

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Batal"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Annulla"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "キャンセル"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "გაუქმება"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "취소"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Annuleren"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Anuluj"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Cancelar"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Cancelar"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Отмена"
}

View file

@ -0,0 +1,3 @@
{
"com_ui_cancel": "Avbryt"
}

Some files were not shown because too many files have changed in this diff Show more