frontend fully integreated with the backend

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