frontend initiatewith shadcn uis
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ChevronRightIcon, UserIcon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useAuth, type Role } from '@/contexts/auth-context'
|
||||
import { DASHBOARD_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav'
|
||||
|
||||
function canSee(item: NavItem, permissions: string[]): boolean {
|
||||
if (!item.permission) return true
|
||||
return permissions.includes('*') || permissions.includes(item.permission)
|
||||
}
|
||||
|
||||
function isGroupVisible(group: NavGroup, permissions: string[]): boolean {
|
||||
return group.items.some((item) => canSee(item, permissions))
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const { user, setRole, permissions } = useAuth()
|
||||
const pathname = usePathname()
|
||||
|
||||
const dashboardActive = pathname === DASHBOARD_ITEM.href
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
||||
D
|
||||
</div>
|
||||
<span className="truncate font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||
Distribution
|
||||
</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="gap-0">
|
||||
{/* Dashboard — standalone, not inside a collapsible group */}
|
||||
<SidebarGroup className="py-1">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
render={<Link href={DASHBOARD_ITEM.href} />}
|
||||
isActive={dashboardActive}
|
||||
tooltip={DASHBOARD_ITEM.title}
|
||||
>
|
||||
<HugeiconsIcon icon={DASHBOARD_ITEM.icon} size={18} />
|
||||
<span>{DASHBOARD_ITEM.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Collapsible groups */}
|
||||
{NAV_GROUPS.map((group) => {
|
||||
if (!isGroupVisible(group, permissions)) return null
|
||||
|
||||
const visibleItems = group.items.filter((item) =>
|
||||
canSee(item, permissions),
|
||||
)
|
||||
const groupActive = visibleItems.some(
|
||||
(item) =>
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href)),
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.title}
|
||||
defaultOpen={false}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarMenu>
|
||||
{/* Group trigger — looks like a regular nav button */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
render={<CollapsibleTrigger />}
|
||||
isActive={groupActive}
|
||||
tooltip={group.title}
|
||||
>
|
||||
<HugeiconsIcon icon={group.icon} size={18} />
|
||||
<span>{group.title}</span>
|
||||
<HugeiconsIcon
|
||||
icon={ChevronRightIcon}
|
||||
size={14}
|
||||
className="ml-auto shrink-0 transition-transform duration-200 group-data-[open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Sub-items with indent line */}
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(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>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<div className="flex items-center gap-2 px-2 py-1 group-data-[collapsible=icon]:justify-center">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<HugeiconsIcon icon={UserIcon} size={16} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-medium text-sidebar-foreground">
|
||||
{user.fullName}
|
||||
</span>
|
||||
<Select value={user.role} onValueChange={(v) => setRole(v as Role)}>
|
||||
<SelectTrigger className="h-5 border-0 p-0 text-xs text-muted-foreground shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="warehouse">Warehouse</SelectItem>
|
||||
<SelectItem value="sales">Sales</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { BOTTOM_NAV_CANDIDATES, type NavItem } from '@/config/nav'
|
||||
|
||||
const MAX_TABS = 5
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname()
|
||||
const { permissions } = useAuth()
|
||||
|
||||
const visibleItems: NavItem[] = BOTTOM_NAV_CANDIDATES.filter((item) => {
|
||||
if (!item.permission) return true
|
||||
return permissions.includes('*') || permissions.includes(item.permission)
|
||||
}).slice(0, MAX_TABS)
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background md:hidden">
|
||||
<div className="flex h-16 items-stretch">
|
||||
{visibleItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href))
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={item.icon}
|
||||
size={22}
|
||||
className={isActive ? 'text-primary' : undefined}
|
||||
/>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
ArrowDataTransferVerticalIcon,
|
||||
ArrowUp01Icon,
|
||||
ArrowDown01Icon,
|
||||
ChevronRightIcon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { TableToolbar } from './table-toolbar';
|
||||
import { TablePagination } from './table-pagination';
|
||||
import { filterData } from './hooks/use-table-filter';
|
||||
import { sortData } from './hooks/use-table-sort';
|
||||
import { exportTable } from './hooks/use-table-export';
|
||||
import { getNestedValue, resolveUrlTemplate } from './utils/nested-value';
|
||||
import type {
|
||||
TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState,
|
||||
ServerSideParams, ExportFormat,
|
||||
} from './types';
|
||||
|
||||
interface DataTableProps<T extends object> {
|
||||
config: TableConfig<T>;
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
totalCount?: number;
|
||||
onSortChange?: (s: SortState) => void;
|
||||
onFilterChange?: (f: FilterState) => void;
|
||||
onPageChange?: (p: PageState) => void;
|
||||
onSelectionChange?: (rows: T[]) => void;
|
||||
onRowClick?: (row: T) => void;
|
||||
onRowExpand?: (row: T) => void;
|
||||
onServerSideChange?: (p: ServerSideParams) => void;
|
||||
}
|
||||
|
||||
function normalizeField(f: FieldConfig | string): FieldConfig {
|
||||
return typeof f === 'string' ? { key: f } : f;
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) {
|
||||
if (sortState.column !== colKey) {
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDataTransferVerticalIcon}
|
||||
className="size-3 text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return sortState.direction === 'asc'
|
||||
? <HugeiconsIcon icon={ArrowUp01Icon} className="size-3" />
|
||||
: <HugeiconsIcon icon={ArrowDown01Icon} className="size-3" />;
|
||||
}
|
||||
|
||||
function MobileCard<T extends object>({
|
||||
row,
|
||||
columns,
|
||||
mobileCardCfg,
|
||||
onClick,
|
||||
}: {
|
||||
row: T;
|
||||
columns: ColumnDef<T>[];
|
||||
mobileCardCfg?: TableConfig<T>['mobileCard'];
|
||||
onClick?: (row: T) => void;
|
||||
}) {
|
||||
const actionCol = columns.find(c => !c.title && c.cellRender);
|
||||
const dataColumns = columns.filter(c => !!c.title);
|
||||
|
||||
const primaryCol = dataColumns[0];
|
||||
// Always 2 columns, max 4 fields (2 rows) to prevent cramping
|
||||
const previewCols = (mobileCardCfg?.previewColumns
|
||||
? dataColumns.filter(c => mobileCardCfg.previewColumns!.includes(c.key))
|
||||
: dataColumns.slice(1, 3)
|
||||
).slice(0, 4);
|
||||
|
||||
const href = mobileCardCfg?.detailHref?.(row);
|
||||
const isNavigable = !!href || !!onClick;
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border bg-card overflow-hidden transition-colors duration-150',
|
||||
isNavigable && 'active:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
{/* Header: primary field + chevron */}
|
||||
<div className="px-4 pt-4 pb-3 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{primaryCol && <CellContent row={row} col={primaryCol} />}
|
||||
</div>
|
||||
{isNavigable && (
|
||||
<HugeiconsIcon
|
||||
icon={ChevronRightIcon}
|
||||
className="size-4 text-muted-foreground/40 shrink-0 mt-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview fields — fixed 2-column grid */}
|
||||
{previewCols.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-4 px-4 pb-4">
|
||||
{previewCols.map(col => (
|
||||
<div key={col.key} className="min-w-0 overflow-hidden">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/50 mb-1.5 truncate">
|
||||
{col.title}
|
||||
</p>
|
||||
<div className="overflow-hidden">
|
||||
<CellContent row={row} col={col} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions — only when not navigating away */}
|
||||
{!href && actionCol && (
|
||||
<div
|
||||
className="border-t px-4 py-2.5 flex justify-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{actionCol.cellRender!(row, actionCol)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return <Link href={href} className="block">{inner}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isNavigable ? 'cursor-pointer' : ''} onClick={() => onClick?.(row)}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CellContent<T extends object>({
|
||||
row, col,
|
||||
}: { row: T; col: ColumnDef<T> }) {
|
||||
if (col.cellRender) return <>{col.cellRender(row, col)}</>;
|
||||
|
||||
const fields = col.fields.map(normalizeField);
|
||||
const layout = col.layout ?? 'vertical';
|
||||
|
||||
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 ?? ''}`;
|
||||
|
||||
const renderer = field.renderer ?? 'text';
|
||||
|
||||
if (renderer === 'badge') {
|
||||
const colorClass = field.badgeConfig?.colorMap?.[String(raw)] ?? field.badgeConfig?.defaultColor ?? '';
|
||||
return (
|
||||
<Badge key={field.key} className={cn('capitalize', colorClass)}>
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderer === 'link') {
|
||||
const href = field.linkConfig?.urlKey
|
||||
? String(getNestedValue(row, field.linkConfig.urlKey) ?? '#')
|
||||
: field.linkConfig?.urlTemplate
|
||||
? resolveUrlTemplate(field.linkConfig.urlTemplate, row)
|
||||
: '#';
|
||||
return (
|
||||
<a
|
||||
key={field.key}
|
||||
href={href}
|
||||
target={field.linkConfig?.target ?? '_self'}
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={field.key}
|
||||
className={cn(
|
||||
'truncate block',
|
||||
field.primary ? 'font-medium' : 'text-muted-foreground text-xs',
|
||||
field.class,
|
||||
i === 0 && 'text-sm',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
const crossAlign =
|
||||
col.align === 'right' ? 'items-end' : col.align === 'center' ? 'items-center' : '';
|
||||
|
||||
if (layout === 'horizontal') {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-1.5 flex-wrap',
|
||||
col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : '',
|
||||
)}>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className={cn('flex flex-col gap-0.5', crossAlign)}>{rendered}</div>;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
config,
|
||||
data,
|
||||
loading,
|
||||
totalCount,
|
||||
onSortChange,
|
||||
onFilterChange,
|
||||
onPageChange,
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
onServerSideChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||
const [filterState, setFilterState] = useState<FilterState>({ global: '', columns: {} });
|
||||
const [pageState, setPageState] = useState<PageState>({
|
||||
page: 1,
|
||||
pageSize: config.pagination?.pageSize ?? 10,
|
||||
});
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(
|
||||
() => new Set(config.columns.filter(c => c.visible === false).map(c => c.key))
|
||||
);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(() => config.columns.map(c => c.key));
|
||||
const [draggedKey, setDraggedKey] = useState<string | null>(null);
|
||||
const [dropTargetKey, setDropTargetKey] = useState<string | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [containerH, setContainerH] = useState(500);
|
||||
|
||||
function rowKey(row: T): string {
|
||||
if (!config.rowKey) return JSON.stringify(row);
|
||||
if (typeof config.rowKey === 'function') return String(config.rowKey(row));
|
||||
return String(row[config.rowKey as keyof T]);
|
||||
}
|
||||
|
||||
const normalizedColumns = useMemo(() =>
|
||||
config.columns.map(col => ({
|
||||
...col,
|
||||
fields: col.fields.map(normalizeField) as FieldConfig[],
|
||||
})), [config.columns]);
|
||||
|
||||
const orderedColumns = useMemo(() =>
|
||||
columnOrder
|
||||
.map(key => normalizedColumns.find(c => c.key === key))
|
||||
.filter(Boolean) as typeof normalizedColumns,
|
||||
[normalizedColumns, columnOrder]
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(() =>
|
||||
orderedColumns.filter(c => !hiddenColumns.has(c.key)),
|
||||
[orderedColumns, hiddenColumns]
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() =>
|
||||
config.serverSide ? data : filterData(data, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[data, filterState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const sortedData = useMemo(() =>
|
||||
config.serverSide ? data : 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]
|
||||
);
|
||||
|
||||
const rowHeightPx = useMemo(() => {
|
||||
const map: Record<string, number> = { compact: 36, normal: 48, comfortable: 60 };
|
||||
return map[config.density ?? 'normal'] ?? 48;
|
||||
}, [config.density]);
|
||||
|
||||
const virtualRange = useMemo(() => {
|
||||
if (!config.virtualScroll) return null;
|
||||
const buf = 6;
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowHeightPx) - buf);
|
||||
const end = Math.min(sortedData.length, Math.ceil((scrollTop + containerH) / rowHeightPx) + buf);
|
||||
return { start, end };
|
||||
}, [config.virtualScroll, scrollTop, containerH, rowHeightPx, sortedData.length]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
if (config.serverSide || !config.pagination?.enabled) return sortedData;
|
||||
const { page, pageSize } = pageState;
|
||||
return sortedData.slice((page - 1) * pageSize, page * pageSize);
|
||||
}, [sortedData, pageState, config.serverSide, config.pagination?.enabled]);
|
||||
|
||||
const displayData = useMemo(() =>
|
||||
virtualRange ? sortedData.slice(virtualRange.start, virtualRange.end) : paginatedData,
|
||||
[virtualRange, sortedData, paginatedData]
|
||||
);
|
||||
|
||||
const spacerTop = (virtualRange?.start ?? 0) * rowHeightPx;
|
||||
const spacerBottom = virtualRange ? (sortedData.length - virtualRange.end) * rowHeightPx : 0;
|
||||
|
||||
const selectablePageData = useMemo(() =>
|
||||
config.isRowSelectable ? paginatedData.filter(r => config.isRowSelectable!(r)) : paginatedData,
|
||||
[paginatedData, config.isRowSelectable]
|
||||
);
|
||||
const allSelected = selectablePageData.length > 0 && selectablePageData.every(r => selectedKeys.has(rowKey(r)));
|
||||
const someSelected = selectablePageData.some(r => selectedKeys.has(rowKey(r))) && !allSelected;
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function handleSort(col: ColumnDef<T>) {
|
||||
if (!col.sortable) return;
|
||||
setSortState(prev => {
|
||||
const next: SortState =
|
||||
prev.column !== col.key ? { column: col.key, direction: 'asc' }
|
||||
: prev.direction === 'asc' ? { column: col.key, direction: 'desc' }
|
||||
: { column: null, direction: null };
|
||||
onSortChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: next, filter: filterState, page: pageState });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGlobalFilter(value: string) {
|
||||
const next: FilterState = { ...filterState, global: value };
|
||||
setFilterState(next);
|
||||
setPageState(p => ({ ...p, page: 1 }));
|
||||
onFilterChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: next, page: { ...pageState, page: 1 } });
|
||||
}
|
||||
|
||||
function toggleRow(row: T) {
|
||||
if (config.isRowSelectable && !config.isRowSelectable(row)) return;
|
||||
const key = rowKey(row);
|
||||
const next = new Set(selectedKeys);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const next = new Set(selectedKeys);
|
||||
if (allSelected) {
|
||||
selectablePageData.map(rowKey).forEach(k => next.delete(k));
|
||||
} else {
|
||||
selectablePageData.forEach(r => next.add(rowKey(r)));
|
||||
}
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelectedKeys(new Set());
|
||||
onSelectionChange?.([]);
|
||||
}
|
||||
|
||||
function toggleExpand(row: T) {
|
||||
const key = rowKey(row);
|
||||
setExpandedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(key: string) {
|
||||
setHiddenColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
const next = { ...pageState, page };
|
||||
setPageState(next);
|
||||
onPageChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: filterState, page: next });
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
const next = { page: 1, pageSize };
|
||||
setPageState(next);
|
||||
onPageChange?.(next);
|
||||
if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: filterState, page: next });
|
||||
}
|
||||
|
||||
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||
setScrollTop(e.currentTarget.scrollTop);
|
||||
setContainerH(e.currentTarget.clientHeight);
|
||||
}
|
||||
|
||||
const handleResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>, colKey: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const th = (e.currentTarget as HTMLElement).closest('th') as HTMLElement;
|
||||
if (!th) return;
|
||||
const startX = e.clientX;
|
||||
const startWidth = th.offsetWidth;
|
||||
th.setPointerCapture(e.pointerId);
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const newWidth = Math.max(80, startWidth + ev.clientX - startX);
|
||||
setColumnWidths(w => ({ ...w, [colKey]: newWidth }));
|
||||
};
|
||||
const onUp = () => {
|
||||
th.removeEventListener('pointermove', onMove);
|
||||
th.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
th.addEventListener('pointermove', onMove);
|
||||
th.addEventListener('pointerup', onUp);
|
||||
}, []);
|
||||
|
||||
function handleDragStart(e: React.DragEvent, colKey: string) {
|
||||
setDraggedKey(colKey);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent, colKey: string) {
|
||||
e.preventDefault();
|
||||
setDropTargetKey(colKey);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent, colKey: string) {
|
||||
e.preventDefault();
|
||||
if (!draggedKey || draggedKey === colKey) return;
|
||||
setColumnOrder(prev => {
|
||||
const next = [...prev];
|
||||
const from = next.indexOf(draggedKey);
|
||||
const to = next.indexOf(colKey);
|
||||
if (from < 0 || to < 0) return prev;
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, draggedKey);
|
||||
return next;
|
||||
});
|
||||
setDraggedKey(null);
|
||||
setDropTargetKey(null);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
setDraggedKey(null);
|
||||
setDropTargetKey(null);
|
||||
}
|
||||
|
||||
const exportConfig = typeof config.exportable === 'object' ? config.exportable : undefined;
|
||||
const showExport = !!config.exportable;
|
||||
|
||||
function handleExport(format: ExportFormat) {
|
||||
exportTable(format, sortedData, visibleColumns as ColumnDef<T>[], exportConfig?.filename ?? 'export');
|
||||
}
|
||||
|
||||
const skeletonCount = config.pagination?.pageSize ?? 10;
|
||||
const alignClass = (col: ColumnDef<T>) =>
|
||||
col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : 'justify-start';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden bg-background">
|
||||
<TableToolbar
|
||||
columns={orderedColumns as ColumnDef<T>[]}
|
||||
hiddenColumns={hiddenColumns}
|
||||
globalFilter={filterState.global}
|
||||
showExport={showExport}
|
||||
exportFormats={exportConfig?.formats}
|
||||
selectedCount={selectedKeys.size}
|
||||
onGlobalFilterChange={handleGlobalFilter}
|
||||
onColumnVisibilityChange={toggleColumnVisibility}
|
||||
onExport={handleExport}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className="md:hidden p-3 space-y-3">
|
||||
{loading && Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((__, j) => <Skeleton key={j} className="h-4 w-full" />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && displayData.length === 0 && (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{config.emptyMessage ?? 'No records found.'}
|
||||
</div>
|
||||
)}
|
||||
{!loading && displayData.map(row => (
|
||||
<MobileCard
|
||||
key={rowKey(row)}
|
||||
row={row}
|
||||
columns={visibleColumns as ColumnDef<T>[]}
|
||||
mobileCardCfg={config.mobileCard}
|
||||
onClick={onRowClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
style={config.virtualScroll ? { height: config.virtualScrollHeight ?? '520px', overflowY: 'auto' } : undefined}
|
||||
onScroll={handleScroll}
|
||||
className="hidden md:block overflow-x-auto"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className={config.stickyHeader ? 'sticky top-0 z-10 bg-muted/50' : ''}>
|
||||
<TableRow>
|
||||
{config.selectable && (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{config.expandable && <TableHead className="w-10" />}
|
||||
{visibleColumns.map(col => {
|
||||
const width = columnWidths[col.key] ?? col.width ?? 160;
|
||||
return (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={{ width, minWidth: col.minWidth ?? 80 }}
|
||||
className={cn(
|
||||
'relative select-none group',
|
||||
col.sticky === 'left' && 'sticky left-0 bg-background z-10',
|
||||
col.sticky === 'right' && 'sticky right-0 bg-background z-10',
|
||||
col.sortable && 'cursor-pointer hover:bg-muted/70',
|
||||
draggedKey === col.key && 'opacity-50',
|
||||
dropTargetKey === col.key && draggedKey !== col.key && 'bg-primary/10',
|
||||
)}
|
||||
draggable
|
||||
onDragStart={e => handleDragStart(e, col.key)}
|
||||
onDragOver={e => handleDragOver(e, col.key)}
|
||||
onDrop={e => handleDrop(e, col.key)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleSort(col as ColumnDef<T>)}
|
||||
>
|
||||
<div className={cn('flex items-center gap-1', alignClass(col as ColumnDef<T>))}>
|
||||
{col.headerRender ? col.headerRender(col as ColumnDef<T>) : col.title}
|
||||
{col.sortable && <SortIcon colKey={col.key} sortState={sortState} />}
|
||||
</div>
|
||||
{col.resizable !== false && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize opacity-0 group-hover:opacity-100 bg-border"
|
||||
onPointerDown={e => handleResizeStart(e, col.key)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{loading && Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{config.selectable && <TableCell><Skeleton className="h-4 w-4" /></TableCell>}
|
||||
{config.expandable && <TableCell><Skeleton className="h-4 w-4" /></TableCell>}
|
||||
{visibleColumns.map(col => (
|
||||
<TableCell key={col.key}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{!loading && displayData.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (config.selectable ? 1 : 0) + (config.expandable ? 1 : 0)}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{config.emptyMessage ?? 'No records found.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{!loading && virtualRange && spacerTop > 0 && (
|
||||
<tr style={{ height: spacerTop }} />
|
||||
)}
|
||||
|
||||
{!loading && displayData.map(row => {
|
||||
const key = rowKey(row);
|
||||
const isSelected = selectedKeys.has(key);
|
||||
const isExpanded = expandedKeys.has(key);
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<TableRow
|
||||
className={cn(
|
||||
'cursor-pointer transition-colors',
|
||||
isSelected && 'bg-primary/5',
|
||||
config.rowClass?.(row),
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{config.selectable && (
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={config.isRowSelectable ? !config.isRowSelectable(row) : false}
|
||||
onCheckedChange={() => toggleRow(row)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{config.expandable && (
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => toggleExpand(row)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ChevronRightIcon}
|
||||
className={cn('size-4 transition-transform', isExpanded && 'rotate-90')}
|
||||
/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map(col => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.sticky === 'left' && 'sticky left-0 bg-background',
|
||||
col.sticky === 'right' && 'sticky right-0 bg-background',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
style={{ height: rowHeightPx }}
|
||||
>
|
||||
<CellContent row={row} col={col as ColumnDef<T>} />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{config.expandable && isExpanded && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (config.selectable ? 2 : 1)}
|
||||
className="bg-muted/30 p-4"
|
||||
>
|
||||
{config.expandRender?.(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && virtualRange && spacerBottom > 0 && (
|
||||
<tr style={{ height: spacerBottom }} />
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{config.pagination?.enabled && (
|
||||
<TablePagination
|
||||
page={pageState.page}
|
||||
pageSize={pageState.pageSize}
|
||||
totalRecords={totalRecords}
|
||||
pageSizeOptions={config.pagination.pageSizeOptions ?? [10, 25, 50, 100]}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type ColumnDef, type ExportFormat, type FieldConfig } from '../types';
|
||||
import { getNestedValue } from '../utils/nested-value';
|
||||
|
||||
export function exportTable<T extends object>(
|
||||
format: ExportFormat, data: T[], columns: ColumnDef<T>[], filename = 'export'
|
||||
): void {
|
||||
const exportable = columns.filter(c => c.exportable !== false);
|
||||
if (format === 'csv') exportCsv(data, exportable, filename);
|
||||
if (format === 'excel') exportExcel(data, exportable, filename);
|
||||
if (format === 'pdf') exportPdf(data, exportable, filename);
|
||||
}
|
||||
|
||||
function resolveCellText<T>(row: T, col: ColumnDef<T>): string {
|
||||
return col.fields.map(f => {
|
||||
const field: FieldConfig = typeof f === 'string' ? { key: f } : f;
|
||||
const raw = getNestedValue(row, field.key);
|
||||
if (field.transform) return field.transform(raw, row);
|
||||
return `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function download(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function escHtml(s: string) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function exportCsv<T>(data: T[], columns: ColumnDef<T>[], filename: string) {
|
||||
const headers = columns.map(c => `"${c.title}"`).join(',');
|
||||
const rows = data.map(row =>
|
||||
columns.map(col => `"${resolveCellText(row, col).replace(/"/g, '""')}"`).join(',')
|
||||
);
|
||||
download(new Blob([[headers, ...rows].join('\r\n')], { type: 'text/csv;charset=utf-8;' }), `${filename}.csv`);
|
||||
}
|
||||
|
||||
function exportExcel<T>(data: T[], columns: ColumnDef<T>[], filename: string) {
|
||||
const thead = columns.map(c => `<th>${escHtml(c.title)}</th>`).join('');
|
||||
const tbody = data.map(row =>
|
||||
`<tr>${columns.map(col => `<td>${escHtml(resolveCellText(row, col))}</td>`).join('')}</tr>`
|
||||
).join('');
|
||||
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
xmlns:x="urn:schemas-microsoft-com:office:excel">
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body><table border="1">${thead}${tbody}</table></body></html>`;
|
||||
download(new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8;' }), `${filename}.xls`);
|
||||
}
|
||||
|
||||
function exportPdf<T>(data: T[], columns: ColumnDef<T>[], filename: string) {
|
||||
const thead = columns.map(c =>
|
||||
`<th style="padding:6px 10px;border:1px solid #ccc;background:#f5f5f5">${escHtml(c.title)}</th>`
|
||||
).join('');
|
||||
const tbody = data.map(row =>
|
||||
`<tr>${columns.map(col =>
|
||||
`<td style="padding:5px 10px;border:1px solid #ccc">${escHtml(resolveCellText(row, col))}</td>`
|
||||
).join('')}</tr>`
|
||||
).join('');
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) return;
|
||||
win.document.write(`<!DOCTYPE html><html><head>
|
||||
<title>${escHtml(filename)}</title>
|
||||
<style>body{font-family:sans-serif;font-size:12px}table{border-collapse:collapse;width:100%}
|
||||
@media print{button{display:none}}</style></head>
|
||||
<body><h2>${escHtml(filename)}</h2>
|
||||
<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>
|
||||
<br><button onclick="window.print()">Print / Save PDF</button>
|
||||
</body></html>`);
|
||||
win.document.close();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type ColumnDef, type FieldConfig, type FilterState } from '../types';
|
||||
import { getNestedValue } from '../utils/nested-value';
|
||||
|
||||
export function filterData<T extends object>(data: T[], state: FilterState, columns: ColumnDef<T>[]): T[] {
|
||||
let result = data;
|
||||
|
||||
if (state.global.trim()) {
|
||||
const term = state.global.toLowerCase();
|
||||
result = result.filter(row =>
|
||||
columns.some(col =>
|
||||
col.fields.some(f => {
|
||||
const key = typeof f === 'string' ? f : (f as FieldConfig).key;
|
||||
return String(getNestedValue(row, key) ?? '').toLowerCase().includes(term);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const colFilters = Object.entries(state.columns).filter(([, v]) => v.trim());
|
||||
for (const [colKey, term] of colFilters) {
|
||||
const col = columns.find(c => c.key === colKey);
|
||||
if (!col) continue;
|
||||
const lower = term.toLowerCase();
|
||||
result = result.filter(row =>
|
||||
col.fields.some(f => {
|
||||
const key = typeof f === 'string' ? f : (f as FieldConfig).key;
|
||||
return String(getNestedValue(row, key) ?? '').toLowerCase().includes(lower);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { type ColumnDef, type SortState } from '../types';
|
||||
import { getNestedValue } from '../utils/nested-value';
|
||||
|
||||
export function sortData<T extends object>(data: T[], state: SortState, columns: ColumnDef<T>[]): T[] {
|
||||
if (!state.column || !state.direction) return data;
|
||||
const col = columns.find(c => c.key === state.column);
|
||||
if (!col) return data;
|
||||
|
||||
if (col.sortFn) {
|
||||
const sorted = [...data].sort(col.sortFn);
|
||||
return state.direction === 'desc' ? sorted.reverse() : sorted;
|
||||
}
|
||||
|
||||
const primaryField = col.fields[0];
|
||||
const fieldKey = typeof primaryField === 'string' ? primaryField : primaryField.key;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const av = getNestedValue(a, fieldKey);
|
||||
const bv = getNestedValue(b, fieldKey);
|
||||
const result = compareValues(av, bv);
|
||||
return state.direction === 'asc' ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function compareValues(a: unknown, b: unknown): number {
|
||||
if (a == null) return -1;
|
||||
if (b == null) return 1;
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { DataTable } from './data-table';
|
||||
export type {
|
||||
TableConfig,
|
||||
ColumnDef,
|
||||
FieldConfig,
|
||||
MobileCardConfig,
|
||||
SortState,
|
||||
FilterState,
|
||||
PageState,
|
||||
ServerSideParams,
|
||||
ExportFormat,
|
||||
BadgeConfig,
|
||||
IconConfig,
|
||||
LinkConfig,
|
||||
PaginationConfig,
|
||||
ExportConfig,
|
||||
} from './types';
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@hugeicons/core-free-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface TablePaginationProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalRecords: number;
|
||||
pageSizeOptions?: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
}
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number): (number | '...')[] {
|
||||
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const pages: (number | '...')[] = [1];
|
||||
if (page > 3) pages.push('...');
|
||||
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (page < totalPages - 2) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
page,
|
||||
pageSize,
|
||||
totalRecords,
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: TablePaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(totalRecords / pageSize));
|
||||
const from = Math.min((page - 1) * pageSize + 1, totalRecords);
|
||||
const to = Math.min(page * pageSize, totalRecords);
|
||||
const pages = buildPageNumbers(page, totalPages);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-3 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Rows per page:</span>
|
||||
<Select
|
||||
value={pageSize}
|
||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map(s => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="ml-2">
|
||||
{totalRecords === 0 ? '0 records' : `${from}–${to} of ${totalRecords} records`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<HugeiconsIcon icon={ChevronLeftIcon} className="size-4" />
|
||||
</Button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground text-sm">…</span>
|
||||
) : (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? 'default' : 'outline'}
|
||||
size="icon-sm"
|
||||
onClick={() => onPageChange(p as number)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<HugeiconsIcon icon={ChevronRightIcon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
Search01Icon,
|
||||
Cancel01Icon,
|
||||
ColumnsThreeCogIcon,
|
||||
Download01Icon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { type ColumnDef, type ExportFormat } from './types';
|
||||
|
||||
interface TableToolbarProps<T> {
|
||||
columns: ColumnDef<T>[];
|
||||
hiddenColumns: Set<string>;
|
||||
globalFilter: string;
|
||||
showExport?: boolean;
|
||||
exportFormats?: ExportFormat[];
|
||||
selectedCount?: number;
|
||||
onGlobalFilterChange: (value: string) => void;
|
||||
onColumnVisibilityChange: (key: string) => void;
|
||||
onExport?: (format: ExportFormat) => void;
|
||||
onClearSelection?: () => void;
|
||||
}
|
||||
|
||||
export function TableToolbar<T>({
|
||||
columns,
|
||||
hiddenColumns,
|
||||
globalFilter,
|
||||
showExport,
|
||||
exportFormats = ['csv', 'excel', 'pdf'],
|
||||
selectedCount = 0,
|
||||
onGlobalFilterChange,
|
||||
onColumnVisibilityChange,
|
||||
onExport,
|
||||
onClearSelection,
|
||||
}: TableToolbarProps<T>) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
{/* Global search */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={globalFilter}
|
||||
onChange={e => onGlobalFilterChange(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<button
|
||||
onClick={() => onGlobalFilterChange('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection badge */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedCount} selected
|
||||
</Badge>
|
||||
<Button variant="ghost" size="xs" onClick={onClearSelection} className="text-xs h-6">
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column visibility */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="sm" className="gap-1.5" />}>
|
||||
<HugeiconsIcon icon={ColumnsThreeCogIcon} className="size-4" />
|
||||
Columns
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{columns.map(col => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.key}
|
||||
checked={!hiddenColumns.has(col.key)}
|
||||
onCheckedChange={() => onColumnVisibilityChange(col.key)}
|
||||
>
|
||||
{col.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Export */}
|
||||
{showExport && onExport && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="sm" className="gap-1.5" />}>
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
Export
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{exportFormats.map(fmt => (
|
||||
<DropdownMenuItem key={fmt} onClick={() => onExport(fmt)}>
|
||||
{fmt.toUpperCase()}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export type CellRenderer = 'text' | 'badge' | 'icon' | 'link' | 'custom';
|
||||
export type ColumnLayout = 'vertical' | 'horizontal';
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
export type ExportFormat = 'csv' | 'excel' | 'pdf';
|
||||
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface BadgeConfig {
|
||||
colorMap?: Record<string, string>;
|
||||
defaultColor?: string;
|
||||
}
|
||||
|
||||
export interface IconConfig {
|
||||
iconMap?: Record<string, string>;
|
||||
defaultIcon?: string;
|
||||
colorMap?: Record<string, string>;
|
||||
defaultColorClass?: string;
|
||||
}
|
||||
|
||||
export interface LinkConfig {
|
||||
urlKey?: string;
|
||||
urlTemplate?: string;
|
||||
target?: '_blank' | '_self';
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
key: string;
|
||||
label?: string;
|
||||
renderer?: CellRenderer;
|
||||
primary?: boolean;
|
||||
class?: string;
|
||||
badgeConfig?: BadgeConfig;
|
||||
iconConfig?: IconConfig;
|
||||
linkConfig?: LinkConfig;
|
||||
truncate?: boolean;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
transform?: (value: unknown, row: unknown) => string;
|
||||
}
|
||||
|
||||
export interface ColumnDef<T = object> {
|
||||
key: string;
|
||||
title: string;
|
||||
fields: (FieldConfig | string)[];
|
||||
layout?: ColumnLayout;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
sticky?: 'left' | 'right' | false;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
resizable?: boolean;
|
||||
visible?: boolean;
|
||||
align?: ColumnAlign;
|
||||
group?: string;
|
||||
exportable?: boolean;
|
||||
sortFn?: (a: T, b: T) => number;
|
||||
cellRender?: (row: T, col: ColumnDef<T>) => React.ReactNode;
|
||||
headerRender?: (col: ColumnDef<T>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
enabled: boolean;
|
||||
pageSize?: number;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
export interface ExportConfig {
|
||||
formats?: ExportFormat[];
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface MobileCardConfig<T = object> {
|
||||
/** Column keys to show in the compact summary (after the primary column). Defaults to first 2. */
|
||||
previewColumns?: string[];
|
||||
/** Return a URL to navigate to on card tap. Takes precedence over onRowClick. */
|
||||
detailHref?: (row: T) => string;
|
||||
}
|
||||
|
||||
export interface TableConfig<T = object> {
|
||||
columns: ColumnDef<T>[];
|
||||
rowKey?: keyof T | ((row: T) => string);
|
||||
selectable?: boolean;
|
||||
multiSelect?: boolean;
|
||||
expandable?: boolean;
|
||||
stickyHeader?: boolean;
|
||||
pagination?: PaginationConfig;
|
||||
virtualScroll?: boolean;
|
||||
virtualScrollHeight?: string;
|
||||
serverSide?: boolean;
|
||||
exportable?: boolean | ExportConfig;
|
||||
density?: 'compact' | 'normal' | 'comfortable';
|
||||
emptyMessage?: string;
|
||||
rowClass?: (row: T) => string;
|
||||
isRowSelectable?: (row: T) => boolean;
|
||||
expandRender?: (row: T) => React.ReactNode;
|
||||
mobileCard?: MobileCardConfig<T>;
|
||||
}
|
||||
|
||||
export interface SortState {
|
||||
column: string | null;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
export interface FilterState {
|
||||
global: string;
|
||||
columns: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PageState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ServerSideParams {
|
||||
sort: SortState;
|
||||
filter: FilterState;
|
||||
page: PageState;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function getNestedValue(obj: unknown, path: string): unknown {
|
||||
return path.split('.').reduce(
|
||||
(acc, key) => (acc as Record<string, unknown>)?.[key], obj
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveUrlTemplate(template: string, row: unknown): string {
|
||||
return template.replace(/\{(\w+(?:\.\w+)*)\}/g, (_, path) =>
|
||||
String(getNestedValue(row, path) ?? '')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
|
||||
|
||||
function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
<ThemeHotkey />
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT"
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeHotkey() {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "d") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTypingTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export { ThemeProvider }
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-2xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-2xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-7",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[5px] border border-transparent bg-input/90 transition-shadow outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-transparent after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex min-h-7 cursor-default items-center gap-2 rounded-xl px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex min-h-7 cursor-default items-center gap-2 rounded-xl px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-2xl bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark: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 )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex min-h-7 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 focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex min-h-7 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 focus:**:text-accent-foreground data-inset:pl-7 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="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border/50", 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 group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-2xl border border-transparent bg-input/50 px-2.5 py-1 text-base transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
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 SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1.5 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-2xl border border-transparent bg-input/50 px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] duration-200 outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark: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}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
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",
|
||||
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" />
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } 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 Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/30 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-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>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", 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-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.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,727 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
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)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
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)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
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])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = 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}>
|
||||
<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 flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...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
|
||||
dir={dir}
|
||||
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"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<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"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
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="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{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="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={2} />
|
||||
<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 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[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(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-input/50 shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn(
|
||||
"flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-xl px-3 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-3 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-2 text-left text-sm whitespace-nowrap ring-sidebar-ring outline-hidden transition-[width,height,padding] duration-200 group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 has-[>svg:first-child]:pl-2.5 has-[>svg:last-child]:pr-2.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 px-3 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-xl px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-xl px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-xl"
|
||||
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-slot="sidebar-menu-sub"
|
||||
data-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-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-xl px-3 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 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-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-2xl bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/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-muted/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-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 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,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-xl bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:translate-x-[1.5px] data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:translate-x-[-1.5px] data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:translate-x-[-1.5px] data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:translate-x-[1.5px] data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user