frontend fully integreated with the backend
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ChevronRightIcon, UserIcon } from '@hugeicons/core-free-icons'
|
||||
@@ -31,6 +32,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useAuth, type Role } from '@/contexts/auth-context'
|
||||
import { DASHBOARD_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav'
|
||||
|
||||
@@ -48,6 +54,32 @@ export function AppSidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const dashboardActive = pathname === DASHBOARD_ITEM.href
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = { ...prev }
|
||||
|
||||
for (const group of NAV_GROUPS) {
|
||||
if (!isGroupVisible(group, permissions)) continue
|
||||
|
||||
const visibleItems = group.items.filter((item) => canSee(item, permissions))
|
||||
const groupActive = visibleItems.some(
|
||||
(item) =>
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href)),
|
||||
)
|
||||
|
||||
if (groupActive) {
|
||||
next[group.title] = true
|
||||
} else if (!(group.title in next)) {
|
||||
next[group.title] = false
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [pathname, permissions])
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -95,7 +127,10 @@ export function AppSidebar() {
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.title}
|
||||
defaultOpen={false}
|
||||
open={openGroups[group.title] ?? groupActive}
|
||||
onOpenChange={(open) =>
|
||||
setOpenGroups((prev) => ({ ...prev, [group.title]: open }))
|
||||
}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarGroup className="py-0">
|
||||
@@ -126,15 +161,18 @@ export function AppSidebar() {
|
||||
(item.href !== '/' &&
|
||||
pathname.startsWith(item.href))
|
||||
return (
|
||||
<SidebarMenuSubItem key={item.href}>
|
||||
<SidebarMenuSubButton
|
||||
render={<Link href={item.href} />}
|
||||
isActive={isActive}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} size={15} />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<Tooltip key={item.href}>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
render={<TooltipTrigger render={<Link href={item.href} />} />}
|
||||
isActive={isActive}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} size={15} />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<TooltipContent side="right">{item.description ?? item.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RowActionButton, RowActions } from '@/components/shared/row-actions';
|
||||
|
||||
import { TableToolbar } from './table-toolbar';
|
||||
import { TablePagination } from './table-pagination';
|
||||
@@ -27,7 +28,7 @@ import { exportTable } from './hooks/use-table-export';
|
||||
import { getNestedValue, resolveUrlTemplate } from './utils/nested-value';
|
||||
import type {
|
||||
TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState,
|
||||
ServerSideParams, ExportFormat,
|
||||
ServerSideParams, ExportFormat, RowActionItem,
|
||||
} from './types';
|
||||
|
||||
interface DataTableProps<T extends object> {
|
||||
@@ -48,6 +49,29 @@ function normalizeField(f: FieldConfig | string): FieldConfig {
|
||||
return typeof f === 'string' ? { key: f } : f;
|
||||
}
|
||||
|
||||
function formatTextValue(raw: unknown): string {
|
||||
if (raw == null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'bigint') return String(raw);
|
||||
if (typeof raw === 'boolean') return raw ? 'Yes' : 'No';
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
if (raw.length === 0) return '';
|
||||
if (raw.every(item => item == null || ['string', 'number', 'bigint', 'boolean'].includes(typeof item))) {
|
||||
return raw.map(item => formatTextValue(item)).filter(Boolean).join(', ');
|
||||
}
|
||||
return `${raw.length} item${raw.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const namedValue = record.name ?? record.title ?? record.label ?? record.id;
|
||||
return namedValue == null ? '' : formatTextValue(namedValue);
|
||||
}
|
||||
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) {
|
||||
if (sortState.column !== colKey) {
|
||||
return (
|
||||
@@ -62,6 +86,36 @@ function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState
|
||||
: <HugeiconsIcon icon={ArrowDown01Icon} className="size-3" />;
|
||||
}
|
||||
|
||||
function DataTableRowActions<T extends object>({
|
||||
row,
|
||||
actions,
|
||||
}: {
|
||||
row: T;
|
||||
actions: RowActionItem<T>[];
|
||||
}) {
|
||||
const visibleActions = actions.filter(action => action.visible ? action.visible(row) : true);
|
||||
|
||||
if (visibleActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<RowActions>
|
||||
{visibleActions.map((action) => (
|
||||
<RowActionButton
|
||||
key={`${action.action}-${action.label}`}
|
||||
action={action.action}
|
||||
label={action.label}
|
||||
tone={action.tone}
|
||||
disabled={action.disabled?.(row)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCard<T extends object>({
|
||||
row,
|
||||
columns,
|
||||
@@ -73,7 +127,7 @@ function MobileCard<T extends object>({
|
||||
mobileCardCfg?: TableConfig<T>['mobileCard'];
|
||||
onClick?: (row: T) => void;
|
||||
}) {
|
||||
const actionCol = columns.find(c => !c.title && c.cellRender);
|
||||
const actionCol = columns.find(c => c.key === '__row_actions' || (!c.title && c.cellRender));
|
||||
const dataColumns = columns.filter(c => !!c.title);
|
||||
|
||||
const primaryCol = dataColumns[0];
|
||||
@@ -153,11 +207,34 @@ function CellContent<T extends object>({
|
||||
const fields = col.fields.map(normalizeField);
|
||||
const layout = col.layout ?? 'vertical';
|
||||
|
||||
if (fields.length === 1 && (fields[0].renderer ?? 'text') === 'text') {
|
||||
const field = fields[0];
|
||||
const raw = getNestedValue(row, field.key);
|
||||
const text = field.transform
|
||||
? field.transform(raw, row)
|
||||
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-full',
|
||||
field.truncate ? 'truncate' : 'whitespace-normal break-words',
|
||||
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
|
||||
'text-sm',
|
||||
field.class,
|
||||
)}
|
||||
style={{ color: field.primary ? 'var(--foreground)' : 'var(--muted-foreground)' }}
|
||||
>
|
||||
{text || '—'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rendered = fields.map((field, i) => {
|
||||
const raw = getNestedValue(row, field.key);
|
||||
const text = field.transform
|
||||
? field.transform(raw, row)
|
||||
: `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`;
|
||||
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
|
||||
|
||||
const renderer = field.renderer ?? 'text';
|
||||
|
||||
@@ -190,17 +267,19 @@ function CellContent<T extends object>({
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn(
|
||||
'truncate block',
|
||||
field.primary ? 'font-medium' : 'text-muted-foreground text-xs',
|
||||
'max-w-full',
|
||||
field.truncate ? 'truncate' : 'whitespace-normal break-words',
|
||||
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
|
||||
field.class,
|
||||
i === 0 && 'text-sm',
|
||||
)}
|
||||
style={{ color: field.primary || i === 0 ? 'var(--foreground)' : 'var(--muted-foreground)' }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -232,6 +311,7 @@ export function DataTable<T extends object>({
|
||||
onRowClick,
|
||||
onServerSideChange,
|
||||
}: DataTableProps<T>) {
|
||||
const safeData = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
||||
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||
const [filterState, setFilterState] = useState<FilterState>({ global: '', columns: {} });
|
||||
const [pageState, setPageState] = useState<PageState>({
|
||||
@@ -257,11 +337,51 @@ export function DataTable<T extends object>({
|
||||
return String(row[config.rowKey as keyof T]);
|
||||
}
|
||||
|
||||
const normalizedColumns = useMemo(() =>
|
||||
config.columns.map(col => ({
|
||||
const normalizedColumns = useMemo(() => {
|
||||
const baseColumns = config.columns.map(col => ({
|
||||
...col,
|
||||
fields: col.fields.map(normalizeField) as FieldConfig[],
|
||||
})), [config.columns]);
|
||||
}));
|
||||
|
||||
if (!config.rowActions) {
|
||||
return baseColumns;
|
||||
}
|
||||
|
||||
const actionsColumn: ColumnDef<T> = {
|
||||
key: '__row_actions',
|
||||
title: config.rowActions.title ?? 'Action',
|
||||
fields: [],
|
||||
width: config.rowActions.width ?? 96,
|
||||
minWidth: config.rowActions.minWidth ?? 96,
|
||||
resizable: false,
|
||||
hideable: false,
|
||||
align: config.rowActions.align ?? 'right',
|
||||
exportable: false,
|
||||
cellRender: (row: T) => (
|
||||
<DataTableRowActions row={row} actions={config.rowActions!.actions(row)} />
|
||||
),
|
||||
};
|
||||
|
||||
return [
|
||||
...baseColumns,
|
||||
actionsColumn,
|
||||
];
|
||||
}, [config.columns, config.rowActions]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder((prev) => {
|
||||
const nextKeys = normalizedColumns.map((col) => col.key);
|
||||
const kept = prev.filter((key) => nextKeys.includes(key));
|
||||
const missing = nextKeys.filter((key) => !kept.includes(key));
|
||||
const next = [...kept, ...missing];
|
||||
|
||||
if (next.length === prev.length && next.every((key, index) => key === prev[index])) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [normalizedColumns]);
|
||||
|
||||
const orderedColumns = useMemo(() =>
|
||||
columnOrder
|
||||
@@ -271,23 +391,23 @@ export function DataTable<T extends object>({
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(() =>
|
||||
orderedColumns.filter(c => !hiddenColumns.has(c.key)),
|
||||
orderedColumns.filter(c => c.hideable === false || !hiddenColumns.has(c.key)),
|
||||
[orderedColumns, hiddenColumns]
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() =>
|
||||
config.serverSide ? data : filterData(data, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[data, filterState, normalizedColumns, config.serverSide]
|
||||
config.serverSide ? safeData : filterData(safeData, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[safeData, filterState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const sortedData = useMemo(() =>
|
||||
config.serverSide ? data : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
|
||||
config.serverSide ? safeData : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
|
||||
[filteredData, sortState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const totalRecords = useMemo(() =>
|
||||
config.serverSide ? (totalCount ?? data.length) : filteredData.length,
|
||||
[config.serverSide, totalCount, data.length, filteredData.length]
|
||||
config.serverSide ? (totalCount ?? safeData.length) : filteredData.length,
|
||||
[config.serverSide, totalCount, safeData.length, filteredData.length]
|
||||
);
|
||||
|
||||
const rowHeightPx = useMemo(() => {
|
||||
@@ -353,7 +473,7 @@ export function DataTable<T extends object>({
|
||||
const next = new Set(selectedKeys);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
@@ -364,7 +484,7 @@ export function DataTable<T extends object>({
|
||||
selectablePageData.forEach(r => next.add(rowKey(r)));
|
||||
}
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
@@ -382,6 +502,9 @@ export function DataTable<T extends object>({
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(key: string) {
|
||||
const column = orderedColumns.find(col => col.key === key);
|
||||
if (!column || column.hideable === false) return;
|
||||
|
||||
setHiddenColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TableToolbar<T>({
|
||||
onClearSelection,
|
||||
}: TableToolbarProps<T>) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const toggleableColumns = columns.filter(col => col.hideable !== false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
@@ -91,7 +92,7 @@ export function TableToolbar<T>({
|
||||
Columns
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{columns.map(col => (
|
||||
{toggleableColumns.map(col => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.key}
|
||||
checked={!hiddenColumns.has(col.key)}
|
||||
|
||||
@@ -3,6 +3,13 @@ export type ColumnLayout = 'vertical' | 'horizontal';
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
export type ExportFormat = 'csv' | 'excel' | 'pdf';
|
||||
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||
export type RowActionKind =
|
||||
| 'edit'
|
||||
| 'activate'
|
||||
| 'deactivate'
|
||||
| 'confirm'
|
||||
| 'close'
|
||||
| 'cancel';
|
||||
|
||||
export interface BadgeConfig {
|
||||
colorMap?: Record<string, string>;
|
||||
@@ -49,6 +56,7 @@ export interface ColumnDef<T = object> {
|
||||
minWidth?: number;
|
||||
resizable?: boolean;
|
||||
visible?: boolean;
|
||||
hideable?: boolean;
|
||||
align?: ColumnAlign;
|
||||
group?: string;
|
||||
exportable?: boolean;
|
||||
@@ -75,6 +83,23 @@ export interface MobileCardConfig<T = object> {
|
||||
detailHref?: (row: T) => string;
|
||||
}
|
||||
|
||||
export interface RowActionItem<T = object> {
|
||||
action: RowActionKind;
|
||||
label: string;
|
||||
onClick: (row: T) => void;
|
||||
visible?: (row: T) => boolean;
|
||||
disabled?: (row: T) => boolean;
|
||||
tone?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export interface RowActionsConfig<T = object> {
|
||||
title?: string;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
align?: ColumnAlign;
|
||||
actions: (row: T) => RowActionItem<T>[];
|
||||
}
|
||||
|
||||
export interface TableConfig<T = object> {
|
||||
columns: ColumnDef<T>[];
|
||||
rowKey?: keyof T | ((row: T) => string);
|
||||
@@ -93,6 +118,7 @@ export interface TableConfig<T = object> {
|
||||
isRowSelectable?: (row: T) => boolean;
|
||||
expandRender?: (row: T) => React.ReactNode;
|
||||
mobileCard?: MobileCardConfig<T>;
|
||||
rowActions?: RowActionsConfig<T>;
|
||||
}
|
||||
|
||||
export interface SortState {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type FieldPath,
|
||||
type Control,
|
||||
type ControllerRenderProps,
|
||||
type FieldError,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError as FieldErrorDisplay,
|
||||
} from '@/components/ui/field'
|
||||
|
||||
interface FormFieldProps<T extends FieldValues, TName extends FieldPath<T> = FieldPath<T>> {
|
||||
control: Control<T>
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
render: (props: {
|
||||
field: ControllerRenderProps<T, TName>
|
||||
error?: FieldError
|
||||
invalid: boolean
|
||||
}) => React.ReactNode
|
||||
}
|
||||
|
||||
export function FormField<T extends FieldValues, TName extends FieldPath<T> = FieldPath<T>>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
render,
|
||||
}: FormFieldProps<T>) {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid || undefined}>
|
||||
{label && (
|
||||
<FieldLabel htmlFor={String(name)}>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</FieldLabel>
|
||||
)}
|
||||
{render({ field, error: fieldState.error, invalid: fieldState.invalid })}
|
||||
{description && !fieldState.error && (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
)}
|
||||
{fieldState.error && (
|
||||
<FieldErrorDisplay errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Cancel01Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Delete02Icon,
|
||||
PencilEdit01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ACTION_ICONS = {
|
||||
activate: CheckmarkCircle02Icon,
|
||||
cancel: Delete02Icon,
|
||||
close: Cancel01Icon,
|
||||
confirm: CheckmarkCircle02Icon,
|
||||
deactivate: Cancel01Icon,
|
||||
edit: PencilEdit01Icon,
|
||||
} as const
|
||||
|
||||
type RowActionKind = keyof typeof ACTION_ICONS
|
||||
|
||||
function RowActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActionButton({
|
||||
action,
|
||||
label,
|
||||
tone = "default",
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "children"> & {
|
||||
action: RowActionKind
|
||||
label: string
|
||||
tone?: "default" | "destructive"
|
||||
}) {
|
||||
const icon = ACTION_ICONS[action]
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
tone === "destructive"
|
||||
? "text-destructive hover:text-destructive"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} size={16} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { RowActions, RowActionButton }
|
||||
@@ -47,7 +47,7 @@ function ThemeHotkey() {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "d") {
|
||||
if (typeof event.key !== "string" || event.key.toLowerCase() !== "d") {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-[min(var(--radius-4xl),24px)] bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-6 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:bg-input/30 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-input/40 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-4",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { Toaster } from "@/components/ui/toast"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
function UiProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { UiProvider }
|
||||
@@ -7,7 +7,64 @@ import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { UnfoldMoreIcon, Tick02Icon, ArrowUp01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
function collectSelectItems(children: React.ReactNode): Array<{ value: unknown; label: string }> {
|
||||
const items: Array<{ value: unknown; label: string }> = []
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement<{ value?: unknown; label?: string; children?: React.ReactNode }>(child)) {
|
||||
return
|
||||
}
|
||||
|
||||
if ("value" in child.props) {
|
||||
items.push({
|
||||
value: child.props.value ?? null,
|
||||
label: child.props.label ?? getItemLabel(child.props.children) ?? String(child.props.value ?? ""),
|
||||
})
|
||||
}
|
||||
|
||||
if (child.props.children) {
|
||||
items.push(...collectSelectItems(child.props.children))
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function Select<Value, Multiple extends boolean | undefined = false>({
|
||||
children,
|
||||
items,
|
||||
...props
|
||||
}: SelectPrimitive.Root.Props<Value, Multiple>) {
|
||||
const derivedItems = React.useMemo(
|
||||
() => (items == null ? collectSelectItems(children) : items),
|
||||
[children, items]
|
||||
)
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root {...props} items={derivedItems}>
|
||||
{children}
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function getItemLabel(children: React.ReactNode): string | undefined {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children)
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
const parts = children
|
||||
.map(getItemLabel)
|
||||
.filter((part): part is string => Boolean(part))
|
||||
return parts.length > 0 ? parts.join(" ") : undefined
|
||||
}
|
||||
|
||||
if (React.isValidElement<{ children?: React.ReactNode }>(children)) {
|
||||
return getItemLabel(children.props.children)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
@@ -64,12 +121,13 @@ function SelectContent({
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
alignItemWithTrigger = false,
|
||||
collisionAvoidance = { side: "shift", align: "shift", fallbackAxisSide: "none" },
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" | "collisionAvoidance"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
@@ -79,6 +137,7 @@ function SelectContent({
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
collisionAvoidance={collisionAvoidance}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
@@ -114,25 +173,28 @@ function SelectItem({
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
const label = props.label ?? getItemLabel(children)
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
{...props}
|
||||
data-slot="select-item"
|
||||
label={label}
|
||||
className={cn(
|
||||
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-xl py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]: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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"relative flex min-h-7 w-full items-center gap-2 rounded-xl py-1.5 pr-8 pl-2 text-sm text-popover-foreground outline-hidden 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}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
<span className="pointer-events-none flex size-4 shrink-0 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
<SelectPrimitive.ItemText className="block flex-1 whitespace-nowrap text-popover-foreground">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Cancel01Icon, CheckmarkCircle02Icon, InformationCircleIcon, Alert02Icon, MultiplicationSignCircleIcon, Loading03Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const toast = ToastPrimitive.createToastManager()
|
||||
|
||||
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
|
||||
return <ToastPrimitive.Provider {...props} />
|
||||
}
|
||||
|
||||
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
|
||||
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
|
||||
}
|
||||
|
||||
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Viewport
|
||||
data-slot="toast-viewport"
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-4 bottom-4 z-[100] mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
data-slot="toast"
|
||||
className={cn(
|
||||
"group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl! border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
|
||||
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
|
||||
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
||||
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
|
||||
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
|
||||
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
|
||||
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
||||
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
||||
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
||||
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Content
|
||||
data-slot="toast-content"
|
||||
className={cn(
|
||||
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Title
|
||||
data-slot="toast-title"
|
||||
className={cn("text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastDescription({
|
||||
className,
|
||||
...props
|
||||
}: ToastPrimitive.Description.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Description
|
||||
data-slot="toast-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastAction({
|
||||
className,
|
||||
render = <Button variant="outline" size="sm" />,
|
||||
...props
|
||||
}: ToastPrimitive.Action.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Action
|
||||
data-slot="toast-action"
|
||||
render={render}
|
||||
className={cn("shrink-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastClose({
|
||||
className,
|
||||
children,
|
||||
render = <Button variant="ghost" size="icon-sm" />,
|
||||
...props
|
||||
}: ToastPrimitive.Close.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Close
|
||||
data-slot="toast-close"
|
||||
aria-label="Close toast"
|
||||
render={render}
|
||||
className={cn(
|
||||
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</ToastPrimitive.Close>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastIcon({ type }: { type: string | undefined }) {
|
||||
let icon: React.ReactNode = null
|
||||
|
||||
if (type === "success") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} strokeWidth={2} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "info") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={InformationCircleIcon} strokeWidth={2} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "warning") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={Alert02Icon} strokeWidth={2} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={MultiplicationSignCircleIcon} strokeWidth={2} className="text-destructive" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "loading") {
|
||||
icon = (
|
||||
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} className="animate-spin" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="toast-icon"
|
||||
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastList() {
|
||||
const { toasts } = ToastPrimitive.useToastManager()
|
||||
|
||||
return toasts.map((toastItem) => (
|
||||
<Toast key={toastItem.id} toast={toastItem}>
|
||||
<ToastContent>
|
||||
<ToastIcon type={toastItem.type} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<ToastTitle />
|
||||
<ToastDescription />
|
||||
</div>
|
||||
<ToastAction />
|
||||
<ToastClose />
|
||||
</ToastContent>
|
||||
</Toast>
|
||||
))
|
||||
}
|
||||
|
||||
function Toaster({
|
||||
children,
|
||||
toastManager = toast,
|
||||
...props
|
||||
}: ToastPrimitive.Provider.Props) {
|
||||
return (
|
||||
<ToastProvider toastManager={toastManager} {...props}>
|
||||
{children}
|
||||
<ToastPortal>
|
||||
<ToastViewport>
|
||||
<ToastList />
|
||||
</ToastViewport>
|
||||
</ToastPortal>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const createToastManager = ToastPrimitive.createToastManager
|
||||
const useToastManager = ToastPrimitive.useToastManager
|
||||
|
||||
export {
|
||||
Toaster,
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastContent,
|
||||
ToastDescription,
|
||||
ToastPortal,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
createToastManager,
|
||||
toast,
|
||||
useToastManager,
|
||||
}
|
||||
Reference in New Issue
Block a user