mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: Linear-style UI redesign with improved mobile experience
Major changes: - Redesign cabinet with Linear-style components and top navigation - Replace detail modals with dedicated pages (users, broadcasts, email templates) - Add email channel support for broadcasts - Remove pull-to-refresh, improve drag-and-drop on touch devices - Fix Telegram Mini App: fullscreen, back button, scroll restoration - Unify admin pages color scheme to design system - Mobile-first improvements: horizontal tabs for settings, better touch targets
This commit is contained in:
104
src/components/primitives/Button/Button.tsx
Normal file
104
src/components/primitives/Button/Button.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { motion, type HTMLMotionProps } from 'framer-motion';
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { buttonTap, buttonHover, springTransition } from '../../motion/transitions';
|
||||
import { buttonVariants, type ButtonVariants } from './Button.variants';
|
||||
|
||||
export interface ButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'disabled'>, ButtonVariants {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
haptic?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
fullWidth,
|
||||
asChild = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
haptic = true,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { haptic: platformHaptic } = usePlatform();
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (haptic && !isDisabled) {
|
||||
platformHaptic.impact('light');
|
||||
}
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
const classes = cn(buttonVariants({ variant, size, fullWidth }), className);
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot ref={ref} className={classes} {...props}>
|
||||
{children}
|
||||
</Slot>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
className={classes}
|
||||
disabled={isDisabled}
|
||||
onClick={handleClick}
|
||||
whileHover={!isDisabled ? buttonHover : undefined}
|
||||
whileTap={!isDisabled ? buttonTap : undefined}
|
||||
transition={springTransition}
|
||||
{...(props as HTMLMotionProps<'button'>)}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="shrink-0">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="shrink-0">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { buttonVariants, type ButtonVariants };
|
||||
64
src/components/primitives/Button/Button.variants.ts
Normal file
64
src/components/primitives/Button/Button.variants.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
// Base styles
|
||||
[
|
||||
'inline-flex items-center justify-center gap-2',
|
||||
'font-medium transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'select-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: [
|
||||
'bg-accent-500 text-white',
|
||||
'hover:bg-accent-600',
|
||||
'active:bg-accent-700',
|
||||
'shadow-linear-sm hover:shadow-linear',
|
||||
],
|
||||
secondary: [
|
||||
'bg-dark-800/80 text-dark-100',
|
||||
'border border-dark-700/50',
|
||||
'hover:bg-dark-700/80 hover:border-dark-600/50',
|
||||
'active:bg-dark-800',
|
||||
],
|
||||
ghost: ['text-dark-300', 'hover:text-dark-100 hover:bg-dark-800/50', 'active:bg-dark-800'],
|
||||
destructive: [
|
||||
'bg-error-500/10 text-error-400',
|
||||
'border border-error-500/20',
|
||||
'hover:bg-error-500/20 hover:border-error-500/30',
|
||||
'active:bg-error-500/30',
|
||||
],
|
||||
outline: [
|
||||
'border border-dark-700/50 text-dark-200',
|
||||
'hover:bg-dark-800/50 hover:border-dark-600/50 hover:text-dark-100',
|
||||
'active:bg-dark-800',
|
||||
],
|
||||
link: [
|
||||
'text-accent-400',
|
||||
'hover:text-accent-300 hover:underline',
|
||||
'active:text-accent-500',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-sm rounded-linear',
|
||||
md: 'h-10 px-4 text-sm rounded-linear',
|
||||
lg: 'h-12 px-6 text-base rounded-linear-lg',
|
||||
icon: 'h-10 w-10 rounded-linear',
|
||||
'icon-sm': 'h-8 w-8 rounded-linear',
|
||||
'icon-lg': 'h-12 w-12 rounded-linear-lg',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
1
src/components/primitives/Button/index.ts
Normal file
1
src/components/primitives/Button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Button, type ButtonProps, buttonVariants, type ButtonVariants } from './Button';
|
||||
172
src/components/primitives/Command/Command.tsx
Normal file
172
src/components/primitives/Command/Command.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Search icon
|
||||
const SearchIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
|
||||
<path
|
||||
d="M7.333 12.667A5.333 5.333 0 1 0 7.333 2a5.333 5.333 0 0 0 0 10.667ZM14 14l-2.9-2.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Root Command
|
||||
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive>;
|
||||
|
||||
export const Command = forwardRef<HTMLDivElement, CommandProps>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-linear-lg',
|
||||
'bg-dark-900/95 text-dark-100 backdrop-blur-linear',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Command.displayName = 'Command';
|
||||
|
||||
// Input
|
||||
export type CommandInputProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Input>;
|
||||
|
||||
export const CommandInput = forwardRef<HTMLInputElement, CommandInputProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b border-dark-700/50 px-3" cmdk-input-wrapper="">
|
||||
<SearchIcon />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-12 w-full bg-transparent py-3 pl-2 text-sm text-dark-100',
|
||||
'placeholder:text-dark-400',
|
||||
'focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
CommandInput.displayName = 'CommandInput';
|
||||
|
||||
// List (scrollable area)
|
||||
export type CommandListProps = ComponentPropsWithoutRef<typeof CommandPrimitive.List>;
|
||||
|
||||
export const CommandList = forwardRef<HTMLDivElement, CommandListProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'max-h-[300px] overflow-y-auto overflow-x-hidden',
|
||||
'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-dark-700',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
CommandList.displayName = 'CommandList';
|
||||
|
||||
// Empty state
|
||||
export type CommandEmptyProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>;
|
||||
|
||||
export const CommandEmpty = forwardRef<HTMLDivElement, CommandEmptyProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className={cn('py-6 text-center text-sm text-dark-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
CommandEmpty.displayName = 'CommandEmpty';
|
||||
|
||||
// Group
|
||||
export type CommandGroupProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Group>;
|
||||
|
||||
export const CommandGroup = forwardRef<HTMLDivElement, CommandGroupProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-dark-100',
|
||||
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5',
|
||||
'[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-dark-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
CommandGroup.displayName = 'CommandGroup';
|
||||
|
||||
// Separator
|
||||
export type CommandSeparatorProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>;
|
||||
|
||||
export const CommandSeparator = forwardRef<HTMLDivElement, CommandSeparatorProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-dark-700/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
CommandSeparator.displayName = 'CommandSeparator';
|
||||
|
||||
// Item
|
||||
export type CommandItemProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Item>;
|
||||
|
||||
export const CommandItem = forwardRef<HTMLDivElement, CommandItemProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-linear px-2 py-2',
|
||||
'text-sm text-dark-200 outline-none',
|
||||
'aria-selected:bg-dark-800/80 aria-selected:text-dark-100',
|
||||
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
'transition-colors duration-150',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
CommandItem.displayName = 'CommandItem';
|
||||
|
||||
// Shortcut display
|
||||
export type CommandShortcutProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommandShortcut = ({ className, ...props }: CommandShortcutProps) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-dark-400', className)} {...props} />
|
||||
);
|
||||
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
// Loading state
|
||||
export type CommandLoadingProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Loading>;
|
||||
|
||||
export const CommandLoading = forwardRef<HTMLDivElement, CommandLoadingProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Loading
|
||||
ref={ref}
|
||||
className={cn('py-6 text-center text-sm text-dark-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
CommandLoading.displayName = 'CommandLoading';
|
||||
20
src/components/primitives/Command/index.ts
Normal file
20
src/components/primitives/Command/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandSeparator,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandLoading,
|
||||
type CommandProps,
|
||||
type CommandInputProps,
|
||||
type CommandListProps,
|
||||
type CommandEmptyProps,
|
||||
type CommandGroupProps,
|
||||
type CommandSeparatorProps,
|
||||
type CommandItemProps,
|
||||
type CommandShortcutProps,
|
||||
type CommandLoadingProps,
|
||||
} from './Command';
|
||||
190
src/components/primitives/Dialog/Dialog.tsx
Normal file
190
src/components/primitives/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
|
||||
|
||||
// Close icon
|
||||
const CloseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12 4L4 12M4 4l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Context for AnimatePresence
|
||||
const DialogContext = createContext<{ open: boolean }>({ open: false });
|
||||
|
||||
// Root
|
||||
export type DialogProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Root>;
|
||||
|
||||
export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isOpen = open !== undefined ? open : internalOpen;
|
||||
const handleOpenChange = onOpenChange || setInternalOpen;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={isOpen} onOpenChange={handleOpenChange} {...props}>
|
||||
<DialogContext.Provider value={{ open: isOpen }}>{children}</DialogContext.Provider>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
// Trigger
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
// Portal
|
||||
export const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
// Close
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
// Overlay
|
||||
export type DialogOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
|
||||
|
||||
export const DialogOverlay = forwardRef<HTMLDivElement, DialogOverlayProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={backdrop}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={backdropTransition}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
),
|
||||
);
|
||||
|
||||
DialogOverlay.displayName = 'DialogOverlay';
|
||||
|
||||
// Content
|
||||
export interface DialogContentProps extends ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
const { open } = useContext(DialogContext);
|
||||
|
||||
return (
|
||||
<DialogPortal forceMount>
|
||||
<AnimatePresence mode="wait">
|
||||
{open && (
|
||||
<>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',
|
||||
'max-h-[85vh] w-full max-w-lg',
|
||||
'grid gap-4 overflow-auto',
|
||||
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
|
||||
'p-6 shadow-linear-lg',
|
||||
'focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={scale}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={scaleTransition}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-linear p-1.5',
|
||||
'text-dark-400 opacity-70 transition-all',
|
||||
'hover:bg-dark-800/80 hover:opacity-100',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500/50',
|
||||
)}
|
||||
>
|
||||
<CloseIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
// Header
|
||||
export type DialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
// Footer
|
||||
export type DialogFooterProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
// Title
|
||||
export type DialogTitleProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Title>;
|
||||
|
||||
export const DialogTitle = forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-dark-100', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
// Description
|
||||
export type DialogDescriptionProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Description>;
|
||||
|
||||
export const DialogDescription = forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-dark-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
19
src/components/primitives/Dialog/index.ts
Normal file
19
src/components/primitives/Dialog/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogClose,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
type DialogProps,
|
||||
type DialogOverlayProps,
|
||||
type DialogContentProps,
|
||||
type DialogHeaderProps,
|
||||
type DialogFooterProps,
|
||||
type DialogTitleProps,
|
||||
type DialogDescriptionProps,
|
||||
} from './Dialog';
|
||||
294
src/components/primitives/DropdownMenu/DropdownMenu.tsx
Normal file
294
src/components/primitives/DropdownMenu/DropdownMenu.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { motion } from 'framer-motion';
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { dropdown, dropdownTransition } from '../../motion/transitions';
|
||||
|
||||
// Icons
|
||||
const CheckIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M3.5 8.5L6.5 11.5L12.5 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DotIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Root
|
||||
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
// Group
|
||||
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
// Portal
|
||||
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
// Sub
|
||||
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
// RadioGroup
|
||||
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
// SubTrigger
|
||||
export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubTrigger
|
||||
> {
|
||||
inset?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownMenuSubTrigger = forwardRef<HTMLDivElement, DropdownMenuSubTriggerProps>(
|
||||
({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-2 rounded-linear px-2 py-2',
|
||||
'text-sm text-dark-200 outline-none',
|
||||
'focus:bg-dark-800/80 focus:text-dark-100',
|
||||
'data-[state=open]:bg-dark-800/80',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
),
|
||||
);
|
||||
|
||||
DropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';
|
||||
|
||||
// SubContent
|
||||
export type DropdownMenuSubContentProps = ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
>;
|
||||
|
||||
export const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenuSubContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden',
|
||||
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
|
||||
'p-1 text-dark-100 shadow-linear-lg',
|
||||
className,
|
||||
)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={dropdown}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={dropdownTransition}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
),
|
||||
);
|
||||
|
||||
DropdownMenuSubContent.displayName = 'DropdownMenuSubContent';
|
||||
|
||||
// Content
|
||||
export type DropdownMenuContentProps = ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
>;
|
||||
|
||||
export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
|
||||
({ className, sideOffset = 4, ...props }, ref) => {
|
||||
const { haptic } = usePlatform();
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden',
|
||||
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
|
||||
'p-1 text-dark-100 shadow-linear-lg',
|
||||
className,
|
||||
)}
|
||||
onCloseAutoFocus={() => haptic.impact('light')}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={dropdown}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={dropdownTransition}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DropdownMenuContent.displayName = 'DropdownMenuContent';
|
||||
|
||||
// Item
|
||||
export interface DropdownMenuItemProps extends ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Item
|
||||
> {
|
||||
inset?: boolean;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(
|
||||
({ className, inset, destructive, ...props }, ref) => {
|
||||
const { haptic } = usePlatform();
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-linear px-2 py-2',
|
||||
'text-sm outline-none transition-colors duration-150',
|
||||
destructive
|
||||
? 'text-error-400 focus:bg-error-500/10 focus:text-error-300'
|
||||
: 'text-dark-200 focus:bg-dark-800/80 focus:text-dark-100',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
onClick={() => haptic.impact('light')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
||||
|
||||
// CheckboxItem
|
||||
export type DropdownMenuCheckboxItemProps = ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.CheckboxItem
|
||||
>;
|
||||
|
||||
export const DropdownMenuCheckboxItem = forwardRef<HTMLDivElement, DropdownMenuCheckboxItemProps>(
|
||||
({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-linear py-2 pl-8 pr-2',
|
||||
'text-sm text-dark-200 outline-none transition-colors duration-150',
|
||||
'focus:bg-dark-800/80 focus:text-dark-100',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
),
|
||||
);
|
||||
|
||||
DropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';
|
||||
|
||||
// RadioItem
|
||||
export type DropdownMenuRadioItemProps = ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.RadioItem
|
||||
>;
|
||||
|
||||
export const DropdownMenuRadioItem = forwardRef<HTMLDivElement, DropdownMenuRadioItemProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-linear py-2 pl-8 pr-2',
|
||||
'text-sm text-dark-200 outline-none transition-colors duration-150',
|
||||
'focus:bg-dark-800/80 focus:text-dark-100',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
),
|
||||
);
|
||||
|
||||
DropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';
|
||||
|
||||
// Label
|
||||
export interface DropdownMenuLabelProps extends ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Label
|
||||
> {
|
||||
inset?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownMenuLabel = forwardRef<HTMLDivElement, DropdownMenuLabelProps>(
|
||||
({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-medium text-dark-400', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
|
||||
|
||||
// Separator
|
||||
export type DropdownMenuSeparatorProps = ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
>;
|
||||
|
||||
export const DropdownMenuSeparator = forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-dark-700/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
|
||||
|
||||
// Shortcut
|
||||
export type DropdownMenuShortcutProps = React.HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const DropdownMenuShortcut = ({ className, ...props }: DropdownMenuShortcutProps) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-dark-400', className)} {...props} />
|
||||
);
|
||||
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
26
src/components/primitives/DropdownMenu/index.ts
Normal file
26
src/components/primitives/DropdownMenu/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
type DropdownMenuRadioItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
type DropdownMenuSeparatorProps,
|
||||
type DropdownMenuShortcutProps,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
type DropdownMenuSubContentProps,
|
||||
} from './DropdownMenu';
|
||||
96
src/components/primitives/Popover/Popover.tsx
Normal file
96
src/components/primitives/Popover/Popover.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { motion } from 'framer-motion';
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdown, dropdownTransition } from '../../motion/transitions';
|
||||
|
||||
// Close icon
|
||||
const CloseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12 4L4 12M4 4l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Root
|
||||
export const Popover = PopoverPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
// Anchor
|
||||
export const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
// Close
|
||||
export const PopoverClose = PopoverPrimitive.Close;
|
||||
|
||||
// Content
|
||||
export interface PopoverContentProps extends ComponentPropsWithoutRef<
|
||||
typeof PopoverPrimitive.Content
|
||||
> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
(
|
||||
{ className, children, align = 'center', sideOffset = 4, showCloseButton = false, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 overflow-hidden',
|
||||
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
|
||||
'p-4 text-dark-100 shadow-linear-lg outline-none',
|
||||
className,
|
||||
)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={dropdown}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={dropdownTransition}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<PopoverPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-linear p-1.5',
|
||||
'text-dark-400 opacity-70 transition-all',
|
||||
'hover:bg-dark-800/80 hover:opacity-100',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500/50',
|
||||
)}
|
||||
>
|
||||
<CloseIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</PopoverPrimitive.Close>
|
||||
)}
|
||||
</motion.div>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
|
||||
PopoverContent.displayName = 'PopoverContent';
|
||||
|
||||
// Arrow
|
||||
export type PopoverArrowProps = ComponentPropsWithoutRef<typeof PopoverPrimitive.Arrow>;
|
||||
|
||||
export const PopoverArrow = forwardRef<SVGSVGElement, PopoverArrowProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<PopoverPrimitive.Arrow ref={ref} className={cn('fill-dark-800', className)} {...props} />
|
||||
),
|
||||
);
|
||||
|
||||
PopoverArrow.displayName = 'PopoverArrow';
|
||||
10
src/components/primitives/Popover/index.ts
Normal file
10
src/components/primitives/Popover/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverAnchor,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverArrow,
|
||||
type PopoverContentProps,
|
||||
type PopoverArrowProps,
|
||||
} from './Popover';
|
||||
183
src/components/primitives/Select/Select.tsx
Normal file
183
src/components/primitives/Select/Select.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { motion } from 'framer-motion';
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { dropdown, dropdownTransition } from '../../motion/transitions';
|
||||
|
||||
// Icons
|
||||
const ChevronDownIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
|
||||
<path
|
||||
d="M4 6L8 10L12 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-accent-400">
|
||||
<path
|
||||
d="M3.5 8.5L6.5 11.5L12.5 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Root
|
||||
export const Select = SelectPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export interface SelectTriggerProps extends ComponentPropsWithoutRef<
|
||||
typeof SelectPrimitive.Trigger
|
||||
> {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
|
||||
({ className, placeholder, ...props }, ref) => {
|
||||
const { haptic } = usePlatform();
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between gap-2 rounded-linear px-3',
|
||||
'border border-dark-700/50 bg-dark-800/80',
|
||||
'text-sm text-dark-100 placeholder:text-dark-400',
|
||||
'hover:border-dark-600/50 hover:bg-dark-700/80',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500/50 focus:ring-offset-2 focus:ring-offset-dark-950',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-all duration-200',
|
||||
'[&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
onClick={() => haptic.impact('light')}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
<SelectPrimitive.Icon>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SelectTrigger.displayName = 'SelectTrigger';
|
||||
|
||||
// Content
|
||||
export type SelectContentProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Content>;
|
||||
|
||||
export const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
|
||||
({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-80 min-w-[8rem] overflow-hidden',
|
||||
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
|
||||
'text-dark-100 shadow-linear-lg',
|
||||
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}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={dropdown}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={dropdownTransition}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
|
||||
SelectContent.displayName = 'SelectContent';
|
||||
|
||||
// Item
|
||||
export interface SelectItemProps extends ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-linear py-2 pl-3 pr-8',
|
||||
'text-sm text-dark-200 outline-none',
|
||||
'hover:bg-dark-800/80 hover:text-dark-100',
|
||||
'focus:bg-dark-800/80 focus:text-dark-100',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'data-[state=checked]:text-accent-400',
|
||||
'transition-colors duration-150',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
<span className="absolute right-2 flex h-4 w-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
</SelectPrimitive.Item>
|
||||
),
|
||||
);
|
||||
|
||||
SelectItem.displayName = 'SelectItem';
|
||||
|
||||
// Group
|
||||
export const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
// Label
|
||||
export type SelectLabelProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Label>;
|
||||
|
||||
export const SelectLabel = forwardRef<HTMLDivElement, SelectLabelProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-3 py-1.5 text-xs font-medium text-dark-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
SelectLabel.displayName = 'SelectLabel';
|
||||
|
||||
// Separator
|
||||
export type SelectSeparatorProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>;
|
||||
|
||||
export const SelectSeparator = forwardRef<HTMLDivElement, SelectSeparatorProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-dark-700/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
SelectSeparator.displayName = 'SelectSeparator';
|
||||
14
src/components/primitives/Select/index.ts
Normal file
14
src/components/primitives/Select/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
type SelectTriggerProps,
|
||||
type SelectContentProps,
|
||||
type SelectItemProps,
|
||||
type SelectLabelProps,
|
||||
type SelectSeparatorProps,
|
||||
} from './Select';
|
||||
284
src/components/primitives/Sheet/Sheet.tsx
Normal file
284
src/components/primitives/Sheet/Sheet.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { motion, AnimatePresence, useDragControls, PanInfo } from 'framer-motion';
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { useBackButton } from '@/platform';
|
||||
import {
|
||||
backdrop,
|
||||
backdropTransition,
|
||||
sheetSlideUp,
|
||||
sheetTransition,
|
||||
} from '../../motion/transitions';
|
||||
|
||||
// Close icon
|
||||
const CloseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12 4L4 12M4 4l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Context for AnimatePresence and control
|
||||
interface SheetContextValue {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SheetContext = createContext<SheetContextValue>({
|
||||
open: false,
|
||||
onClose: () => {},
|
||||
});
|
||||
|
||||
// Root
|
||||
export interface SheetProps extends ComponentPropsWithoutRef<typeof DialogPrimitive.Root> {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: SheetProps) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isOpen = open !== undefined ? open : internalOpen;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (onOpenChange) {
|
||||
onOpenChange(newOpen);
|
||||
} else {
|
||||
setInternalOpen(newOpen);
|
||||
}
|
||||
|
||||
if (!newOpen && onClose) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onOpenChange, onClose],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={isOpen} onOpenChange={handleOpenChange} {...props}>
|
||||
<SheetContext.Provider value={{ open: isOpen, onClose: handleClose }}>
|
||||
{children}
|
||||
</SheetContext.Provider>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
// Trigger
|
||||
export const SheetTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
// Portal
|
||||
export const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
// Close
|
||||
export const SheetClose = DialogPrimitive.Close;
|
||||
|
||||
// Overlay
|
||||
export type SheetOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
|
||||
|
||||
export const SheetOverlay = forwardRef<HTMLDivElement, SheetOverlayProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={backdrop}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={backdropTransition}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
),
|
||||
);
|
||||
|
||||
SheetOverlay.displayName = 'SheetOverlay';
|
||||
|
||||
// Content
|
||||
export interface SheetContentProps extends ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> {
|
||||
showDragHandle?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
enableDragToClose?: boolean;
|
||||
closeThreshold?: number;
|
||||
}
|
||||
|
||||
export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
showDragHandle = true,
|
||||
showCloseButton = false,
|
||||
enableDragToClose = true,
|
||||
closeThreshold = 0.3,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { open, onClose } = useContext(SheetContext);
|
||||
const { haptic } = usePlatform();
|
||||
const dragControls = useDragControls();
|
||||
|
||||
// Back button integration
|
||||
useBackButton(open ? onClose : null);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||
const velocity = info.velocity.y;
|
||||
const offset = info.offset.y;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// Close if dragged down fast enough or past threshold
|
||||
if (velocity > 500 || offset > height * closeThreshold) {
|
||||
haptic.impact('light');
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[closeThreshold, haptic, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetPortal forceMount>
|
||||
<AnimatePresence mode="wait">
|
||||
{open && (
|
||||
<>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-50',
|
||||
'flex flex-col',
|
||||
'max-h-[85vh]',
|
||||
'rounded-t-2xl border-t border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
|
||||
'shadow-linear-lg',
|
||||
'focus:outline-none',
|
||||
'pb-[env(safe-area-inset-bottom,0px)]',
|
||||
className,
|
||||
)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={sheetSlideUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={sheetTransition}
|
||||
drag={enableDragToClose ? 'y' : false}
|
||||
dragControls={dragControls}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.6 }}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{showDragHandle && (
|
||||
<div
|
||||
className="flex cursor-grab justify-center pb-2 pt-3 active:cursor-grabbing"
|
||||
onPointerDown={(e) => dragControls.start(e)}
|
||||
>
|
||||
<div className="h-1 w-10 rounded-full bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-linear p-1.5',
|
||||
'text-dark-400 opacity-70 transition-all',
|
||||
'hover:bg-dark-800/80 hover:opacity-100',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500/50',
|
||||
)}
|
||||
>
|
||||
<CloseIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">{children}</div>
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SheetPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SheetContent.displayName = 'SheetContent';
|
||||
|
||||
// Header
|
||||
export type SheetHeaderProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SheetHeader = ({ className, ...props }: SheetHeaderProps) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 px-2 pt-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
// Footer
|
||||
export type SheetFooterProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SheetFooter = ({ className, ...props }: SheetFooterProps) => (
|
||||
<div
|
||||
className={cn('flex flex-col gap-2 px-2 pt-4 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
// Title
|
||||
export type SheetTitleProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Title>;
|
||||
|
||||
export const SheetTitle = forwardRef<HTMLHeadingElement, SheetTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-dark-100', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
SheetTitle.displayName = 'SheetTitle';
|
||||
|
||||
// Description
|
||||
export type SheetDescriptionProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Description>;
|
||||
|
||||
export const SheetDescription = forwardRef<HTMLParagraphElement, SheetDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-dark-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
SheetDescription.displayName = 'SheetDescription';
|
||||
19
src/components/primitives/Sheet/index.ts
Normal file
19
src/components/primitives/Sheet/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetPortal,
|
||||
SheetClose,
|
||||
SheetOverlay,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
type SheetProps,
|
||||
type SheetOverlayProps,
|
||||
type SheetContentProps,
|
||||
type SheetHeaderProps,
|
||||
type SheetFooterProps,
|
||||
type SheetTitleProps,
|
||||
type SheetDescriptionProps,
|
||||
} from './Sheet';
|
||||
73
src/components/primitives/Switch/Switch.tsx
Normal file
73
src/components/primitives/Switch/Switch.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { motion } from 'framer-motion';
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { springTransition } from '../../motion/transitions';
|
||||
|
||||
export interface SwitchProps extends Omit<
|
||||
ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
|
||||
'onChange'
|
||||
> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
onChange?: (checked: boolean) => void;
|
||||
haptic?: boolean;
|
||||
}
|
||||
|
||||
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
({ className, label, description, onChange, onCheckedChange, haptic = true, ...props }, ref) => {
|
||||
const { haptic: platformHaptic } = usePlatform();
|
||||
|
||||
const handleCheckedChange = (checked: boolean) => {
|
||||
if (haptic) {
|
||||
platformHaptic.impact('light');
|
||||
}
|
||||
onCheckedChange?.(checked);
|
||||
onChange?.(checked);
|
||||
};
|
||||
|
||||
const switchElement = (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full',
|
||||
'border-2 border-transparent transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-accent-500 data-[state=unchecked]:bg-dark-700',
|
||||
className,
|
||||
)}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb asChild>
|
||||
<motion.span
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0',
|
||||
'data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
layout
|
||||
transition={springTransition}
|
||||
/>
|
||||
</SwitchPrimitive.Thumb>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
|
||||
if (!label) {
|
||||
return switchElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-dark-100">{label}</span>
|
||||
{description && <span className="mt-0.5 block text-sm text-dark-400">{description}</span>}
|
||||
</div>
|
||||
{switchElement}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
1
src/components/primitives/Switch/index.ts
Normal file
1
src/components/primitives/Switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Switch, type SwitchProps } from './Switch';
|
||||
75
src/components/primitives/Tooltip/Tooltip.tsx
Normal file
75
src/components/primitives/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { motion } from 'framer-motion';
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { tooltip, tooltipTransition } from '../../motion/transitions';
|
||||
|
||||
// Provider - wrap your app with this
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
// Root
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
// Content
|
||||
export type TooltipContentProps = ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
|
||||
|
||||
export const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
|
||||
({ className, sideOffset = 4, children, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden',
|
||||
'rounded-linear px-3 py-1.5',
|
||||
'border border-dark-700/50 bg-dark-800',
|
||||
'text-xs text-dark-100 shadow-linear',
|
||||
className,
|
||||
)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
variants={tooltip}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={tooltipTransition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
|
||||
TooltipContent.displayName = 'TooltipContent';
|
||||
|
||||
// Convenience wrapper component
|
||||
export interface SimpleTooltipProps {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
align?: 'start' | 'center' | 'end';
|
||||
delayDuration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SimpleTooltip = ({
|
||||
content,
|
||||
children,
|
||||
side = 'top',
|
||||
align = 'center',
|
||||
delayDuration = 200,
|
||||
className,
|
||||
}: SimpleTooltipProps) => (
|
||||
<Tooltip delayDuration={delayDuration}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} align={align} className={className}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
9
src/components/primitives/Tooltip/index.ts
Normal file
9
src/components/primitives/Tooltip/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
SimpleTooltip,
|
||||
type TooltipContentProps,
|
||||
type SimpleTooltipProps,
|
||||
} from './Tooltip';
|
||||
10
src/components/primitives/index.ts
Normal file
10
src/components/primitives/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Re-export all primitives from a single entry point
|
||||
export * from './Button';
|
||||
export * from './Command';
|
||||
export * from './Dialog';
|
||||
export * from './DropdownMenu';
|
||||
export * from './Popover';
|
||||
export * from './Select';
|
||||
export * from './Sheet';
|
||||
export * from './Switch';
|
||||
export * from './Tooltip';
|
||||
Reference in New Issue
Block a user