feat(backoffice): redesign backoffice UI dengan shadcn/ui
Menyeluruh merancang ulang apps/backoffice memakai pola shadcn/ui sambil mempertahankan seluruh palette warna existing (primary/neutral/success/ info/warning/danger) dan typography Bai Jamjuree. UI library (libs/ui): - Atoms existing (Button, Input, Label, Textarea, Card, Dialog, Drawer, Form, Select, Toggle) dirombak ke pola shadcn, API dipertahankan agar sibling apps (gacha, dimentorin, hackathon, qrcampaign) tidak break - Select atom beralih dari native ke Radix Select + NativeSelect fallback - 18 atom baru: Badge, Avatar, Separator, Skeleton, Tabs, Tooltip, DropdownMenu, Popover, AlertDialog, Checkbox, RadioGroup, Switch, ScrollArea, Sheet, Sidebar, Breadcrumb, Table, + use-mobile hook - DataTable, Filter, BackofficeWrapper, Pagination, Modal (alias Dialog) direstyle dengan token baru - CSS variable layer shadcn ditambahkan (--color-primary, --color-sidebar-*, dll) dipetakan ke palette existing; tw-animate-css di-import untuk transisi Backoffice shell: - Sidebar bersarang SidebarProvider + SidebarInset, built-in mobile Sheet, user DropdownMenu (Avatar + logout), ikon lucide-react Pages redesign (19 list/dashboard/settings): - accounts, cms-events, cms-testimonials, dashboard, dashboard-dimentorin (dengan recharts di Card), feedback-review-dimentorin, gacha-roll, hackathon-dashboard, hackathon-submissions, hackathon-teams, hackathon-users, permissions, prizes, roadmap-dimentorin, roles, session-dimentorin, settings-dimentorin (Tabs), transactions, users-dimentorin (Tabs mentor/mentee) - Pola unified: BackofficeWrapper > Card (CardHeader toolbar + CardContent DataTable) + AlertDialog untuk delete confirm - Form accounts_/$id diredesign; form pages lain tetap pakai ControlledInputField yang sekarang berbasis shadcn Input Dependencies: - +20 radix-ui primitives, lucide-react, cmdk, input-otp - Select native patched di dimentorin (profile-sidebar, schedule step) untuk tetap kompatibel via NativeSelect alias Verifikasi: - nx build backoffice: sukses (2.33s, 4801 modules) - nx build dimentorin/gacha/hackathon/qrcampaign: semua sukses (zero regresi dari rewrite libs/ui) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
53079fe868
commit
63dfb7299f
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { buttonVariants } from '../button/button';
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn('text-lg font-semibold leading-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants({ variant: 'primary' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'secondary' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './alert-dialog';
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
'relative flex size-9 shrink-0 overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn('aspect-square size-full object-cover', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center rounded-full bg-primary-100 text-primary-700 text-sm font-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './avatar';
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap transition-colors overflow-hidden [&>svg]:size-3 [&>svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary-500 text-white',
|
||||
secondary:
|
||||
'border-transparent bg-neutral-100 text-neutral-700',
|
||||
success:
|
||||
'border-transparent bg-success-100 text-success-700',
|
||||
warning:
|
||||
'border-transparent bg-warning-100 text-warning-800',
|
||||
info:
|
||||
'border-transparent bg-info-100 text-info-700',
|
||||
destructive:
|
||||
'border-transparent bg-danger-100 text-danger-700',
|
||||
outline:
|
||||
'border-neutral-200 bg-background text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './badge';
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Breadcrumb(props: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './breadcrumb';
|
||||
@@ -1,31 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
ButtonHTMLAttributes,
|
||||
DetailedHTMLProps,
|
||||
} from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px] transition-colors duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-semibold transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
|
||||
primary:
|
||||
'bg-primary-500 text-white shadow-sm hover:bg-primary-600 active:bg-primary-700',
|
||||
secondary:
|
||||
'bg-white hover:text-primary-600 hover:bg-gray-50 text-primary-500 shadow-md border',
|
||||
text: 'bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
||||
'border border-neutral-200 bg-white text-primary-500 shadow-sm hover:bg-primary-50 hover:text-primary-600 hover:border-primary-200',
|
||||
text: 'bg-transparent text-primary-500 hover:bg-primary-50 hover:text-primary-600',
|
||||
bordered:
|
||||
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
||||
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
|
||||
'border border-primary-500 bg-transparent text-primary-500 hover:bg-primary-50 hover:border-primary-600 hover:text-primary-600',
|
||||
success:
|
||||
'bg-success-500 text-white shadow-sm hover:bg-success-600 active:bg-success-700',
|
||||
danger:
|
||||
'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md',
|
||||
'bg-danger-100 text-danger-600 shadow-sm hover:bg-danger-200 hover:text-danger-700',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-[12px] max-h-[36px]',
|
||||
md: 'text-[15px] max-h-[40px]',
|
||||
lg: 'text-[19px] max-h-[44px]',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-9 px-4 text-sm',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
icon: 'h-9 w-9 p-0',
|
||||
},
|
||||
},
|
||||
@@ -36,27 +36,22 @@ export const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
type TButtonProps = DetailedHTMLProps<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export const Button: FC<TButtonProps> = ({
|
||||
variant,
|
||||
size,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant, size, asChild = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer size-4 shrink-0 rounded-sm border border-neutral-300 bg-background shadow-sm',
|
||||
'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-500 data-[state=checked]:text-white data-[state=checked]:border-primary-500',
|
||||
'data-[state=indeterminate]:bg-primary-500 data-[state=indeterminate]:text-white data-[state=indeterminate]:border-primary-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
{props.checked === 'indeterminate' ? (
|
||||
<Minus className="size-3.5" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './checkbox';
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import * as React from 'react';
|
||||
import { LuX } from 'react-icons/lu';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Dialog({
|
||||
@@ -70,7 +70,7 @@ function DialogContent({
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<LuX />
|
||||
<X />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal
|
||||
data-slot="dropdown-menu-portal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground 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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'data-[inset]:pl-8',
|
||||
"data-[variant=destructive]:text-danger-600 data-[variant=destructive]:focus:bg-danger-50 data-[variant=destructive]:focus:text-danger-700",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('-mx-1 my-1 h-px bg-neutral-100', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
'data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dropdown-menu';
|
||||
@@ -1,10 +1,26 @@
|
||||
export * from './alert-dialog';
|
||||
export * from './avatar';
|
||||
export * from './badge';
|
||||
export * from './breadcrumb';
|
||||
export * from './button';
|
||||
export * from './card';
|
||||
export * from './checkbox';
|
||||
export * from './dialog';
|
||||
export * from './drawer';
|
||||
export * from './dropdown-menu';
|
||||
export * from './form';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
export * from './popover';
|
||||
export * from './radio-group';
|
||||
export * from './scroll-area';
|
||||
export * from './select';
|
||||
export * from './separator';
|
||||
export * from './sheet';
|
||||
export * from './sidebar';
|
||||
export * from './skeleton';
|
||||
export * from './table';
|
||||
export * from './tabs';
|
||||
export * from './textarea';
|
||||
export * from './toggle';
|
||||
export * from './toggle';
|
||||
export * from './tooltip';
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
InputHTMLAttributes,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import Ant Design icons
|
||||
import * as React from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Button } from '../button';
|
||||
|
||||
type TInputType =
|
||||
| 'text'
|
||||
@@ -23,81 +16,80 @@ type TInputSize = 'sm' | 'md' | 'lg';
|
||||
type Width = 'standard' | 'custom';
|
||||
|
||||
type TInputProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'size' | 'type'
|
||||
> & {
|
||||
type?: TInputType;
|
||||
size?: TInputSize;
|
||||
widthform?: Width;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TInputSize, { textSize: string; iconSize: string }> =
|
||||
{
|
||||
sm: { textSize: 'text-[10px] h-[28px]', iconSize: 'text-[10px]' },
|
||||
md: { textSize: 'text-[12px] h-[30px]', iconSize: 'text-[12px]' },
|
||||
lg: { textSize: 'text-[15px] h-[34px]', iconSize: 'text-[15px]' },
|
||||
};
|
||||
const sizeClasses: Record<TInputSize, string> = {
|
||||
sm: 'h-8 text-xs',
|
||||
md: 'h-9 text-sm',
|
||||
lg: 'h-11 text-base',
|
||||
};
|
||||
|
||||
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
||||
export const Input = React.forwardRef<HTMLInputElement, TInputProps>(
|
||||
(
|
||||
{
|
||||
type = 'text',
|
||||
size = 'md',
|
||||
placeholder,
|
||||
widthform = 'standard',
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
export const Input: FC<TInputProps> = ({
|
||||
type = 'text',
|
||||
size = 'md',
|
||||
placeholder = 'Placeholder',
|
||||
widthform = 'standard',
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false); // State for password visibility
|
||||
const togglePasswordVisibility = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setShowPassword((prev) => !prev);
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setShowPassword((prev) => !prev);
|
||||
};
|
||||
|
||||
const mergedClassName = cn(
|
||||
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree w-full ${
|
||||
widthform === 'standard' ? 'min-w-70' : ''
|
||||
}`,
|
||||
sizeClasses[size].textSize,
|
||||
disabled && disabledClass,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
className={mergedClassName}
|
||||
type={type === 'password' && showPassword ? 'text' : type}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<div className="absolute end-0 px-3 h-full flex items-center">
|
||||
<Button
|
||||
return (
|
||||
<div className="relative flex items-center w-full">
|
||||
<input
|
||||
ref={ref}
|
||||
data-slot="input"
|
||||
type={type === 'password' && showPassword ? 'text' : type}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'flex w-full rounded-md border border-input bg-background px-3 py-1 font-bai-jamjuree text-foreground shadow-xs transition-colors',
|
||||
'placeholder:text-muted-foreground',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
sizeClasses[size],
|
||||
type === 'password' && 'pr-9',
|
||||
widthform === 'standard' && 'min-w-0',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
variant="text"
|
||||
size={size}
|
||||
tabIndex={-1}
|
||||
onClick={togglePasswordVisibility}
|
||||
className={cn(
|
||||
'relative aspect-square -me-2 p-1.5',
|
||||
sizeClasses[size].iconSize,
|
||||
disabled && 'cursor-not-allowed'
|
||||
)}
|
||||
disabled={disabled}
|
||||
className="absolute right-2 inline-flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeInvisibleOutlined
|
||||
style={{ color: 'var(--color-neutral-500)' }}
|
||||
/>
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<EyeOutlined style={{ color: 'var(--color-neutral-500)' }} />
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './popover';
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground 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}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './radio-group';
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import { Circle } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
'aspect-square size-4 shrink-0 rounded-full border border-neutral-300 text-primary-500 shadow-sm',
|
||||
'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]:border-primary-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Circle className="size-2 fill-primary-500 text-primary-500" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './scroll-area';
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none p-px transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 w-full flex-col border-t border-t-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-neutral-300"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -1,50 +1,248 @@
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
SelectHTMLAttributes,
|
||||
} from 'react';
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
type TSelectSize = 'sm' | 'md' | 'lg';
|
||||
type Width = 'standard' | 'custom';
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Radix-backed shadcn Select */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type TSelectProps = Omit<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
'size'
|
||||
> & {
|
||||
size?: TSelectSize;
|
||||
widthform?: Width;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<TSelectSize, string> = {
|
||||
sm: 'text-[10px] max-h-[34px]',
|
||||
md: 'text-[12px] max-h-[36px]',
|
||||
lg: 'text-[15px] max-h-[38px]',
|
||||
};
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
const disabledClass =
|
||||
'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
export const Select: FC<TSelectProps> = ({
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'md',
|
||||
widthform = 'standard',
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}) {
|
||||
const sizeCls =
|
||||
size === 'sm' ? 'h-8 text-xs' : size === 'lg' ? 'h-11 text-base' : 'h-9 text-sm';
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-1 font-bai-jamjuree text-foreground shadow-xs transition-colors outline-none",
|
||||
'data-[placeholder]:text-muted-foreground',
|
||||
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
'*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2',
|
||||
sizeCls,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const mergedClassName = cn(
|
||||
`appearance-none px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md`,
|
||||
sizeClasses[size],
|
||||
widthform === 'standard' && 'min-w-70',
|
||||
disabled && disabledClass,
|
||||
className
|
||||
);
|
||||
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<select className={mergedClassName} disabled={disabled} {...rest}>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground 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',
|
||||
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)] scroll-my-1'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none",
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* NativeSelect — backward-compat wrapper over <select> for callers */
|
||||
/* that still pass <option> children (SelectField, legacy pages). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type TNativeSelectSize = 'sm' | 'md' | 'lg';
|
||||
type Width = 'standard' | 'custom';
|
||||
|
||||
type TNativeSelectProps = Omit<
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||
'size'
|
||||
> & {
|
||||
size?: TNativeSelectSize;
|
||||
widthform?: Width;
|
||||
};
|
||||
|
||||
const nativeSizeClasses: Record<TNativeSelectSize, string> = {
|
||||
sm: 'h-8 text-xs',
|
||||
md: 'h-9 text-sm',
|
||||
lg: 'h-11 text-base',
|
||||
};
|
||||
|
||||
export const NativeSelect = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
TNativeSelectProps
|
||||
>(({ size = 'md', widthform = 'standard', disabled, className, children, ...rest }, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'appearance-none rounded-md border border-input bg-background px-3 py-1 font-bai-jamjuree text-foreground shadow-xs transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
nativeSizeClasses[size],
|
||||
widthform === 'standard' && 'min-w-0 w-full',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
});
|
||||
NativeSelect.displayName = 'NativeSelect';
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './separator';
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-neutral-200 shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sheet';
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
side === 'bottom' &&
|
||||
'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './sidebar';
|
||||
export * from './use-mobile';
|
||||
@@ -0,0 +1,681 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Button } from '../button';
|
||||
import { Separator } from '../separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '../sheet';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '../tooltip';
|
||||
import { useIsMobile } from './use-mobile';
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = '16rem';
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
const SIDEBAR_WIDTH_ICON = '3rem';
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed';
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((o) => !o)
|
||||
: setOpen((o) => !o);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
const state: 'expanded' | 'collapsed' = open ? 'expanded' : 'collapsed';
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right';
|
||||
variant?: 'sidebar' | 'floating' | 'inset';
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="text"
|
||||
size="icon"
|
||||
className={cn('size-7', className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear sm:flex',
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border',
|
||||
'group-data-[side=left]:-right-4 group-data-[side=right]:left-0',
|
||||
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar',
|
||||
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="header"
|
||||
data-slot="sidebar-header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="footer"
|
||||
data-slot="sidebar-footer"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-sidebar="separator"
|
||||
data-slot="sidebar-separator"
|
||||
className={cn('bg-sidebar-border mx-2 w-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="content"
|
||||
data-slot="sidebar-content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="group"
|
||||
data-slot="sidebar-group"
|
||||
className={cn(
|
||||
'relative flex w-full min-w-0 flex-col p-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
return (
|
||||
<Comp
|
||||
data-sidebar="group-label"
|
||||
data-slot="sidebar-group-label"
|
||||
className={cn(
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2',
|
||||
"[&>svg]:size-4 [&>svg]:shrink-0",
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
data-sidebar="group-action"
|
||||
data-slot="sidebar-group-action"
|
||||
className={cn(
|
||||
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="group-content"
|
||||
data-slot="sidebar-group-content"
|
||||
className={cn('w-full text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-sidebar="menu"
|
||||
data-slot="sidebar-menu"
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-sidebar="menu-item"
|
||||
data-slot="sidebar-menu-item"
|
||||
className={cn('group/menu-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-ring transition-[width,height,padding] focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
outline:
|
||||
'bg-background shadow-[0_0_0_1px_var(--color-sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--color-sidebar-accent)]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 text-sm',
|
||||
sm: 'h-7 text-xs',
|
||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-sidebar="menu-button"
|
||||
data-slot="sidebar-menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === 'string') {
|
||||
tooltip = { children: tooltip };
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
data-sidebar="menu-action"
|
||||
data-slot="sidebar-menu-action"
|
||||
className={cn(
|
||||
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover &&
|
||||
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="menu-badge"
|
||||
data-slot="sidebar-menu-badge"
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { showIcon?: boolean }) {
|
||||
const width = React.useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []);
|
||||
return (
|
||||
<div
|
||||
data-sidebar="menu-skeleton"
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={{ '--skeleton-width': width } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-sidebar="menu-sub"
|
||||
data-slot="sidebar-menu-sub"
|
||||
className={cn(
|
||||
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-sidebar="menu-sub-item"
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
className={cn('group/menu-sub-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = 'md',
|
||||
isActive,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
return (
|
||||
<Comp
|
||||
data-sidebar="menu-sub-button"
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './skeleton';
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn('animate-pulse rounded-md bg-neutral-100', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './table';
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn('[&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
'border-t bg-neutral-50 font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-neutral-50/60 data-[state=selected]:bg-primary-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'h-10 px-3 text-left align-middle text-xs font-semibold text-muted-foreground whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './tabs';
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn('flex flex-col gap-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
'inline-flex h-10 w-fit items-center justify-center rounded-md bg-neutral-100 p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-white data-[state=active]:text-primary-600 data-[state=active]:shadow-sm',
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn('flex-1 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -1,18 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
ReactElement,
|
||||
TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
type TTextareaSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
type TTextareaProps = Omit<
|
||||
DetailedHTMLProps<
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
HTMLTextAreaElement
|
||||
>,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
'size'
|
||||
> & {
|
||||
size?: TTextareaSize;
|
||||
@@ -20,40 +14,40 @@ type TTextareaProps = Omit<
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TTextareaSize, string> = {
|
||||
sm: 'text-[10px]',
|
||||
md: 'text-[12px]',
|
||||
lg: 'text-[15px]',
|
||||
sm: 'text-xs min-h-[60px]',
|
||||
md: 'text-sm min-h-[72px]',
|
||||
lg: 'text-base min-h-[96px]',
|
||||
};
|
||||
|
||||
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
||||
const errorClass =
|
||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500';
|
||||
|
||||
export const Textarea: FC<TTextareaProps> = ({
|
||||
size = 'md',
|
||||
placeholder = 'Placeholder',
|
||||
disabled,
|
||||
error,
|
||||
className,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const mergedClassName = cn(
|
||||
'rounded-md border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 px-[12px] py-[8px] bg-white text-neutral-800 placeholder:text-neutral-300 invalid:border-danger-500 invalid:text-danger-500',
|
||||
sizeClasses[size],
|
||||
disabled && disabledClass,
|
||||
error && errorClass,
|
||||
className
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
className={mergedClassName}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
style={{ resize: disabled ? 'none' : 'both' }}
|
||||
{...rest}
|
||||
></textarea>
|
||||
{error && <p className="text-danger-500 text-xs">{error}</p>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TTextareaProps>(
|
||||
({ size = 'md', placeholder, disabled, error, className, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
aria-invalid={!!error}
|
||||
className={cn(
|
||||
'flex w-full rounded-md border border-input bg-background px-3 py-2 font-bai-jamjuree text-foreground shadow-xs transition-colors',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:resize-none',
|
||||
'resize-y',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-destructive" data-slot="textarea-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
@@ -1,27 +1,87 @@
|
||||
import { cn } from "@imphnen-frontend-service/utils";
|
||||
import { DetailedHTMLProps, FC, HTMLAttributes } from "react";
|
||||
'use client';
|
||||
|
||||
export type ToggleInputProps = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
/* Radix-backed Switch primitive */
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors',
|
||||
'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-500 data-[state=unchecked]:bg-neutral-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform',
|
||||
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
));
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
/* Backward-compat ToggleInput: label + switch row */
|
||||
export type ToggleInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'type' | 'onChange' | 'checked' | 'defaultChecked'
|
||||
> & {
|
||||
label?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
label?: string;
|
||||
labelClassName?: string;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export const ToggleInput: FC<ToggleInputProps> = ({ label, className, labelClassName, ...rest }) => {
|
||||
export const ToggleInput: React.FC<ToggleInputProps> = ({
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
checked,
|
||||
defaultChecked,
|
||||
onChange,
|
||||
disabled,
|
||||
id,
|
||||
...rest
|
||||
}) => {
|
||||
const reactId = React.useId();
|
||||
const fieldId = id ?? reactId;
|
||||
return (
|
||||
<label className={cn("flex items-center gap-5 cursor-pointer mb-8", className)}>
|
||||
<span className={cn("text-p3 text-gray-800 font-medium", labelClassName)}>{label}</span>
|
||||
<input type="checkbox" className="sr-only peer" {...rest} />
|
||||
<div
|
||||
className={cn(
|
||||
"w-14 h-7 bg-gray-300 rounded-3xl relative transition-colors",
|
||||
"peer-checked:bg-blue-600 peer-focus:outline peer-focus:outline-blue-500 peer-checked:[&>div]:translate-x-6.5",
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-0.5 top-0.5 h-6 w-6 bg-white rounded-full shadow transition-transform peer-checked:translate-x-6.5" />
|
||||
</div>
|
||||
</label>
|
||||
<div
|
||||
className={cn('flex items-center justify-between gap-4 py-2', className)}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className={cn(
|
||||
'text-sm font-medium text-foreground cursor-pointer select-none',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
labelClassName
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Switch
|
||||
id={fieldId}
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
{...(rest as React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export { Switch };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './tooltip';
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-fit rounded-md bg-neutral-900 px-3 py-1.5 text-xs text-white text-balance shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-neutral-900 fill-neutral-900 z-50 size-2 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -1,5 +1,6 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||
|
||||
@theme {
|
||||
@@ -96,6 +97,45 @@
|
||||
/* ~10px */
|
||||
--text-label3: 0.677rem;
|
||||
/* ~8px */
|
||||
|
||||
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #3d3d3d;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #3d3d3d;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #3d3d3d;
|
||||
--color-primary: #23a1eb;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #e7e7e7;
|
||||
--color-secondary-foreground: #3d3d3d;
|
||||
--color-muted: #f6f6f6;
|
||||
--color-muted-foreground: #6d6d6d;
|
||||
--color-accent: #e1f0fd;
|
||||
--color-accent-foreground: #085f9c;
|
||||
--color-destructive: #ff5242;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-border: #d1d1d1;
|
||||
--color-input: #d1d1d1;
|
||||
--color-ring: #3eb0f2;
|
||||
|
||||
--color-sidebar: #ffffff;
|
||||
--color-sidebar-foreground: #4f4f4f;
|
||||
--color-sidebar-primary: #23a1eb;
|
||||
--color-sidebar-primary-foreground: #ffffff;
|
||||
--color-sidebar-accent: #f0f8ff;
|
||||
--color-sidebar-accent-foreground: #085f9c;
|
||||
--color-sidebar-border: #e7e7e7;
|
||||
--color-sidebar-ring: #3eb0f2;
|
||||
|
||||
--color-chart-1: #23a1eb;
|
||||
--color-chart-2: #35ba43;
|
||||
--color-chart-3: #04acf3;
|
||||
--color-chart-4: #ffed27;
|
||||
--color-chart-5: #ff5242;
|
||||
|
||||
--radius: 8px;
|
||||
--font-sans: 'Bai Jamjuree', sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -1,95 +1,77 @@
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
InputHTMLAttributes,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import { Input } from '../../atoms';
|
||||
import * as React from 'react';
|
||||
import { Input } from '../../atoms/input';
|
||||
import { Label } from '../../atoms/label';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export type TInputType = 'text' | 'email' | 'number' | 'password' | 'file';
|
||||
export type TInputType =
|
||||
| 'text'
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'file'
|
||||
| 'date'
|
||||
| 'time';
|
||||
export type TInputSize = 'sm' | 'md' | 'lg';
|
||||
export type TInputFieldProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'size' | 'type'
|
||||
> & {
|
||||
label: string;
|
||||
type?: TInputType;
|
||||
size?: TInputSize;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
htmlFor?: string;
|
||||
isRequired?: boolean;
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TInputSize, { label: string; helperText: string }> = {
|
||||
lg: {
|
||||
label: 'text-label1 font-medium',
|
||||
helperText: 'text-label3 font-normal',
|
||||
},
|
||||
md: {
|
||||
label: 'text-label2 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
sm: {
|
||||
label: 'text-label3 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
};
|
||||
|
||||
export const InputField: FC<TInputFieldProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
size = 'md',
|
||||
error,
|
||||
helperText,
|
||||
htmlFor,
|
||||
className,
|
||||
disabled,
|
||||
isRequired = false,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="flex gap-[8px] flex-col">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'items-start justify-item-start text-start text-neutral-800!',
|
||||
sizeClasses[size].label
|
||||
)}
|
||||
>
|
||||
{label} {isRequired ? <span className="text-red-500">*</span> : null}
|
||||
</label>
|
||||
<Input
|
||||
{...(htmlFor && { id: htmlFor })}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
error &&
|
||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500',
|
||||
className,
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-danger-500 text-label2 text-left">{error}</p>
|
||||
) : (
|
||||
helperText && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-label2 text-left',
|
||||
sizeClasses[size].helperText
|
||||
)}
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const InputField = React.forwardRef<HTMLInputElement, TInputFieldProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
size = 'md',
|
||||
error,
|
||||
helperText,
|
||||
htmlFor,
|
||||
className,
|
||||
disabled,
|
||||
isRequired = false,
|
||||
id,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const autoId = React.useId();
|
||||
const fieldId = htmlFor ?? id ?? autoId;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={fieldId} className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
{isRequired && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
className={cn(
|
||||
error && 'border-destructive focus-visible:ring-destructive/20',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
) : helperText ? (
|
||||
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
InputField.displayName = 'InputField';
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import React, { useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../atoms/dialog';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -16,170 +24,83 @@ interface ModalProps {
|
||||
'aria-describedby'?: string;
|
||||
}
|
||||
|
||||
const Modal = ({
|
||||
/**
|
||||
* Modal — backward-compat wrapper around the shadcn Dialog primitives.
|
||||
* Existing callers use `<Modal isOpen onClose>` + `Modal.Header/Content/Footer/Title/Description`.
|
||||
*/
|
||||
function ModalRoot({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
closeButtonClassName,
|
||||
disableEscapeKeyDown = false,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
}: ModalProps) => {
|
||||
const handleEscapeKey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !disableEscapeKeyDown) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isOpen, onClose, disableEscapeKeyDown]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleEscapeKey);
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, [isOpen, handleEscapeKey]);
|
||||
|
||||
const modalNode = useMemo(() => document.createElement('div'), []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.appendChild(modalNode);
|
||||
return () => {
|
||||
document.body.removeChild(modalNode);
|
||||
};
|
||||
}, [modalNode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-gray-900/80 transition-opacity duration-200',
|
||||
isOpen ? 'opacity-100' : 'opacity-0',
|
||||
overlayClassName
|
||||
)}
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-[#F0F8FF] rounded-lg p-6 shadow-xl transition-all duration-200',
|
||||
'sm:rounded-lg sm:max-w-md',
|
||||
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
||||
className
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn('sm:max-w-md', className)}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (disableEscapeKeyDown) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-sm p-1 text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2',
|
||||
closeButtonClassName
|
||||
)}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<CloseOutlined className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
modalNode
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalHeader = ({ className, children }: ModalHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-4 flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
type ModalHeaderProps = React.ComponentProps<'div'>;
|
||||
const ModalHeader = ({ className, ...props }: ModalHeaderProps) => (
|
||||
<DialogHeader className={className} {...props} />
|
||||
);
|
||||
|
||||
interface ModalContentProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalContent = ({ className, children }: ModalContentProps) => (
|
||||
<div className={cn('mb-4', className)}>{children}</div>
|
||||
type ModalContentProps = React.ComponentProps<'div'>;
|
||||
const ModalContent = ({ className, ...props }: ModalContentProps) => (
|
||||
<div className={cn('py-2', className)} {...props} />
|
||||
);
|
||||
|
||||
interface ModalFooterProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalFooter = ({ className, children }: ModalFooterProps) => (
|
||||
<div className={cn('flex gap-2 sm:flex-row sm:justify-end', className)}>
|
||||
{children}
|
||||
</div>
|
||||
type ModalFooterProps = React.ComponentProps<'div'>;
|
||||
const ModalFooter = ({ className, ...props }: ModalFooterProps) => (
|
||||
<DialogFooter className={className} {...props} />
|
||||
);
|
||||
|
||||
interface ModalTitleProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
type ModalTitleProps = React.ComponentProps<'h2'>;
|
||||
const ModalTitle = ({ className, children, id }: ModalTitleProps) => (
|
||||
<h2
|
||||
id={id}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<DialogTitle id={id} className={className}>
|
||||
{children}
|
||||
</h2>
|
||||
</DialogTitle>
|
||||
);
|
||||
|
||||
interface ModalDescriptionProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
type ModalDescriptionProps = React.ComponentProps<'p'>;
|
||||
const ModalDescription = ({
|
||||
className,
|
||||
children,
|
||||
id,
|
||||
}: ModalDescriptionProps) => (
|
||||
<p id={id} className={cn('text-sm text-gray-500', className)}>
|
||||
<DialogDescription id={id} className={className}>
|
||||
{children}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
);
|
||||
|
||||
Modal.Header = ModalHeader;
|
||||
Modal.Content = ModalContent;
|
||||
Modal.Footer = ModalFooter;
|
||||
Modal.Title = ModalTitle;
|
||||
Modal.Description = ModalDescription;
|
||||
export const Modal = Object.assign(ModalRoot, {
|
||||
Header: ModalHeader,
|
||||
Content: ModalContent,
|
||||
Footer: ModalFooter,
|
||||
Title: ModalTitle,
|
||||
Description: ModalDescription,
|
||||
});
|
||||
|
||||
export { Modal };
|
||||
export type {
|
||||
ModalProps,
|
||||
ModalHeaderProps,
|
||||
|
||||
@@ -1,93 +1,109 @@
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '../../atoms/button';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface PaginationProps<T> {
|
||||
table: Table<T>;
|
||||
}
|
||||
|
||||
export const Pagination = <T,>({ table }: PaginationProps<T>) => {
|
||||
function PageButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-[40px]">
|
||||
<button
|
||||
className="disabled:opacity-50 cursor-pointer"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ArrowLeftOutlined className="text-[16px] text-neutral-800" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
active
|
||||
? 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
: 'bg-transparent text-foreground hover:bg-primary-50 hover:text-primary-600'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex gap-4 items-baseline">
|
||||
{table.getPageCount() <= 8 ? (
|
||||
Array.from({ length: table.getPageCount() }, (_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex === index
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
onClick={() => table.setPageIndex(index)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex === 0
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
{table.getState().pagination.pageIndex > 3 && <span>...</span>}
|
||||
{Array.from(
|
||||
{ length: 5 },
|
||||
(_, index) => table.getState().pagination.pageIndex - 2 + index
|
||||
)
|
||||
.filter((page) => page > 0 && page < table.getPageCount() - 1)
|
||||
.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => table.setPageIndex(page)}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex === page
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
{page + 1}
|
||||
</button>
|
||||
))}
|
||||
{table.getState().pagination.pageIndex <
|
||||
table.getPageCount() - 4 && <span>...</span>}
|
||||
<button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex ===
|
||||
table.getPageCount() - 1
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
{table.getPageCount()}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
export const Pagination = <T,>({ table }: PaginationProps<T>) => {
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
const pageCount = table.getPageCount();
|
||||
|
||||
if (pageCount <= 1) return null;
|
||||
|
||||
const pages: Array<number | 'ellipsis'> = [];
|
||||
if (pageCount <= 7) {
|
||||
for (let i = 0; i < pageCount; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(0);
|
||||
if (pageIndex > 3) pages.push('ellipsis');
|
||||
const start = Math.max(1, pageIndex - 1);
|
||||
const end = Math.min(pageCount - 2, pageIndex + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (pageIndex < pageCount - 4) pages.push('ellipsis');
|
||||
pages.push(pageCount - 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
className="flex items-center justify-between gap-2 pt-2"
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="disabled:opacity-50 cursor-pointer"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ArrowRightOutlined className="text-[16px] text-neutral-800" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
{pages.map((p, idx) =>
|
||||
p === 'ellipsis' ? (
|
||||
<span
|
||||
key={`e-${idx}`}
|
||||
className="px-1 text-sm text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<PageButton
|
||||
key={p}
|
||||
active={pageIndex === p}
|
||||
onClick={() => table.setPageIndex(p)}
|
||||
>
|
||||
{p + 1}
|
||||
</PageButton>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
SelectHTMLAttributes,
|
||||
DetailedHTMLProps,
|
||||
} from 'react';
|
||||
import { Select } from '../../atoms'; // Custom select atom kamu
|
||||
import * as React from 'react';
|
||||
import { NativeSelect } from '../../atoms/select';
|
||||
import { Label } from '../../atoms/label';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export type TSelectSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export type TSelectFieldProps = Omit<
|
||||
DetailedHTMLProps<SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>,
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||
'size'
|
||||
> & {
|
||||
label: string;
|
||||
@@ -18,76 +14,55 @@ export type TSelectFieldProps = Omit<
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
htmlFor?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TSelectSize, { label: string; helperText: string }> = {
|
||||
lg: {
|
||||
label: 'text-p3 font-medium',
|
||||
helperText: 'text-label3 font-normal',
|
||||
},
|
||||
md: {
|
||||
label: 'text-label1 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
sm: {
|
||||
label: 'text-label2 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectField: FC<TSelectFieldProps> = ({
|
||||
label,
|
||||
size = 'md',
|
||||
error,
|
||||
helperText,
|
||||
htmlFor,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="flex flex-col gap-[8px]">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'items-start justify-item-start text-start !text-neutral-800',
|
||||
sizeClasses[size].label
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
|
||||
<Select
|
||||
{...(htmlFor && { id: htmlFor })}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
error &&
|
||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500',
|
||||
className,
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Select>
|
||||
|
||||
{error ? (
|
||||
<p className="text-danger-500 text-label1 text-left">{error}</p>
|
||||
) : (
|
||||
helperText && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-label2 text-left',
|
||||
sizeClasses[size].helperText
|
||||
)}
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const SelectField = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
TSelectFieldProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
size = 'md',
|
||||
error,
|
||||
helperText,
|
||||
htmlFor,
|
||||
className,
|
||||
disabled,
|
||||
id,
|
||||
children,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const autoId = React.useId();
|
||||
const fieldId = htmlFor ?? id ?? autoId;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={fieldId} className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
className={cn(
|
||||
error && 'border-destructive focus-visible:ring-destructive/20',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NativeSelect>
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
) : helperText ? (
|
||||
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SelectField.displayName = 'SelectField';
|
||||
|
||||
@@ -1,67 +1,140 @@
|
||||
import * as React from 'react';
|
||||
import { LogOut, User, Settings } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAuthStore, useSession } from '@imphnen-frontend-service/service';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { FC, ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '../../atoms';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '../../atoms/avatar';
|
||||
import { Button } from '../../atoms/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../atoms/dropdown-menu';
|
||||
import { Separator } from '../../atoms/separator';
|
||||
import { SidebarTrigger } from '../../atoms/sidebar';
|
||||
|
||||
export type TBackofficeWrapperProps = {
|
||||
children: ReactNode;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
classHeader?: string;
|
||||
classTitle?: string;
|
||||
};
|
||||
|
||||
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
||||
export const BackofficeWrapper: React.FC<TBackofficeWrapperProps> = ({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
classHeader,
|
||||
classTitle,
|
||||
}): ReactElement => {
|
||||
}) => {
|
||||
const { session } = useAuthStore();
|
||||
const { signOut } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const user = session?.user;
|
||||
const initials = (user?.fullname ?? 'U')
|
||||
.split(' ')
|
||||
.map((s) => s[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'w-full px-[48px] py-[40px] flex flex-col gap-8',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<header
|
||||
className={cn(
|
||||
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
|
||||
'sticky top-0 z-20 flex h-14 shrink-0 items-center gap-3 border-b border-neutral-200 bg-background/95 px-6 backdrop-blur supports-[backdrop-filter]:bg-background/75',
|
||||
classHeader
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className={cn(
|
||||
'text-[19px] text-primary-500 font-semibold',
|
||||
classTitle
|
||||
<SidebarTrigger className="-ml-1 md:hidden" />
|
||||
<Separator orientation="vertical" className="h-5 md:hidden" />
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
{title && (
|
||||
<div className="flex flex-col">
|
||||
<h1
|
||||
className={cn(
|
||||
'text-base font-semibold leading-tight text-foreground',
|
||||
classTitle
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="text-neutral-600 font-medium">
|
||||
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
|
||||
<p className="text-label1">Admin</p>
|
||||
</div>
|
||||
<div className="size-12 rounded-full overflow-hidden">
|
||||
<img
|
||||
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
|
||||
alt="Profile"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{actions}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full p-1 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="User menu"
|
||||
>
|
||||
<div className="hidden text-right md:flex md:flex-col md:leading-tight">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{user?.fullname ?? 'Admin'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Admin</span>
|
||||
</div>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
|
||||
alt={user?.fullname ?? 'User avatar'}
|
||||
/>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{user?.fullname ?? 'Admin'}
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{user?.email ?? 'admin@imphnen.dev'}
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<User />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
signOut();
|
||||
navigate({ to: '/auth/login' });
|
||||
}}
|
||||
>
|
||||
<LogOut />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>{children}</section>
|
||||
</main>
|
||||
<main className="flex-1 space-y-6 px-6 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
PaginationState,
|
||||
SortingState,
|
||||
@@ -8,17 +9,25 @@ import {
|
||||
getFilteredRowModel,
|
||||
flexRender,
|
||||
ColumnDef,
|
||||
Table,
|
||||
Table as TanstackTable,
|
||||
RowData,
|
||||
TableOptions,
|
||||
} from '@tanstack/react-table';
|
||||
import { Pagination } from '../../molecules';
|
||||
|
||||
import React from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../../atoms/table';
|
||||
import { Button } from '../../atoms/button';
|
||||
import { Pagination } from '../../molecules/pagination';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface DataTableProps<T extends RowData> {
|
||||
table?: Table<T>;
|
||||
table?: TanstackTable<T>;
|
||||
data?: T[];
|
||||
columns?: ColumnDef<T, unknown>[];
|
||||
pageSize?: number;
|
||||
@@ -27,18 +36,103 @@ interface DataTableProps<T extends RowData> {
|
||||
pageCount?: number;
|
||||
currentPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function ManualPagination({
|
||||
currentPage,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
pageCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
if (pageCount <= 1) return null;
|
||||
|
||||
const pages: Array<number | 'ellipsis'> = [];
|
||||
if (pageCount <= 7) {
|
||||
for (let i = 1; i <= pageCount; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push('ellipsis');
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(pageCount - 1, currentPage + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (currentPage < pageCount - 2) pages.push('ellipsis');
|
||||
pages.push(pageCount);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
className="flex items-center justify-between gap-2 pt-2"
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Page {currentPage} of {pageCount}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
{pages.map((p, idx) =>
|
||||
p === 'ellipsis' ? (
|
||||
<span
|
||||
key={`e-${idx}`}
|
||||
className="px-1 text-sm text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => onPageChange(p)}
|
||||
className={cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
currentPage === p
|
||||
? 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
: 'bg-transparent text-foreground hover:bg-primary-50 hover:text-primary-600'
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export const DataTable = <T extends RowData>({
|
||||
table,
|
||||
data = [],
|
||||
columns = [],
|
||||
pageSize = 9,
|
||||
pageSize = 10,
|
||||
className,
|
||||
manualPagination = false,
|
||||
pageCount,
|
||||
currentPage = 1,
|
||||
onPageChange,
|
||||
emptyMessage = 'Tidak ada data',
|
||||
}: DataTableProps<T>) => {
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
@@ -47,18 +141,12 @@ export const DataTable = <T extends RowData>({
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageSize,
|
||||
}));
|
||||
setPagination((prev) => ({ ...prev, pageSize }));
|
||||
}, [pageSize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageIndex: 0, // Reset to first page when data changes
|
||||
}));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}
|
||||
}, [data.length]);
|
||||
|
||||
@@ -69,10 +157,7 @@ export const DataTable = <T extends RowData>({
|
||||
const config: TableOptions<T> = {
|
||||
data: memoizedData,
|
||||
columns: memoizedColumns,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
state: { pagination, sorting },
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -82,16 +167,8 @@ export const DataTable = <T extends RowData>({
|
||||
manualPagination,
|
||||
pageCount: manualPagination ? pageCount : undefined,
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [
|
||||
memoizedData,
|
||||
memoizedColumns,
|
||||
pagination,
|
||||
sorting,
|
||||
manualPagination,
|
||||
pageCount,
|
||||
]);
|
||||
}, [memoizedData, memoizedColumns, pagination, sorting, manualPagination, pageCount]);
|
||||
|
||||
const internalTable = useReactTable(tableConfig);
|
||||
const t = table ?? internalTable;
|
||||
@@ -99,189 +176,89 @@ export const DataTable = <T extends RowData>({
|
||||
const isEmpty = t.getRowModel().rows.length === 0;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-8', className)}>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-full text-base">
|
||||
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
|
||||
<div className={cn('flex flex-col gap-4', className)}>
|
||||
<div className="rounded-md border border-neutral-200 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-neutral-50">
|
||||
{t.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
|
||||
header.column.getCanSort() &&
|
||||
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
|
||||
header?.column?.columnDef?.meta?.headerClassName
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getCanSort() && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{header.column.getIsSorted() === 'asc' && '▲'}
|
||||
{header.column.getIsSorted() === 'desc' && '▼'}
|
||||
{!header.column.getIsSorted() && <span>⇅</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{isEmpty ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={t.getAllColumns().length}
|
||||
className="py-8 px-5 text-center text-neutral-500"
|
||||
>
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
t.getRowModel().rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'hover:bg-primary-50 transition-colors',
|
||||
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
onClick={
|
||||
canSort
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
|
||||
cell?.column?.columnDef?.meta?.cellClassName
|
||||
canSort && 'cursor-pointer select-none hover:bg-neutral-100',
|
||||
header?.column?.columnDef?.meta?.headerClassName
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{canSort && (
|
||||
<span className="text-muted-foreground">
|
||||
{sorted === 'asc' ? (
|
||||
<ArrowUp className="size-3" />
|
||||
) : sorted === 'desc' ? (
|
||||
<ArrowDown className="size-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="size-3 opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isEmpty ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={t.getAllColumns().length}
|
||||
className="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
t.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cell?.column?.columnDef?.meta?.cellClassName}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
</TableCell>
|
||||
))}
|
||||
</tr>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{manualPagination && onPageChange && pageCount ? (
|
||||
<div className="flex items-center justify-center gap-10">
|
||||
<button
|
||||
className="disabled:opacity-50 cursor-pointer"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-neutral-800"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex gap-4 items-baseline">
|
||||
{pageCount <= 8 ? (
|
||||
Array.from({ length: pageCount }, (_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||
currentPage === index + 1
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
onClick={() => onPageChange(index + 1)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||
currentPage === 1
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
{currentPage > 3 && <span>...</span>}
|
||||
{Array.from(
|
||||
{ length: 5 },
|
||||
(_, index) => currentPage - 2 + index
|
||||
)
|
||||
.filter((page) => page > 1 && page < pageCount)
|
||||
.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||
currentPage === page
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
{currentPage < pageCount - 2 && <span>...</span>}
|
||||
<button
|
||||
onClick={() => onPageChange(pageCount)}
|
||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||
currentPage === pageCount
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
{pageCount}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="disabled:opacity-50 cursor-pointer"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-neutral-800"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ManualPagination
|
||||
currentPage={currentPage}
|
||||
pageCount={pageCount}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
) : (
|
||||
<Pagination table={t} />
|
||||
)}
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface RadioProps {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
id: string;
|
||||
label?: string;
|
||||
name: string;
|
||||
value: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const Radio = ({
|
||||
checked,
|
||||
disabled,
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}: RadioProps) => (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative grid place-items-center mt-1">
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className="peer col-start-1 row-start-1 appearance-none shrink-0 size-[10px] bg-primary-100 rounded-full disabled:border-gray-400"
|
||||
/>
|
||||
<div className="pointer-events-none col-start-1 row-start-1 size-[6px] rounded-full peer-checked:bg-primary-500 peer-checked:peer-disabled:bg-gray-400" />
|
||||
</div>
|
||||
<label htmlFor={id} className="text-start text-neutral-400 text-label2">
|
||||
{label || 'This is the radio label'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { RadioGroup, RadioGroupItem } from '../../atoms/radio-group';
|
||||
import { Label } from '../../atoms/label';
|
||||
import { Separator } from '../../atoms/separator';
|
||||
|
||||
interface FilterProps {
|
||||
onClose?: () => void;
|
||||
@@ -59,42 +23,43 @@ export const Filter = ({
|
||||
onFilterChange,
|
||||
title = 'Status',
|
||||
}: FilterProps) => {
|
||||
const [selectedStatus, setSelectedStatus] = useState(
|
||||
selectedValue || options[0]?.value || ''
|
||||
const [selectedStatus, setSelectedStatus] = React.useState(
|
||||
selectedValue ?? options[0]?.value ?? ''
|
||||
);
|
||||
|
||||
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const handleStatusChange = (newValue: string) => {
|
||||
setSelectedStatus(newValue);
|
||||
onFilterChange?.(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col p-[20px] bg-white rounded-lg gap-4 w-[122px] shadow">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-semibold text-p3 text-primary-500">Filters</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="cursor-pointer text-neutral-400 hover:text-neutral-600"
|
||||
>
|
||||
<CloseOutlined className="text-[12px]" />
|
||||
</button>
|
||||
<div className="inline-flex min-w-40 flex-col gap-3 rounded-md border border-neutral-200 bg-popover p-4 text-popover-foreground shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-primary-600">Filters</span>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-neutral-100 hover:text-foreground"
|
||||
aria-label="Close filter"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<hr className="border-primary-200" />
|
||||
<span className="font-semibold text-primary-500">{title}</span>
|
||||
<div className="flex flex-col gap-[10px]">
|
||||
<Separator />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<RadioGroup value={selectedStatus} onValueChange={handleStatusChange}>
|
||||
{options.map((option) => (
|
||||
<Radio
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
name="status"
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
checked={selectedStatus === option.value}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
<div key={option.id} className="flex items-center gap-2">
|
||||
<RadioGroupItem id={option.id} value={option.value} />
|
||||
<Label htmlFor={option.id} className="text-sm font-normal">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user