mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-14 10:41:21 +00:00
feat: new banking module (#54720)
* feat: initial SPA setup for banking * wip: bring over new banking module * feat: added Espresso design tokens * feat: button styles * fix: add all ink colors * wip: espresso design system changes * feat: button and badge espresso components * fix: button styling for reconcile * feat: Espresso progress bar * feat: Espresso toggle switch * feat: Espresso tabs design * fix: vertical tab support * fix: button sizing across modals * feat: Espresso style table layout * feat: Espresso tooltip * feat: Espresso elevations and checkbox * feat: Dialog with Espresso styles * feat: Espresso textarea * fix: input styles * fix: colors on bank picker * fix: breadcrumb styling * fix: bank picker styling * feat: create doctypes and fields for bank reconciliation * feat: APIs for banking * fix: use date format parser * fix: font styling to match Espresso * wip: settings modal * feat: settings dialog component * fix: icons and invalid requests * feat: preferences tab * fix: adjust icon stroke width to 1.5 * feat: rule configuration in settings * fix: remove sheet component * feat: alert and error banner component * feat: dropdown in Espresso * feat: popover and select in Espresso * fix: cleanup more styles * fix: match size of link fields * feat: command styling * fix: remove unused style tokens * fix: styles for global date picker dropdown * fix: styles for match and reconcile * feat: table Espresso component * feat: remove all other design tokens * fix: remove unused tokens * fix: form elements * fix: remove unused styles and fix filters in bank transaction list * feat: fetch bank rec doctypes for filtering * fix: record payment modal * feat: support for dark mode switching * fix: move bank logos to public folder * feat: add support for RTL * feat: support for RTL * chore: send layout direction in dev boot * fix: make checkbox work in RTL * feat: dark mode support * fix: dark mode style * feat: bank logos in dark mode * feat: dark mode bank logos * chore: use dark mode bank logos everywhere * chore: move rule evaluation to controller * chore: add tests for bank transaction rules * fix: move deps to fix actions errors * fix: move tw-animate-css to deps * fix: remove shadcn * fix: do not open modal if no transactions selected * fix: add translation strings * feat: add banner on existing bank reconciliation tool * feat: bank statement import * fix: translations and layout directions * fix: validation for transaction matching rule * fix: styles * fix: show conflicting transactions in alert * fix: show help text for new banking module forms * feat: show total debits and credits * fix: dark mode colors in automatic config * feat: add keyboard shortcuts help * feat: added keyboard shortcut for settings * fix: decrease size of progress bar * chore: bump packages * feat: add tests for statement import * fix: settings dialog * fix: show banner on small screens * fix: show banner when no bank account set
This commit is contained in:
196
banking/src/components/ui/alert-dialog.tsx
Normal file
196
banking/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black-200 dark:bg-black-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"bg-surface-modal shadow-xl rounded-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-start sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-2xl leading-6 text-ink-gray-8 font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-ink-gray-7 text-p-base", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"bg-surface-gray-1 mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "solid",
|
||||
size = "md",
|
||||
theme = "red",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} theme={theme} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "md",
|
||||
theme = "gray",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
104
banking/src/components/ui/alert.tsx
Normal file
104
banking/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3.5 text-base grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-1 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
subtle: "bg-surface-white",
|
||||
outline: "border border-outline-gray-3",
|
||||
},
|
||||
theme: {
|
||||
gray: "text-ink-gray-8",
|
||||
blue: "text-ink-blue-3",
|
||||
green: "text-ink-green-3",
|
||||
red: "text-ink-red-3",
|
||||
amber: "text-ink-amber-3",
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
// Subtle alerts
|
||||
{
|
||||
theme: "gray",
|
||||
variant: "subtle",
|
||||
className: "bg-surface-gray-2 border-outline-gray-1"
|
||||
},
|
||||
{
|
||||
theme: "blue",
|
||||
variant: "subtle",
|
||||
className: "bg-surface-blue-2 border-surface-blue-2"
|
||||
},
|
||||
{
|
||||
theme: "green",
|
||||
variant: "subtle",
|
||||
className: "bg-surface-green-2 border-surface-green-2"
|
||||
},
|
||||
{
|
||||
theme: "red",
|
||||
variant: "subtle",
|
||||
className: "bg-surface-red-2 border-surface-red-2"
|
||||
},
|
||||
{
|
||||
theme: "amber",
|
||||
variant: "subtle",
|
||||
className: "bg-surface-amber-2 border-surface-amber-2"
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "subtle",
|
||||
theme: "gray",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type AlertProps = React.ComponentProps<"div"> & VariantProps<typeof alertVariants>
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
theme,
|
||||
...props
|
||||
}: AlertProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant, theme }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 min-h-4 text-ink-gray-8 font-medium text-p-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-ink-gray-6 col-start-2 grid justify-items-start gap-1 text-p-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
188
banking/src/components/ui/badge.tsx
Normal file
188
banking/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center select-none rounded-full whitespace-nowrap gap-1 w-fit shrink-0 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: "",
|
||||
subtle: "",
|
||||
outline: "bg-transparent border",
|
||||
ghost: "bg-transparent",
|
||||
},
|
||||
size: {
|
||||
sm: 'h-4 text-xs px-1.5 [&>svg]:size-2.5',
|
||||
md: 'h-5 text-xs px-1.5 [&>svg]:size-3',
|
||||
lg: 'h-6 text-sm px-2 [&>svg]:size-3',
|
||||
},
|
||||
theme: {
|
||||
gray: "",
|
||||
blue: "",
|
||||
green: "",
|
||||
red: "",
|
||||
orange: "",
|
||||
violet: "",
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
// Solid badges
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "gray",
|
||||
className: "text-ink-white bg-surface-gray-7 [a&]:hover:bg-surface-gray-8"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "blue",
|
||||
className: "text-ink-blue-1 bg-surface-blue-5 [a&]:hover:bg-surface-blue-6"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "green",
|
||||
className: "text-ink-green-1 bg-surface-green-5 [a&]:hover:bg-surface-green-6"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "orange",
|
||||
className: "text-ink-amber-1 bg-surface-amber-5 [a&]:hover:bg-surface-amber-6"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "red",
|
||||
className: "text-ink-red-1 bg-surface-red-5 [a&]:hover:bg-surface-red-6"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-1 bg-surface-violet-5 [a&]:hover:bg-surface-violet-6"
|
||||
},
|
||||
// Subtle badge
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "gray",
|
||||
className: "text-ink-gray-6 bg-surface-gray-2 [a&]:hover:bg-surface-gray-3"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "blue",
|
||||
className: "text-ink-blue-4 bg-surface-blue-2 [a&]:hover:bg-surface-blue-3"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "green",
|
||||
className: "text-ink-green-4 bg-surface-green-2 [a&]:hover:bg-surface-green-3"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "orange",
|
||||
className: "text-ink-amber-4 bg-surface-amber-2 [a&]:hover:bg-surface-amber-3"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "red",
|
||||
className: "text-ink-red-4 bg-surface-red-2 [a&]:hover:bg-surface-red-3"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-4 bg-surface-violet-2 [a&]:hover:bg-surface-violet-3"
|
||||
},
|
||||
// Outline badge
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "gray",
|
||||
className: "text-ink-gray-6 border-outline-gray-2 [a&]:hover:bg-surface-gray-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "blue",
|
||||
className: "text-ink-blue-4 border-outline-blue-2 [a&]:hover:bg-surface-blue-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "green",
|
||||
className: "text-ink-green-4 border-outline-green-2 [a&]:hover:bg-surface-green-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "orange",
|
||||
className: "text-ink-amber-4 border-outline-amber-2 [a&]:hover:bg-surface-amber-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "red",
|
||||
className: "text-ink-red-4 border-outline-red-2 [a&]:hover:bg-surface-red-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-4 border-outline-violet-2 [a&]:hover:bg-surface-violet-2"
|
||||
},
|
||||
// Ghost badge
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "gray",
|
||||
className: "text-ink-gray-6"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "blue",
|
||||
className: "text-ink-blue-4"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "green",
|
||||
className: "text-ink-green-4"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "orange",
|
||||
className: "text-ink-amber-4"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "red",
|
||||
className: "text-ink-red-4"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-4"
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "subtle",
|
||||
size: "md",
|
||||
theme: "gray",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "subtle",
|
||||
size = "md",
|
||||
theme = "gray",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-theme={theme}
|
||||
className={cn(badgeVariants({ variant, size, theme }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
banking/src/components/ui/breadcrumb.tsx
Normal file
109
banking/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { MoreHorizontal } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-ink-gray-5 flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("text-ink-gray-5 font-medium text-lg hover:text-ink-gray-7 active:text-ink-gray-7 transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-ink-gray-8 text-lg font-medium text-balance tracking-wide", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span className="text-ink-gray-4 text-base">/</span>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
263
banking/src/components/ui/button.tsx
Normal file
263
banking/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap transition-all disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: "text-ink-white",
|
||||
subtle: "",
|
||||
ghost: "bg-transparent",
|
||||
outline: "bg-surface-white border",
|
||||
link: "bg-transparent underline-offset-4 underline",
|
||||
},
|
||||
size: {
|
||||
sm: "h-7 text-base px-2 rounded [&_svg:not([class*='size-'])]:size-4",
|
||||
md: "h-8 text-base font-medium px-2.5 rounded [&_svg:not([class*='size-'])]:size-4.5",
|
||||
lg: "h-10 text-lg font-medium px-3 rounded-md [&_svg:not([class*='size-'])]:size-5",
|
||||
xl: "h-11.5 text-xl font-medium px-3.5 rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
"2xl": "h-13 text-2xl font-medium px-3.5 rounded-xl [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
theme: {
|
||||
gray: "focus-visible:shadow-focus-gray",
|
||||
blue: "focus-visible:shadow-focus-blue",
|
||||
green: "focus-visible:shadow-focus-green",
|
||||
red: "focus-visible:shadow-focus-red",
|
||||
amber: "focus-visible:shadow-focus-amber",
|
||||
violet: "focus-visible:shadow-focus-violet",
|
||||
},
|
||||
isIconButton: {
|
||||
true: "px-0",
|
||||
false: ""
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
// Icon only buttons - Sizes
|
||||
{
|
||||
isIconButton: true,
|
||||
size: "sm",
|
||||
className: "size-7"
|
||||
},
|
||||
{
|
||||
isIconButton: true,
|
||||
size: "md",
|
||||
className: "size-8"
|
||||
},
|
||||
{
|
||||
isIconButton: true,
|
||||
size: "lg",
|
||||
className: "size-10"
|
||||
},
|
||||
{
|
||||
isIconButton: true,
|
||||
size: "xl",
|
||||
className: "size-11.5"
|
||||
},
|
||||
{
|
||||
isIconButton: true,
|
||||
size: "2xl",
|
||||
className: "size-13"
|
||||
},
|
||||
// Solid buttons
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "gray",
|
||||
className: "bg-surface-gray-7 hover:bg-surface-gray-6 active:bg-surface-gray-5 disabled:bg-surface-gray-2 disabled:text-ink-gray-4"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "blue",
|
||||
className: "bg-surface-blue-5 text-ink-blue-1 hover:bg-surface-blue-6 active:bg-surface-blue-7 disabled:bg-surface-blue-2 disabled:text-ink-blue-2"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "green",
|
||||
className: "bg-surface-green-5 text-ink-green-1 hover:bg-surface-green-6 active:bg-surface-green-7 disabled:bg-surface-green-2 disabled:text-ink-green-2"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "red",
|
||||
className: "bg-surface-red-5 text-ink-red-1 hover:bg-surface-red-6 active:bg-surface-red-7 disabled:bg-surface-red-2 disabled:text-ink-red-2"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "violet",
|
||||
className: "bg-surface-violet-5 text-ink-violet-1 hover:bg-surface-violet-6 active:bg-surface-violet-7 disabled:bg-surface-violet-2 disabled:text-ink-violet-2"
|
||||
},
|
||||
{
|
||||
variant: "solid",
|
||||
theme: "amber",
|
||||
className: "bg-surface-amber-5 text-ink-amber-1 hover:bg-surface-amber-6 active:bg-surface-amber-7 disabled:bg-surface-amber-2 disabled:text-ink-amber-2"
|
||||
},
|
||||
// Subtle Buttons
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "gray",
|
||||
className: "text-ink-gray-7 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 disabled:bg-surface-gray-2 disabled:text-ink-gray-4"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "blue",
|
||||
className: "text-ink-blue-4 bg-surface-blue-2 hover:bg-surface-blue-3 active:bg-surface-blue-4 disabled:bg-surface-blue-2 disabled:text-ink-blue-2"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "green",
|
||||
className: "text-ink-green-4 bg-surface-green-2 hover:bg-surface-green-3 active:bg-surface-green-4 disabled:bg-surface-green-2 disabled:text-ink-green-2"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "red",
|
||||
className: "text-ink-red-4 bg-surface-red-2 hover:bg-surface-red-3 active:bg-surface-red-4 disabled:bg-surface-red-2 disabled:text-ink-red-2"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-4 bg-surface-violet-2 hover:bg-surface-violet-3 active:bg-surface-violet-4 disabled:bg-surface-violet-2 disabled:text-ink-violet-2"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
theme: "amber",
|
||||
className: "text-ink-amber-4 bg-surface-amber-2 hover:bg-surface-amber-3 active:bg-surface-amber-4 disabled:bg-surface-amber-2 disabled:text-ink-amber-2"
|
||||
},
|
||||
// Outline buttons
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "gray",
|
||||
className:
|
||||
"text-ink-gray-7 border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 active:bg-surface-gray-4 disabled:bg-surface-gray-2 disabled:text-ink-gray-4 disabled:border-outline-gray-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "blue",
|
||||
className:
|
||||
"text-ink-blue-4 border-outline-blue-2 hover:border-outline-blue-3 active:border-outline-blue-4 active:bg-surface-blue-4 disabled:bg-surface-blue-2 disabled:text-ink-blue-2 disabled:border-outline-blue-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "green",
|
||||
className:
|
||||
"text-ink-green-4 border-outline-green-2 hover:border-outline-green-3 active:border-outline-green-4 active:bg-surface-green-4 disabled:bg-surface-green-2 disabled:text-ink-green-2 disabled:border-outline-green-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "red",
|
||||
className:
|
||||
"text-ink-red-4 border-outline-red-2 hover:border-outline-red-3 active:border-outline-red-4 active:bg-surface-red-4 disabled:bg-surface-red-2 disabled:text-ink-red-2 disabled:border-outline-red-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-4 border-outline-violet-2 hover:border-outline-violet-3 active:border-outline-violet-4 active:bg-surface-violet-4 disabled:bg-surface-violet-2 disabled:text-ink-violet-2 disabled:border-outline-violet-2"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
theme: "amber",
|
||||
className: "text-ink-amber-4 border-outline-amber-2 hover:border-outline-amber-3 active:border-outline-amber-4 active:bg-surface-amber-4 disabled:bg-surface-amber-2 disabled:text-ink-amber-2 disabled:border-outline-amber-2"
|
||||
},
|
||||
// Ghost buttons
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "gray",
|
||||
className:
|
||||
"text-ink-gray-7 hover:bg-surface-gray-3 active:bg-surface-gray-4 disabled:text-ink-gray-4"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "blue",
|
||||
className:
|
||||
"text-ink-blue-4 hover:bg-surface-blue-3 active:bg-surface-blue-4 disabled:text-ink-blue-2"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "green",
|
||||
className:
|
||||
"text-ink-green-4 hover:bg-surface-green-3 active:bg-surface-green-4 disabled:text-ink-green-2"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "red",
|
||||
className:
|
||||
"text-ink-red-4 hover:bg-surface-red-3 active:bg-surface-red-4 disabled:text-ink-red-2"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-4 hover:bg-surface-violet-3 active:bg-surface-violet-4 disabled:text-ink-violet-2"
|
||||
},
|
||||
{
|
||||
variant: "ghost",
|
||||
theme: "amber",
|
||||
className: "text-ink-amber-4 hover:bg-surface-amber-3 active:bg-surface-amber-4 disabled:text-ink-amber-2"
|
||||
},
|
||||
//Link buttons
|
||||
{
|
||||
variant: "link",
|
||||
theme: "gray",
|
||||
className: "text-ink-gray-8 hover:text-ink-gray-8 active:text-ink-gray-8 disabled:text-ink-gray-4"
|
||||
},
|
||||
{
|
||||
variant: "link",
|
||||
theme: "blue",
|
||||
className: "text-ink-blue-3 hover:text-ink-blue-4 active:text-ink-blue-4 disabled:text-ink-blue-link"
|
||||
},
|
||||
{
|
||||
variant: "link",
|
||||
theme: "green",
|
||||
className: "text-ink-green-3 hover:text-ink-green-4 active:text-ink-green-4 disabled:text-ink-green-2"
|
||||
},
|
||||
{
|
||||
variant: "link",
|
||||
theme: "red",
|
||||
className: "text-ink-red-3 hover:text-ink-red-4 active:text-red-4 disabled:text-ink-red-2"
|
||||
},
|
||||
{
|
||||
variant: "link",
|
||||
theme: "violet",
|
||||
className: "text-ink-violet-3 hover:text-ink-violet-4 active:text-ink-violet-4 disabled:text-ink-violet-2"
|
||||
},
|
||||
{
|
||||
variant: "link",
|
||||
theme: "amber",
|
||||
className: "text-ink-amber-3 hover:text-ink-amber-4 active:text-ink-amber-4 disabled:text-ink-amber-2"
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "solid",
|
||||
size: "sm",
|
||||
theme: "gray",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "solid",
|
||||
size = "sm",
|
||||
theme = "gray",
|
||||
isIconButton = false,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-theme={theme}
|
||||
className={cn(buttonVariants({ variant, size, theme, className, isIconButton }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
218
banking/src/components/ui/calendar.tsx
Normal file
218
banking/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-surface-modal group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-outline-gray-1 border border-outline-gray-2 shadow-xs has-focus:ring-outline-gray-1/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-surface-modal inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md ps-2 pe-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-ink-gray-5 [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-ink-gray-5 rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-ink-gray-5",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-e-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-s-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-s-md bg-surface-gray-1",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-e-md bg-surface-gray-1", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-surface-gray-1 text-ink-gray-8 rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-ink-gray-5 aria-selected:text-ink-gray-5",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-ink-gray-5 opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
isIconButton
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-surface-gray-7 data-[selected-single=true]:text-ink-white data-[range-middle=true]:bg-surface-gray-1 data-[range-middle=true]:text-ink-gray-8 data-[range-start=true]:bg-surface-gray-7 data-[range-start=true]:text-ink-white data-[range-end=true]:bg-surface-gray-7 data-[range-end=true]:text-ink-white group-data-[focused=true]/day:border-outline-gray-1 group-data-[focused=true]/day:ring-outline-gray-1/50 dark:hover:text-ink-gray-8 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-e-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-s-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
92
banking/src/components/ui/card.tsx
Normal file
92
banking/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-surface-cards text-ink-gray-8 flex flex-col gap-6 rounded-xl border py-6 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-ink-gray-5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
44
banking/src/components/ui/checkbox.tsx
Normal file
44
banking/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
size = "md",
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root> & { size?: "sm" | "md" }) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border data-[state=checked]:text-ink-white shrink-0 transition-shadow outline-none align-middle",
|
||||
"rounded-[4px]",
|
||||
"border-ink-gray-4 data-[state=checked]:bg-ink-gray-8 data-[state=checked]:border-ink-gray-8",
|
||||
// Hover state
|
||||
"hover:border-ink-gray-5 hover:shadow-checkbox-hover hover:data-[state=checked]:bg-ink-gray-7 hover:data-[state=checked]:border-ink-gray-7",
|
||||
// Active state
|
||||
"active:border-ink-gray-6 active:data-[state=checked]:bg-ink-gray-6 active:data-[state=checked]:border-ink-gray-6",
|
||||
// Focus state
|
||||
"focus-visible:border-ink-gray-8 focus-visible:shadow-focus-gray focus-visible:data-[state=checked]:bg-ink-gray-8 focus-visible:data-[state=checked]:border-ink-gray-8",
|
||||
// Disabled state
|
||||
"disabled:border-ink-gray-3 disabled:bg-surface-gray-1 disabled:cursor-not-allowed disabled:data-[state=checked]:bg-surface-gray-3 disabled:data-[state=checked]:border-surface-gray-3 disabled:text-ink-gray-4",
|
||||
// Invalid state
|
||||
"aria-invalid:border-red-500",
|
||||
size === "sm" ? "size-3.5" : "size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className={size === 'sm' ? "size-2.5" : "size-3"} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
183
banking/src/components/ui/command.tsx
Normal file
183
banking/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-surface-modal flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-ink-gray-4 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex items-center gap-2 m-1.5 h-8 rounded px-2.5 py-2 border border-transparent transition-all bg-surface-gray-2 not-focus-within:hover:bg-surface-gray-3 text-ink-gray-7 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-focus-gray"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 text-ink-gray-4" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"flex w-full bg-transparent outline-hidden text-base placeholder:text-ink-gray-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-ink-gray-6 [&_[cmdk-group-heading]]:text-ink-gray-4 overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-outline-gray-modals mx-0.5 h-px my-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"py-1.5 px-2 flex cursor-default text-ink-gray-6 items-center gap-2 rounded text-base relative outline-hidden select-none",
|
||||
"data-[selected=true]:bg-surface-gray-2 [&_svg:not([class*='text-'])]:text-ink-gray-6 data-[disabled=true]:pointer-events-none data-[disabled=true]:text-ink-gray-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-ink-gray-5 ms-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
156
banking/src/components/ui/dialog.tsx
Normal file
156
banking/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black-200 dark:bg-black-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-surface-modal shadow-xl rounded-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-200 outline-none sm:max-w-lg max-h-[90vh] overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="data-[state=open]:bg-surface-gray-1 data-[state=open]:text-ink-gray-8 absolute top-4 ltr:right-4 rtl:left-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 sm:text-start", 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 asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-2xl leading-6 text-ink-gray-8 font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-ink-gray-7 text-p-base", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
20
banking/src/components/ui/direction.tsx
Normal file
20
banking/src/components/ui/direction.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Direction } from "radix-ui"
|
||||
|
||||
function DirectionProvider({
|
||||
dir,
|
||||
direction,
|
||||
children,
|
||||
}: React.ComponentProps<typeof Direction.DirectionProvider> & {
|
||||
direction?: React.ComponentProps<typeof Direction.DirectionProvider>["dir"]
|
||||
}) {
|
||||
return (
|
||||
<Direction.DirectionProvider dir={direction ?? dir}>
|
||||
{children}
|
||||
</Direction.DirectionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const useDirection = Direction.useDirection
|
||||
|
||||
export { DirectionProvider, useDirection }
|
||||
262
banking/src/components/ui/dropdown-menu.tsx
Normal file
262
banking/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-surface-modal min-w-32 rounded-lg p-1 shadow-xl",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
const BASE_ITEM_STYLES = `outline-hidden select-none relative flex cursor-default items-center
|
||||
gap-2 rounded px-2 py-1.5 text-base text-ink-gray-6 data-[variant=destructive]:text-ink-red-3
|
||||
data-[variant=destructive]:*:[svg]:text-ink-red-3! [&_svg:not([class*='text-'])]:text-ink-gray-6 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0
|
||||
data-disabled:pointer-events-none data-disabled:text-ink-gray-3 data-disabled:*:[svg]:text-ink-gray-3! focus:bg-surface-gray-2 data-inset:ps-8`
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
BASE_ITEM_STYLES,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
BASE_ITEM_STYLES,
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="pointer-events-none flex size-4 ms-2 px-2 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
BASE_ITEM_STYLES,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="pointer-events-none flex size-4 ps-2 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium text-ink-gray-4 data-inset:ps-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-outline-gray-modals my-1 h-px mx-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-ink-gray-5 ms-auto text-xs tabular-nums",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
BASE_ITEM_STYLES,
|
||||
"data-[state=open]:bg-surface-gray-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ms-auto cn-rtl-flip size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-surface-modal rounded-lg p-1 shadow-xl min-w-32 text-ink-gray-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
85
banking/src/components/ui/empty.tsx
Normal file
85
banking/src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 min-h-64 flex-1 flex-col items-center justify-center gap-3 rounded-lg p-6 text-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
className={cn("flex justify-center items-center shrink-0 [&_svg]:pointer-events-none [&_svg]:shrink-0 bg-transparent size-7.5 [&_svg:not([class*='size-'])]:size-7.5 [&_svg:not([class*='text-'])]:text-ink-gray-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium text-ink-gray-7", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-center text-p-base text-ink-gray-6 [&>a:hover]:text-ink-gray-7 [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
64
banking/src/components/ui/error-banner.tsx
Normal file
64
banking/src/components/ui/error-banner.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { getErrorMessages } from '@/lib/frappe'
|
||||
import { FrappeError } from 'frappe-react-sdk'
|
||||
import { Alert, AlertDescription, AlertProps, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import MarkdownRenderer from '@/components/ui/markdown'
|
||||
import _ from '@/lib/translate'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type ErrorBannerProps = AlertProps & {
|
||||
error?: FrappeError | null,
|
||||
overrideHeading?: string,
|
||||
}
|
||||
|
||||
interface ParsedErrorMessage {
|
||||
message: string,
|
||||
title?: string,
|
||||
indicator?: string,
|
||||
}
|
||||
|
||||
const parseHeading = (message?: ParsedErrorMessage) => {
|
||||
if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
|
||||
return message?.title
|
||||
}
|
||||
|
||||
const wrapLooseListItemsWithUl = (html: string): string => {
|
||||
// Regex matches consecutive <li>...</li> blocks not wrapped in <ul> or <ol>
|
||||
// It wraps them in a <ul> if not already wrapped.
|
||||
return html.replace(/(?:^|[^>])((<li[\s\S]*?<\/li>)+)(?![\s\S]*?<\/ul>)(?![\s\S]*?<\/ol>)/g, (match, p1) => {
|
||||
// Check if the match already has <ul> or <ol> wrapping (simple check)
|
||||
if (/^<ul>/.test(p1) || /^<ol>/.test(p1)) {
|
||||
return match // Already wrapped, keep as is
|
||||
}
|
||||
return match.replace(p1, `<ul>${p1}</ul>`)
|
||||
})
|
||||
}
|
||||
|
||||
const ErrorBanner = ({ error, overrideHeading, ...props }: ErrorBannerProps) => {
|
||||
|
||||
|
||||
//exc_type: "ValidationError" or "PermissionError" etc
|
||||
// exc: With entire traceback - useful for reporting maybe
|
||||
// httpStatus and httpStatusText - not needed
|
||||
// _server_messages: Array of messages - useful for showing to user
|
||||
// console.log(JSON.parse(error?._server_messages!))
|
||||
|
||||
const messages = useMemo(() => {
|
||||
return getErrorMessages(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<Alert theme={messages[0]?.indicator === 'yellow' ? 'amber' : "red"} {...props}>
|
||||
<AlertCircle />
|
||||
<AlertTitle>{overrideHeading ?? parseHeading(messages[0])}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{messages.map((m, i) => {
|
||||
const safeMessage = wrapLooseListItemsWithUl(m.message)
|
||||
return <MarkdownRenderer content={safeMessage} key={i} />
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorBanner
|
||||
289
banking/src/components/ui/file-dropzone.tsx
Normal file
289
banking/src/components/ui/file-dropzone.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import _ from '@/lib/translate'
|
||||
import { Dispatch, SetStateAction, useCallback } from 'react'
|
||||
import { Accept, useDropzone } from 'react-dropzone'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatBytes, getFileExtension } from '@/lib/file'
|
||||
import { Button } from './button'
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
files: File[],
|
||||
setFiles?: Dispatch<SetStateAction<File[]>>
|
||||
accept?: Accept,
|
||||
multiple?: boolean
|
||||
onDrop?: (acceptedFiles: File[]) => void,
|
||||
onUpdate?: VoidFunction
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const FileDropzone = ({ files, setFiles, accept, multiple = true, onDrop, className, onUpdate }: Props) => {
|
||||
|
||||
const onFileDrop = useCallback((acceptedFiles: File[]) => {
|
||||
// Do something with the files
|
||||
if (multiple) {
|
||||
setFiles?.((prev) => [...prev, ...acceptedFiles])
|
||||
} else {
|
||||
setFiles?.(acceptedFiles)
|
||||
}
|
||||
onDrop?.(acceptedFiles)
|
||||
onUpdate?.()
|
||||
|
||||
}, [setFiles, onDrop, multiple, onUpdate])
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop: onFileDrop, accept, multiple })
|
||||
return (
|
||||
<div {...getRootProps()} className={cn('border border-outline-gray-2 border-dashed p-4 rounded bg-surface-gray-1 focus-within:bg-surface-gray-2 focus-within:border-outline-gray-4 focus-within:outline-none', className)}>
|
||||
<input {...getInputProps()} />
|
||||
{files.length === 0 ? <p className='text-sm text-ink-gray-5 text-center h-8 flex items-center justify-center'>{multiple ? _("Drop some files here, or click to select files") : _("Drop a file here, or click to select a file")}</p> : null}
|
||||
<div className='flex flex-col gap-4'>
|
||||
{files.map(f => <div key={f.name} className='flex justify-between items-center'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileTypeIcon fileType={getFileExtension(f.name)} size='sm' />
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='text-ink-gray-7 text-sm'>{f.name}</span>
|
||||
<span className='text-ink-gray-5 text-xs'>{formatBytes(f.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type='button' variant='ghost' isIconButton
|
||||
className='text-ink-gray-5 hover:text-ink-gray-8 hover:bg-transparent'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFiles?.(files.filter(file => file.name !== f.name))
|
||||
onUpdate?.()
|
||||
}}>
|
||||
<Trash2Icon className='w-4 h-4' />
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTypeIconProps {
|
||||
fileType: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
showBackground?: boolean
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16'
|
||||
}
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'h-5 w-5',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
xl: 'h-10 w-10'
|
||||
}
|
||||
|
||||
// Special sizing for PowerPoint icon due to different viewBox
|
||||
const pptIconSizeClasses = {
|
||||
sm: 'h-3.5 w-3.5',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
xl: 'h-6 w-6'
|
||||
}
|
||||
|
||||
export const FileTypeIcon = ({
|
||||
fileType,
|
||||
size = 'md',
|
||||
className,
|
||||
showBackground = true
|
||||
}: FileTypeIconProps) => {
|
||||
|
||||
|
||||
const containerClass = cn(sizeClasses[size], className)
|
||||
|
||||
const RenderIcon = ({ className }: { className?: string }) => {
|
||||
switch (fileType.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M7 22.9c.1-.6.5-1 .9-1.4.5-.5 1.1-.8 1.8-1.2.7-.4 1.4-.7 2.1-1 .1 0 .2-.1.2-.2.6-1.2 1.2-2.4 1.7-3.6.3-.7.5-1.4.8-2.1v-.1c-.3-.7-.6-1.5-.7-2.3-.2-.8-.2-1.6-.1-2.4.1-.5.4-.9.8-1.3.1-.1.3-.1.5-.1h.8c.2 0 .4.1.5.3.3.2.5.5.7.8.2.4.2.8.3 1.2 0 1.2-.2 2.3-.4 3.4-.1.4-.2.7-.3 1.1v.1c.6 1.1 1.4 2.1 2.2 3 .1.1.1.1.3.1 1.1-.2 2.2-.2 3.2-.2.6 0 1.3.1 1.9.4.3.2.6.4.8.7.1.2.2.4.2.6v.7c0 .2-.1.4-.3.5-.2.2-.4.5-.8.5-.2 0-.5.1-.7.1-1.6.1-2.9-.4-4.2-1.3-.2-.2-.5-.4-.7-.6-.1 0-.1-.1-.2-.1-.6.1-1.2.2-1.8.4-.8.2-1.6.5-2.4.7-.1 0-.1.1-.2.1-.5.9-1.1 1.8-1.7 2.6-.5.6-1.1 1.2-1.7 1.7-.3.2-.7.4-1.1.5h-.8c-.2 0-.3 0-.5-.1-.5-.2-.9-.6-1-1.1-.1 0-.1-.2-.1-.4zm8.8-7c-.3.8-.7 1.6-1 2.4l2.4-.6c-.5-.6-1-1.3-1.4-1.8zm4.3 2.6c.6.4 1.3.7 2 .9.3.1.5 0 .7-.1.2-.1.3-.4.1-.5 0-.1-.1-.1-.2-.1-.2-.1-.5-.1-.8-.2-.6-.1-1.2-.1-1.8 0zm-9.4 2.8s-.1 0 0 0c-.6.3-1.2.7-1.7 1.1-.3.2-.5.5-.7.8v.2c.1.1.1.1.2.1.3-.2.5-.4.7-.5.6-.5 1-1.1 1.5-1.7zM15 11.2c.1 0 .1 0 0 0 .2-.6.3-1.2.3-1.7 0-.3 0-.6-.1-.9 0-.1-.1-.1-.2-.1s-.1.1-.2.1c-.2.3-.2.6-.2 1 0 .3 0 .5.1.8.2.2.2.5.3.8z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M26 11.4V8.8c0-.4-.3-.8-.8-.8h-7.3V6.2h-1.4c-.2 0-.3.1-.5.1-.7.1-1.4.3-2.1.4-.7.1-1.4.2-2 .4-.7.1-1.4.2-2.2.4-.8.1-1.5.2-2.2.3-.5.1-1 .2-1.4.2H6v15.9c.8.1 1.6.3 2.4.4.8.1 1.7.3 2.5.4.8.1 1.6.3 2.4.4.8.1 1.7.3 2.5.5.3.1.7.1 1 .1h.9V24c0-.1 0-.1.1-.1h7.3c.1 0 .3 0 .4-.1.2 0 .3-.1.3-.3 0-.2.1-.3.1-.5V11.4c.1.1.1.1.1 0zm-11 1.5l-.9 3.9c-.2.7-.3 1.4-.5 2.2 0 .1-.1.1-.1.1-.2.1-.4 0-.6 0h-.6c-.1 0-.1 0-.1-.1-.1-.6-.3-1.3-.4-1.9-.2-.8-.3-1.6-.5-2.4 0 .2-.1.4-.1.6l-.6 3c0 .2-.1.5-.1.7 0 .1 0 .1-.1.1-.4 0-.8-.1-1.2-.1-.1 0-.1 0-.1-.1-.3-1.6-.6-3.2-1-4.9-.1-.3-.1-.7-.2-1v-.1h1.2c.2 1.4.5 2.8.7 4.3 0-.2.1-.4.1-.6.3-1.2.5-2.5.8-3.7 0-.1 0-.1.1-.1h1c.2 0 .2 0 .3.2.3 1.4.6 2.8.9 4.3v.1c.1-.8.3-1.6.4-2.4.1-.7.3-1.5.4-2.2 0 0 0-.1.1-.1.4 0 .8 0 1.3-.1h.1c-.2 0-.3.2-.3.3zm10.3-4.1s0 .1 0 0v14.5h-7.5v-1.8h5.9v-.9H18c-.1 0-.1 0-.1-.1v-.9c0-.1 0-.1.1-.1h5.8v-.9h-5.9v-1.1h5.8v-.9h-5.9v-1h5.8c.1 0 .1 0 .1-.1v-.7c0-.1 0-.1-.1-.1H18c-.1 0-.1 0-.1-.1v-1h5.9v-.9h-5.7c-.1 0-.1 0-.1-.1v-.9c0-.1 0-.1.1-.1h5.7v-.9h-5.9v-1.2h5.8c.1 0 .1 0 .1-.1v-.7c0-.1 0-.1-.1-.1h-5.9V9c0-.1 0-.1.1-.1h7.3c.1-.2.1-.2.1-.1z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M26 9.3v13.6c0 .1-.1.2-.1.3-.2.3-.5.5-.8.5h-7.7v2c-.3-.1-.7-.1-1-.2-.7-.1-1.5-.3-2.2-.4-.8-.1-1.6-.3-2.4-.4-.8-.1-1.6-.3-2.4-.4-.7-.1-1.5-.3-2.2-.4-.4-.1-.7-.1-1.1-.2V9c.1 0 .3-.1.4-.1.7-.5 1.5-.7 2.3-.8.7-.1 1.4-.3 2-.4.6-.1 1.3-.2 1.9-.4.7-.1 1.5-.3 2.2-.4.8-.1 1.5-.3 2.3-.4h.1v1.9h7.8c.4 0 .8.3.9.7v.2zm-.8-.1h-7.9v1.2H20v1.7h-2.7v.6H20v1.7h-2.7v.6H20v1.7h-2.7v.7h2.8v1.7h-2.8v.6H20v1.7h-2.7v1.2h7.9V9.2zM14.7 20.7s0-.1-.1-.1c-.7-1.4-1.5-2.8-2.2-4.2v-.2c.7-1.4 1.4-2.7 2.2-4.1V12h-.1c-.2 0-.5 0-.7.1-.3 0-.6 0-1 .1-.1 0-.1 0-.1.1-.3.6-.5 1.1-.8 1.7-.2.5-.4.9-.6 1.4-.1-.2-.1-.5-.2-.7-.3-.7-.6-1.5-.9-2.2-.1-.2-.1-.2-.3-.2-.4 0-.8.1-1.2.1h-.4v.1c.1.2.2.5.3.7l1.5 3v.1c-.6 1.2-1.3 2.4-1.9 3.6 0 .1-.1.1-.1.2h.6c.4 0 .7.1 1.1.1.1 0 .1 0 .1-.1.3-.6.6-1.2.9-1.9.1-.3.3-.6.4-.9 0-.1 0-.2.1-.3v.1c.1.2.1.4.2.5.4.8.7 1.6 1.1 2.5.1.1.1.2.3.2.5 0 1 .1 1.5.1.1.3.2.3.3.3z" fill="currentColor" />
|
||||
<path d="M23.9 10.4v1.7h-3.1v-1.7h3.1zm-3.1 11.2v-1.7h3.1v1.7h-3.1zm0-4.7v-1.7h3.1v1.7h-3.1zm3.1-4.1v1.7h-3.1v-1.7h3.1zm0 4.8v1.7h-3.1v-1.7h3.1z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 116.03" className={cn("text-white", pptIconSizeClasses[size], className)}>
|
||||
<g>
|
||||
<path d="M0.38,12.11L69.16,0.09L69.69,0v0.54v114.96v0.53l-0.53-0.09L0.38,104.63L0,104.57v-0.38V12.55v-0.38L0.38,12.11 L0.38,12.11z M76.29,17.01h43.79c0.77,0,1.47,0.32,1.98,0.82c0.51,0.51,0.82,1.21,0.82,1.98v76.75c0,0.78-0.32,1.5-0.84,2.01 s-1.23,0.84-2.01,0.84H76.29h-0.45v-0.45v-9.16v-0.45h0.45h33.62v-6.15H76.29h-0.45v-0.45v-7.17V75.1h0.45h33.62v-6.15H76.29h-0.45 v-0.45v-8.49v-0.88l0.71,0.51c1.32,0.94,2.79,1.68,4.36,2.18c1.52,0.48,3.14,0.74,4.82,0.74c4.38,0,8.34-1.78,11.21-4.64 c2.82-2.82,4.59-6.7,4.64-11H85.83h-0.45v-0.45V30.86c-1.56,0.03-3.06,0.29-4.47,0.74c-1.57,0.5-3.04,1.24-4.36,2.18l-0.71,0.51 v-0.88V17.46v-0.45H76.29L76.29,17.01z M99.26,32.75c-2.76-2.77-6.54-4.52-10.73-4.65v15.48h15.36 C103.79,39.35,102.04,35.53,99.26,32.75L99.26,32.75z M30.91,80.41V63.97v-0.45h0.45h6.22c2.41,0,4.56-0.35,6.45-1.05 c1.87-0.7,3.49-1.75,4.86-3.15c1.37-1.4,2.39-3.04,3.08-4.91c0.69-1.88,1.03-4,1.03-6.37c0-1.61-0.16-3.12-0.48-4.55 c-0.32-1.42-0.79-2.76-1.43-4.01c-0.63-1.25-1.4-2.36-2.29-3.32c-0.89-0.96-1.91-1.78-3.06-2.45c-2.31-1.35-4.97-2.03-7.98-2.03 H22.07v48.75H30.91L30.91,80.41z M37.76,55.2h-6.39h-0.45v-0.45V40.43v-0.45h0.45h6.51l0.01,0c0.95,0.01,1.81,0.21,2.57,0.59 c0.76,0.38,1.41,0.95,1.96,1.71h0c0.54,0.74,0.95,1.6,1.21,2.58c0.27,0.97,0.4,2.05,0.4,3.24c0,1.1-0.13,2.08-0.39,2.94h0 c-0.27,0.88-0.67,1.63-1.21,2.26c-0.54,0.63-1.21,1.11-2,1.43C39.65,55.05,38.76,55.2,37.76,55.2L37.76,55.2z" fill="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'video':
|
||||
case 'mp4':
|
||||
case 'mov':
|
||||
case 'mkv':
|
||||
case 'avi':
|
||||
case 'webm':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M16 4c-6.6 0-12 5.4-12 12s5.4 12 12 12 12-5.4 12-12S22.6 4 16 4zm-2 16.5V9.5l8 5.5-8 5.5z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
case 'audio':
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'ogg':
|
||||
case 'flac':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M16 4c-6.6 0-12 5.4-12 12s5.4 12 12 12 12-5.4 12-12S22.6 4 16 4zm-2 16.5V9.5l8 5.5-8 5.5z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
case 'image':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M26 4H6c-1.1 0-2 .9-2 2v20c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM6 26V6h20v20H6z" fill="currentColor" />
|
||||
<path d="M10 12c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm12 8H10l4-6 3 4 2-3 7 5z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M26 4H6c-1.1 0-2 .9-2 2v20c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM6 26V6h20v20H6z" fill="currentColor" />
|
||||
<path d="M10 8h12v2H10V8zm0 4h12v2H10v-2zm0 4h12v2H10v-2z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className={cn("text-white", iconSizeClasses[size], className)}>
|
||||
<path d="M18 22a2 2 0 0 0 2-2V8l-6-6H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12zM13 4l5 5h-5V4zM7 8h3v2H7V8zm0 4h10v2H7v-2zm0 4h10v2H7v-2z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
switch (fileType.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return 'bg-red-700'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'bg-[#1A5CBD]'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return 'bg-green-700'
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'bg-[#ED6C47]'
|
||||
case 'video':
|
||||
case 'mp4':
|
||||
case 'mov':
|
||||
case 'mkv':
|
||||
case 'avi':
|
||||
case 'webm':
|
||||
return 'bg-purple-600'
|
||||
case 'audio':
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'ogg':
|
||||
case 'flac':
|
||||
return 'bg-purple-600'
|
||||
case 'image':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return 'bg-blue-600'
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
return 'bg-yellow-600'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const getTextColor = () => {
|
||||
switch (fileType.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return 'text-red-700'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'text-[#1A5CBD]'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return 'text-green-700 dark:text-green-500'
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'text-[#ED6C47]'
|
||||
case 'video':
|
||||
case 'mp4':
|
||||
case 'mov':
|
||||
case 'mkv':
|
||||
case 'avi':
|
||||
case 'webm':
|
||||
return 'text-purple-600'
|
||||
case 'audio':
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'ogg':
|
||||
case 'flac':
|
||||
return 'text-purple-600'
|
||||
case 'image':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return 'text-blue-600'
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
return 'text-yellow-600'
|
||||
default:
|
||||
return 'text-gray-50'
|
||||
}
|
||||
}
|
||||
|
||||
if (showBackground) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center", getBackgroundColor(), containerClass)}>
|
||||
<RenderIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center")}>
|
||||
<RenderIcon className={getTextColor()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
383
banking/src/components/ui/form-elements.tsx
Normal file
383
banking/src/components/ui/form-elements.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { FieldValues, RegisterOptions, useFormContext } from "react-hook-form"
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormRequiredIndicator, useFormField } from "@/components/ui/form"
|
||||
import _ from "@/lib/translate"
|
||||
import { Input } from "./input"
|
||||
import { ComponentProps, FocusEventHandler, useCallback, useState } from "react"
|
||||
import { parseDate } from "chrono-node"
|
||||
import { formatDate, getUserDateFormat, toDate } from "@/lib/date"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
||||
import { Button } from "./button"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
import { Calendar } from "./calendar"
|
||||
import dayjs from "dayjs"
|
||||
import { Textarea } from "./textarea"
|
||||
import AccountsDropdown, { AccountsDropdownProps } from "../common/AccountsDropdown"
|
||||
import PartyTypeDropdown, { PartyTypeDropdownProps } from "../common/PartyTypeDropdown"
|
||||
import CurrencyInput from "react-currency-input-field"
|
||||
import { getSystemDefault } from "@/lib/frappe"
|
||||
import { getCurrencySymbol } from "@/lib/currency"
|
||||
import { getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
import LinkFieldCombobox, { LinkFieldComboboxProps } from "../common/LinkFieldCombobox"
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from "./select"
|
||||
import { InputGroup, InputGroupAddon } from "./input-group"
|
||||
|
||||
interface FormElementProps {
|
||||
name: string,
|
||||
rules?: Omit<RegisterOptions<FieldValues, string>, "disabled" | "valueAsNumber" | "valueAsDate" | "setValueAs">,
|
||||
label: string,
|
||||
isRequired?: boolean,
|
||||
disabled?: boolean,
|
||||
formDescription?: string,
|
||||
hideLabel?: boolean,
|
||||
readOnly?: boolean,
|
||||
|
||||
}
|
||||
|
||||
interface DataFieldProps extends FormElementProps {
|
||||
inputProps?: Omit<ComponentProps<"input">, "value" | "onChange" | "onBlur" | "name" | "ref">
|
||||
}
|
||||
|
||||
export const DataField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled, readOnly }: DataFieldProps) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
return <FormField
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} maxLength={140} aria-readonly={readOnly} readOnly={readOnly} {...inputProps} />
|
||||
</FormControl>
|
||||
{formDescription && <FormDescription>{formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
interface SelectFieldProps extends FormElementProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const SelectFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, children, disabled, readOnly }: SelectFieldProps) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
|
||||
return <FormField
|
||||
control={control}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value} disabled={disabled || readOnly} aria-readonly={readOnly}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{children}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{formDescription && <FormDescription>{formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
interface DateFieldProps extends FormElementProps {
|
||||
inputProps?: Omit<ComponentProps<"input">, "value" | "onChange" | "onBlur" | "name" | "ref">
|
||||
}
|
||||
|
||||
export const DateField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled }: DateFieldProps) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
|
||||
const DatePicker = ({ field }: { field: FieldValues }) => {
|
||||
|
||||
const userDateFormat = getUserDateFormat()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [value, setValue] = useState<string | undefined>(field.value ? formatDate(field.value) : undefined)
|
||||
|
||||
const date = field.value ? toDate(field.value) : undefined
|
||||
|
||||
return <div className="relative flex gap-2">
|
||||
<FormControl>
|
||||
<Input className="pe-10"
|
||||
name={field.name}
|
||||
onBlur={() => {
|
||||
setValue(formatDate(field.value))
|
||||
field.onBlur()
|
||||
}}
|
||||
placeholder={userDateFormat}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
if (e.target.value) {
|
||||
// On change in value, try computing date usning standard formats first
|
||||
const dateObj = toDate(e.target.value, userDateFormat)
|
||||
// If we find a valid date, use it
|
||||
if (dateObj && !isNaN(dateObj.getTime())) {
|
||||
field.onChange(formatDate(dateObj, "YYYY-MM-DD"))
|
||||
} else {
|
||||
// If not, try parsing using chrono-node for things like "1st July 2025"
|
||||
const date = parseDate(e.target.value)
|
||||
if (date) {
|
||||
field.onChange(formatDate(date, "YYYY-MM-DD"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field.onChange("")
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
maxLength={140}
|
||||
{...inputProps} />
|
||||
</FormControl>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date-picker-button"
|
||||
variant="ghost"
|
||||
className="absolute top-1/2 ltr:right-2 rtl:left-2 size-6 -translate-y-1/2"
|
||||
>
|
||||
<CalendarIcon className="size-3.5" />
|
||||
<span className="sr-only">{_("Select date")}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
fixedWeeks
|
||||
endMonth={dayjs().add(1, "year").toDate()}
|
||||
captionLayout="dropdown"
|
||||
defaultMonth={date}
|
||||
onSelect={(date) => {
|
||||
setValue(formatDate(date))
|
||||
field.onChange(formatDate(date, "YYYY-MM-DD"))
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <FormField
|
||||
control={control}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
<DatePicker field={field} />
|
||||
{formDescription && <FormDescription>{formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
interface SmallTextFieldProps extends FormElementProps {
|
||||
inputProps?: Omit<ComponentProps<"textarea">, "value" | "onChange" | "onBlur" | "name" | "ref">
|
||||
}
|
||||
|
||||
export const SmallTextField = ({ name, rules, label, isRequired, formDescription, inputProps, hideLabel, disabled, readOnly }: SmallTextFieldProps) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
return <FormField
|
||||
control={control}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} {...inputProps} readOnly={readOnly} aria-readonly={readOnly} />
|
||||
</FormControl>
|
||||
{formDescription && <FormDescription>{formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
interface AccountFormFieldProps extends Omit<AccountsDropdownProps, 'value' | 'onChange'>, FormElementProps {
|
||||
}
|
||||
export const AccountFormField = (props: AccountFormFieldProps) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
|
||||
return <FormField
|
||||
control={control}
|
||||
disabled={props.disabled}
|
||||
name={props.name}
|
||||
rules={props.rules}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel className={props.hideLabel ? 'sr-only' : ''}>{props.label}{props.isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
<AccountsDropdown {...props} value={field.value} onChange={field.onChange} useInForm readOnly={props.readOnly} />
|
||||
{props.formDescription && <FormDescription>{props.formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
interface PartyTypeFormField extends FormElementProps {
|
||||
inputProps?: Omit<PartyTypeDropdownProps, 'value' | 'onChange'>
|
||||
}
|
||||
|
||||
export const PartyTypeFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, inputProps, disabled, readOnly }: PartyTypeFormField) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
|
||||
return <FormField
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
<PartyTypeDropdown {...inputProps} value={field.value} onChange={field.onChange} useInForm readOnly={readOnly} />
|
||||
{formDescription && <FormDescription>{formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
interface CurrencyFormFieldProps extends FormElementProps {
|
||||
currency?: string,
|
||||
style?: React.CSSProperties,
|
||||
leftSlot?: React.ReactNode,
|
||||
}
|
||||
|
||||
export const CurrencyFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, currency, disabled, readOnly, style = {}, leftSlot }: CurrencyFormFieldProps) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
|
||||
const defaultCurrency = getSystemDefault("currency")
|
||||
const currencySymbol = getCurrencySymbol(currency ?? defaultCurrency)
|
||||
|
||||
|
||||
const CurrencyField = ({ field }: { field: FieldValues }) => {
|
||||
|
||||
const onFocus: FocusEventHandler<HTMLInputElement> = useCallback((e) => {
|
||||
// When the input is focused, select the text
|
||||
// A short timeout is needed so that the input selects the text after the focus event
|
||||
setTimeout(() => {
|
||||
// Check if the input is focused - do not select text if the input is not focused
|
||||
if (e.target.contains(document.activeElement)) {
|
||||
e.target.select()
|
||||
}
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const { formItemId } = useFormField()
|
||||
|
||||
// Get the correct separators for the currency
|
||||
const formatInfo = getCurrencyFormatInfo(currency ?? defaultCurrency)
|
||||
const groupSeparator = formatInfo.group_sep || ","
|
||||
const decimalSeparator = formatInfo.decimal_str || "."
|
||||
|
||||
return <CurrencyInput
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
...style
|
||||
}}
|
||||
id={formItemId}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
readOnly={readOnly}
|
||||
aria-readonly={readOnly}
|
||||
onFocus={onFocus}
|
||||
groupSeparator={groupSeparator}
|
||||
decimalSeparator={decimalSeparator}
|
||||
placeholder={`${currencySymbol} 0${decimalSeparator}00`}
|
||||
decimalsLimit={2}
|
||||
value={field.value}
|
||||
maxLength={12}
|
||||
decimalScale={2}
|
||||
prefix={currencySymbol + " "}
|
||||
onValueChange={(v, _n, values) => {
|
||||
// If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals.
|
||||
// When the user eventually types the decimals or blurs out, the value is formatted anyway.
|
||||
// Otherwise store the float value
|
||||
// Check if the value ends with a decimal or a decimal with trailing zeroes
|
||||
const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0')
|
||||
const newValue = isDecimal ? v : values?.float ?? ''
|
||||
field.onChange(newValue)
|
||||
}}
|
||||
customInput={Input}
|
||||
/>
|
||||
}
|
||||
|
||||
return <FormField
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<InputGroup>
|
||||
{leftSlot && <InputGroupAddon>{leftSlot}</InputGroupAddon>}
|
||||
<CurrencyField field={field} />
|
||||
</InputGroup>
|
||||
|
||||
</FormControl>
|
||||
{formDescription && <FormDescription>{formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
interface LinkFormFieldProps extends FormElementProps, Omit<LinkFieldComboboxProps, 'value' | 'onChange'> {
|
||||
}
|
||||
|
||||
export const LinkFormField = ({ name, rules, label, isRequired, formDescription, hideLabel, disabled, readOnly, ...inputProps }: LinkFormFieldProps) => {
|
||||
|
||||
const { control } = useFormContext()
|
||||
|
||||
return <FormField
|
||||
control={control}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel className={hideLabel ? 'sr-only' : ''}>{label}{isRequired && <FormRequiredIndicator />}</FormLabel>
|
||||
<LinkFieldCombobox {...inputProps} value={field.value} onChange={field.onChange} useInForm disabled={disabled} readOnly={readOnly} />
|
||||
{formDescription && <FormDescription>{formDescription}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
174
banking/src/components/ui/form.tsx
Normal file
174
banking/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"
|
||||
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={className}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormRequiredIndicator({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span className={cn("text-ink-red-2", className)} {...props}>
|
||||
*
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof SlotPrimitive.Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<SlotPrimitive.Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-ink-gray-5 text-p-base", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-ink-red-4 text-p-base", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
FormRequiredIndicator,
|
||||
}
|
||||
42
banking/src/components/ui/hover-card.tsx
Normal file
42
banking/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"rounded-lg border bg-surface-modal shadow-xl text-ink-gray-8 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) p-4 outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
161
banking/src/components/ui/input-group.tsx
Normal file
161
banking/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const inputGroupVariants = cva(cn("group/input-group relative flex w-full items-center outline-none min-w-0 border border-transparent transition-all",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:ps-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pe-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input]:focus-visible]:bg-surface-white has-[[data-slot=input]:focus-visible]:border-outline-gray-4 has-[[data-slot=input]:focus-visible]:shadow-focus-gray",
|
||||
|
||||
// Disabled state
|
||||
"has-[>[data-slot=input]:disabled]:bg-surface-gray-1 has-[>[data-slot=input]:disabled]:text-ink-gray-3 has-[>[data-slot=input]:disabled]:cursor-not-allowed has-[>[data-slot=input]:disabled]:pointer-events-none",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:shadow-focus-red has-[[data-slot][aria-invalid=true]]:border-outline-red-3",
|
||||
|
||||
// Read only state
|
||||
"has-[[data-slot][aria-readonly=true]]:bg-surface-gray-1 has-[[data-slot][aria-readonly=true]]:text-ink-gray-6 has-[[data-slot][aria-readonly=true]]:pointer-events-none",
|
||||
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
subtle: "bg-surface-gray-2",
|
||||
outline: "bg-surface-white border-outline-gray-2"
|
||||
},
|
||||
size: {
|
||||
sm: "h-7 has-[>textarea]:h-auto rounded text-base",
|
||||
md: "h-8 has-[>textarea]:h-auto rounded text-base",
|
||||
lg: "h-10 has-[>textarea]:h-auto rounded-md text-lg"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "subtle",
|
||||
size: "md"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroup({ className, variant = "subtle", size = "md", ...props }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
role="group"
|
||||
className={cn(
|
||||
inputGroupVariants({ variant, size }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-ink-gray-5 flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first ps-3 has-[>button]:ms-[-0.45rem] has-[>kbd]:ms-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pe-3 has-[>button]:me-[-0.45rem] has-[>kbd]:me-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-ink-gray-5 flex items-center gap-2 text-sm whitespace-nowrap [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
}
|
||||
49
banking/src/components/ui/input.tsx
Normal file
49
banking/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
const inputVariants = cva(cn("flex w-full min-w-0 transition-all outline-none border border-transparent",
|
||||
"focus-visible:bg-surface-white focus-visible:border-outline-gray-4 focus-visible:shadow-focus-gray",
|
||||
"active:bg-surface-white active:shadow-sm active:border-outline-gray-4",
|
||||
"placeholder:text-ink-gray-4 text-ink-gray-7",
|
||||
"disabled:bg-surface-gray-1 disabled:placeholder:text-ink-gray-3 disabled:text-ink-gray-3 disabled:cursor-not-allowed disabled:pointer-events-none",
|
||||
"aria-readonly:bg-surface-gray-1 aria-readonly:text-ink-gray-6 aria-readonly:pointer-events-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
|
||||
"in-data-[slot=input-group]:border-transparent! in-data-[slot=input-group]:focus-visible:shadow-none! in-data-[slot=input-group]:bg-transparent!"),
|
||||
{
|
||||
variants: {
|
||||
inputSize: {
|
||||
sm: "text-base rounded py-1.5 px-2 h-7",
|
||||
md: "text-base rounded py-2 px-2.5 h-8",
|
||||
lg: "text-lg rounded-md py-[11px] px-3 h-10",
|
||||
},
|
||||
variant: {
|
||||
subtle: "bg-surface-gray-2 hover:bg-surface-gray-3 aria-invalid:bg-surface-red-1",
|
||||
outline: "bg-surface-white border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 disabled:border-outline-gray-2",
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
inputSize: "md",
|
||||
variant: "subtle"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function Input({ className, type, inputSize = "md", variant = "subtle", ...props }: React.ComponentProps<"input"> & VariantProps<typeof inputVariants>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
data-input-size={inputSize}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"file:text-ink-gray-8 file:inline-flex file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
inputVariants({ inputSize, variant }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
28
banking/src/components/ui/kbd.tsx
Normal file
28
banking/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"bg-surface-gray-2 py-0.5 text-ink-gray-5 pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-surface-gray-6 [[data-slot=tooltip-content]_&]:text-ink-gray-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
8
banking/src/components/ui/keyboard-keys.tsx
Normal file
8
banking/src/components/ui/keyboard-keys.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
export const KeyboardMetaKeyIcon = () => {
|
||||
if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
||||
return <span className="text-sm">⌘</span>
|
||||
} else {
|
||||
return <span>Ctrl</span>
|
||||
}
|
||||
}
|
||||
22
banking/src/components/ui/label.tsx
Normal file
22
banking/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-base text-ink-gray-5 select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
510
banking/src/components/ui/list-view.tsx
Normal file
510
banking/src/components/ui/list-view.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
type Cell,
|
||||
type ColumnDef,
|
||||
type ColumnSizingState,
|
||||
type Header,
|
||||
type OnChangeFn,
|
||||
type Row,
|
||||
type RowSelectionState,
|
||||
flexRender,
|
||||
functionalUpdate,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { useDebounceCallback } from "usehooks-ts"
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDirection } from "./direction"
|
||||
|
||||
/** Optional per-column layout hints for `ListView`. */
|
||||
export type ListViewColumnMeta = {
|
||||
/** CSS grid track (`1fr`, `2fr`, `minmax(0,1fr)`). When set, used instead of TanStack pixel `size` in `grid-template-columns`. */
|
||||
gridWidth?: string
|
||||
align?: "left" | "center" | "right"
|
||||
/**
|
||||
* Tabular figures for stable digit width. Default: on when `align` is `right` (amounts); set `false` to opt out, or `true` for dates/IDs.
|
||||
*/
|
||||
tabularNums?: boolean
|
||||
/**
|
||||
* Full text for an overflow tooltip (shown only when the cell truncates). If omitted, a string `accessorKey` value is used when available.
|
||||
*/
|
||||
getTooltipText?: (row: unknown) => string | null | undefined
|
||||
/** `false` disables the overflow tooltip for this column. */
|
||||
truncateTooltip?: boolean
|
||||
/**
|
||||
* `false` skips single-line truncation for cells with custom layouts (e.g. action buttons). Default `true`.
|
||||
*/
|
||||
truncate?: boolean
|
||||
}
|
||||
|
||||
function alignClass(meta: ListViewColumnMeta | undefined) {
|
||||
switch (meta?.align) {
|
||||
case "center":
|
||||
return "justify-center text-center"
|
||||
case "right":
|
||||
return "justify-end text-end"
|
||||
default:
|
||||
return "justify-start text-start"
|
||||
}
|
||||
}
|
||||
|
||||
function tabularNumsClass(meta: ListViewColumnMeta | undefined) {
|
||||
if (meta?.tabularNums === false) return ""
|
||||
if (meta?.tabularNums === true) return "tabular-nums"
|
||||
if (meta?.align === "right") return "tabular-nums"
|
||||
return ""
|
||||
}
|
||||
|
||||
function resolveTooltipLabel<TData>(
|
||||
row: Row<TData>,
|
||||
meta: ListViewColumnMeta | undefined,
|
||||
columnDef: ColumnDef<TData, unknown>,
|
||||
): string | undefined {
|
||||
if (meta?.truncateTooltip === false) return undefined
|
||||
const fromMeta = meta?.getTooltipText?.(row.original as unknown)
|
||||
if (fromMeta != null && String(fromMeta).length > 0) {
|
||||
return String(fromMeta)
|
||||
}
|
||||
const key = "accessorKey" in columnDef ? columnDef.accessorKey : undefined
|
||||
if (key !== undefined && key !== null && key !== "") {
|
||||
try {
|
||||
const v = row.getValue(String(key))
|
||||
if (v != null && v !== "") return String(v)
|
||||
} catch {
|
||||
/* column may not expose a value */
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ListViewCellBody<TData>({
|
||||
cell,
|
||||
row,
|
||||
meta,
|
||||
children,
|
||||
}: {
|
||||
cell: Cell<TData, unknown>
|
||||
row: Row<TData>
|
||||
meta: ListViewColumnMeta | undefined
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
const [overflowing, setOverflowing] = React.useState(false)
|
||||
const direction = useDirection()
|
||||
|
||||
const tooltipLabel = resolveTooltipLabel(row, meta, cell.column.columnDef)
|
||||
const tooltipAlign = meta?.align === "right" && direction === "ltr" ? "end" : "start"
|
||||
|
||||
const measure = React.useCallback(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
setOverflowing(el.scrollWidth > el.clientWidth + 1)
|
||||
}, [])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
measure()
|
||||
}, [measure, children, tooltipLabel])
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || typeof ResizeObserver === "undefined") return
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [measure])
|
||||
|
||||
if (meta?.truncate === false) {
|
||||
return <div className="min-w-0 flex-1 overflow-visible">{children}</div>
|
||||
}
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 flex-1 truncate",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!tooltipLabel || !overflowing) {
|
||||
return inner
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>{inner}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align={tooltipAlign}
|
||||
className="max-w-sm text-balance wrap-break-word"
|
||||
>
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function gridTemplateFromHeaders<TData>(headers: Header<TData, unknown>[]) {
|
||||
return headers
|
||||
.map((header) => {
|
||||
const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
|
||||
if (meta?.gridWidth) {
|
||||
return meta.gridWidth
|
||||
}
|
||||
return `${header.getSize()}px`
|
||||
})
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function defaultGetRowId<TData>(row: TData, index: number) {
|
||||
const r = row as Record<string, unknown>
|
||||
if (r && typeof r.name === "string") return r.name
|
||||
if (r && typeof r.id === "string") return r.id
|
||||
return String(index)
|
||||
}
|
||||
|
||||
export type ListViewProps<TData> = {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
/**
|
||||
* Stable row id for selection and keys. Defaults to `name`, then `id`, then row index (index is fragile if data order changes).
|
||||
*/
|
||||
getRowId?: (originalRow: TData, index: number) => string
|
||||
/** Pixel height of each body row (default 40, matches frappe-ui ListView). */
|
||||
rowHeight?: number
|
||||
className?: string
|
||||
/** Classes for the scrollable viewport (default includes max-height). */
|
||||
scrollAreaClassName?: string
|
||||
/** Max height of the scroll area; number is pixels. Default `420`. */
|
||||
maxHeight?: number | string
|
||||
emptyState?: React.ReactNode
|
||||
enableColumnResizing?: boolean
|
||||
columnSizing?: ColumnSizingState
|
||||
onColumnSizingChange?: OnChangeFn<ColumnSizingState>
|
||||
/** Debounced callback for persisting widths (e.g. localStorage). */
|
||||
onColumnSizingCommit?: (sizing: ColumnSizingState) => void
|
||||
columnSizingCommitDelayMs?: number
|
||||
enableRowSelection?: boolean
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>
|
||||
onRowClick?: (row: TData, event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
function ListViewInner<TData>({
|
||||
data,
|
||||
columns: userColumns,
|
||||
getRowId: getRowIdProp,
|
||||
rowHeight = 40,
|
||||
className,
|
||||
scrollAreaClassName,
|
||||
maxHeight = 420,
|
||||
emptyState,
|
||||
enableColumnResizing = true,
|
||||
columnSizing: controlledColumnSizing,
|
||||
onColumnSizingChange: controlledOnColumnSizingChange,
|
||||
onColumnSizingCommit,
|
||||
columnSizingCommitDelayMs = 250,
|
||||
enableRowSelection = false,
|
||||
rowSelection: controlledRowSelection,
|
||||
onRowSelectionChange: controlledOnRowSelectionChange,
|
||||
onRowClick,
|
||||
}: ListViewProps<TData>) {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const [internalColumnSizing, setInternalColumnSizing] = React.useState<ColumnSizingState>({})
|
||||
const columnSizing = controlledColumnSizing ?? internalColumnSizing
|
||||
|
||||
const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
|
||||
const rowSelection = controlledRowSelection ?? internalRowSelection
|
||||
const setRowSelection = controlledOnRowSelectionChange ?? setInternalRowSelection
|
||||
|
||||
const debouncedSizingCommit = useDebounceCallback(
|
||||
(sizing: ColumnSizingState) => {
|
||||
onColumnSizingCommit?.(sizing)
|
||||
},
|
||||
columnSizingCommitDelayMs,
|
||||
)
|
||||
|
||||
const selectionColumn = React.useMemo<ColumnDef<TData, unknown>>(
|
||||
() => ({
|
||||
id: "__list_view_select__",
|
||||
size: 36,
|
||||
minSize: 36,
|
||||
maxSize: 36,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
truncate: false,
|
||||
truncateTooltip: false,
|
||||
} satisfies ListViewColumnMeta,
|
||||
header: ({ table }) => (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<Checkbox
|
||||
aria-label="Select all rows"
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(value === true)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<Checkbox
|
||||
aria-label="Select row"
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(value === true)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const columns = React.useMemo(() => {
|
||||
if (!enableRowSelection) return userColumns
|
||||
return [selectionColumn, ...userColumns]
|
||||
}, [enableRowSelection, selectionColumn, userColumns])
|
||||
|
||||
const getRowId = React.useCallback(
|
||||
(originalRow: TData, index: number) =>
|
||||
(getRowIdProp ?? defaultGetRowId)(originalRow, index),
|
||||
[getRowIdProp],
|
||||
)
|
||||
|
||||
const onColumnSizingChangeInternal = React.useCallback<OnChangeFn<ColumnSizingState>>(
|
||||
(updater) => {
|
||||
if (controlledOnColumnSizingChange) {
|
||||
controlledOnColumnSizingChange(updater)
|
||||
return
|
||||
}
|
||||
setInternalColumnSizing((old) => {
|
||||
const next = functionalUpdate(updater, old)
|
||||
debouncedSizingCommit(next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[controlledOnColumnSizingChange, debouncedSizingCommit],
|
||||
)
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
defaultColumn: {
|
||||
minSize: 50,
|
||||
size: 150,
|
||||
},
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: direction,
|
||||
enableColumnResizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
onColumnSizingChange: onColumnSizingChangeInternal,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
columnSizing,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection,
|
||||
})
|
||||
|
||||
const headerGroup = table.getHeaderGroups()[0]
|
||||
const gridTemplateColumns = headerGroup
|
||||
? gridTemplateFromHeaders(headerGroup.headers)
|
||||
: ""
|
||||
|
||||
const { rows } = table.getRowModel()
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 10,
|
||||
})
|
||||
|
||||
const maxHeightStyle =
|
||||
typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-surface-gray-2 text-ink-gray-5 flex min-h-32 items-center justify-center rounded-md px-4 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{emptyState ?? "No data"}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tracks + column gaps + horizontal padding (`px-2` × 2) so header and body share one scroll width. */
|
||||
const colCount = headerGroup?.headers.length ?? 0
|
||||
const minTableOuterWidth =
|
||||
table.getCenterTotalSize() +
|
||||
Math.max(0, colCount - 1) * 16 +
|
||||
16
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-col", className)} role="grid">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={cn("min-h-0 overflow-auto", scrollAreaClassName)}
|
||||
style={{ maxHeight: maxHeightStyle }}
|
||||
>
|
||||
{headerGroup ? (
|
||||
<div
|
||||
className="bg-surface-gray-2 sticky top-0 z-10 mb-2 grid w-full items-center gap-x-4 rounded p-2"
|
||||
role="row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns,
|
||||
minWidth: `max(100%, ${minTableOuterWidth}px)`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"text-ink-gray-5 group relative flex min-w-0 items-center px-1 text-sm",
|
||||
alignClass(meta),
|
||||
)}
|
||||
role="columnheader"
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{enableColumnResizing && header.column.getCanResize() ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute ltr:right-0 rtl:left-0 z-1 w-0.5 bg-gray-400",
|
||||
"opacity-0 transition-[opacity,background-color] ease-in-out duration-150",
|
||||
"group-hover:opacity-100 group-hover:bg-gray-400",
|
||||
header.column.getIsResizing() && "bg-outline-gray-6 opacity-100",
|
||||
)}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize column"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
document.body.classList.add("select-none", "cursor-col-resize")
|
||||
const end = () => {
|
||||
document.body.classList.remove("select-none", "cursor-col-resize")
|
||||
window.removeEventListener("mouseup", end)
|
||||
window.removeEventListener("touchend", end)
|
||||
}
|
||||
window.addEventListener("mouseup", end)
|
||||
window.addEventListener("touchend", end)
|
||||
header.getResizeHandler()(e)
|
||||
}}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className="absolute top-0 ltr:right-0 rtl:left-0 z-10 h-full w-2 max-w-[12px] cursor-col-resize touch-none select-none bg-transparent"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minWidth: `max(100%, ${minTableOuterWidth}px)`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
if (!row) return null
|
||||
const leadDataColumnIndex = enableRowSelection ? 1 : 0
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
data-index={virtualRow.index}
|
||||
role="row"
|
||||
className={cn(
|
||||
"ease-in-out absolute top-0 ltr:left-0 rtl:right-0 w-full min-w-0 rounded px-2 transition-all duration-300",
|
||||
// virtualRow.index > 0 && "border-t border-outline-gray-1",
|
||||
!row.getIsSelected() && "hover:bg-surface-menu-bar",
|
||||
row.getIsSelected() && "bg-surface-gray-2 hover:bg-surface-gray-3",
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns,
|
||||
boxSizing: "border-box",
|
||||
columnGap: "1rem",
|
||||
height: `${rowHeight}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (onRowClick) onRowClick(row.original, e)
|
||||
}}
|
||||
>
|
||||
{virtualRow.index > 0 && <div className="absolute top-0 inset-s-2 inset-e-2 h-px bg-outline-gray-1" />}
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const meta = cell.column.columnDef.meta as ListViewColumnMeta | undefined
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
role="gridcell"
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden text-sm",
|
||||
cellIndex === leadDataColumnIndex
|
||||
? "text-ink-gray-8"
|
||||
: "text-ink-gray-7",
|
||||
alignClass(meta),
|
||||
tabularNumsClass(meta),
|
||||
)}
|
||||
>
|
||||
<ListViewCellBody cell={cell} row={row} meta={meta}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</ListViewCellBody>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Div-based list with CSS Grid columns, optional resize handles, row virtualization, and frappe-ui–aligned Espresso tokens.
|
||||
*/
|
||||
export function ListView<TData>(props: ListViewProps<TData>) {
|
||||
return <ListViewInner {...props} />
|
||||
}
|
||||
|
||||
export type { ColumnSizingState, RowSelectionState }
|
||||
27
banking/src/components/ui/loaders.tsx
Normal file
27
banking/src/components/ui/loaders.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from "./skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table"
|
||||
|
||||
export const TableLoader = ({ rows = 10, columns = 5 }: { rows?: number, columns?: number }) => {
|
||||
return <Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<TableHead key={index}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<TableCell key={index}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
28
banking/src/components/ui/markdown.tsx
Normal file
28
banking/src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
// import './markdown.css'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string,
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {
|
||||
return <ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
// components={{
|
||||
// p: (props) => <Text {...props} as='p' />,
|
||||
// ul: (props) => <UnorderedList {...props} />,
|
||||
// ol: (props) => <OrderedList {...props} />,
|
||||
// li: (props) => <ListItem {...props} />,
|
||||
// a: (props) => <Link {...props} />,
|
||||
// }}>
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
}
|
||||
|
||||
export default MarkdownRenderer
|
||||
87
banking/src/components/ui/popover.tsx
Normal file
87
banking/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-surface-modal rounded-lg border p-3 shadow-xl outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-ink-gray-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
67
banking/src/components/ui/progress.tsx
Normal file
67
banking/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
const progressVariants = cva(
|
||||
"bg-surface-gray-2 relative w-full overflow-hidden rounded-full",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-0.5",
|
||||
md: "h-1",
|
||||
lg: "h-2.5",
|
||||
xl: "h-3"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ProgressProps extends React.ComponentProps<typeof ProgressPrimitive.Root>, VariantProps<typeof progressVariants> {
|
||||
/** Optional text label displayed on the progress bar */
|
||||
label?: React.ReactNode,
|
||||
/** Whether to show a hint/tooltip for the progress value */
|
||||
hint?: boolean,
|
||||
/** Override the default hint text with custom progress value */
|
||||
hintText?: React.ReactNode
|
||||
}
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
size = "sm",
|
||||
label,
|
||||
hint,
|
||||
hintText,
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
|
||||
const progressValue = hintText ? hintText : `${value}%`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{label || hint ? <div className="flex items-center justify-between gap-1">
|
||||
{label && <span className="text-base font-medium text-ink-gray-7">{label}</span>}
|
||||
{hint && <span className="text-base font-medium text-ink-gray-5">{progressValue}</span>}
|
||||
</div> : null}
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
progressVariants({ size }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-surface-gray-7 rounded-xl h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
43
banking/src/components/ui/radio-group.tsx
Normal file
43
banking/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-outline-gray-2 text-ink-gray-7 focus-visible:border-outline-gray-1 focus-visible:ring-outline-gray-1/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
221
banking/src/components/ui/select.tsx
Normal file
221
banking/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
|
||||
const selectVariants = cva(cn("flex w-fit items-center justify-between gap-2 min-w-0 transition-all outline-none border border-transparent whitespace-nowrap",
|
||||
"focus-visible:bg-surface-white focus-visible:border-outline-gray-4 focus-visible:shadow-focus-gray",
|
||||
"active:bg-surface-white active:shadow-sm active:border-outline-gray-4 data-[state=open]:border-outline-gray-4",
|
||||
"placeholder:text-ink-gray-4 text-ink-gray-7",
|
||||
"disabled:bg-surface-gray-1 disabled:placeholder:text-ink-gray-3 disabled:text-ink-gray-3 disabled:cursor-not-allowed disabled:pointer-events-none",
|
||||
"aria-readonly:bg-surface-gray-1 aria-readonly:text-ink-gray-6 aria-readonly:pointer-events-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
|
||||
// Disable most styles inside an input group
|
||||
"in-data-[slot=input-group]:border-transparent! in-data-[slot=input-group]:focus-visible:shadow-none! in-data-[slot=input-group]:bg-transparent!"),
|
||||
{
|
||||
variants: {
|
||||
inputSize: {
|
||||
sm: "text-base rounded py-1.5 px-2 h-7",
|
||||
md: "text-base rounded py-2 px-2.5 h-8",
|
||||
lg: "text-lg rounded-md py-[11px] px-3 h-10",
|
||||
},
|
||||
variant: {
|
||||
subtle: "bg-surface-gray-2 hover:bg-surface-gray-3 aria-invalid:bg-surface-red-1",
|
||||
outline: "bg-surface-white border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 disabled:border-outline-gray-2",
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
inputSize: "md",
|
||||
variant: "subtle"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
inputSize = "md",
|
||||
variant = "subtle",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & VariantProps<typeof selectVariants>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-input-size={inputSize}
|
||||
className={cn(
|
||||
"data-placeholder:text-ink-gray-4 [&_svg:not([class*='text-'])]:text-ink-gray-7",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
selectVariants({ inputSize, variant }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-surface-modal rounded-lg min-w-32 border shadow-xl",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-ink-gray-4 px-2 py-1.5 text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"outline-hidden select-none relative flex w-full cursor-default items-center gap-2 rounded py-1.5 pe-8 px-2",
|
||||
"focus:bg-surface-gray-2 text-ink-gray-6 [&_svg:not([class*='text-'])]:text-ink-gray-6 text-base [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"data-disabled:pointer-events-none data-disabled:text-ink-gray-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute ltr:right-2 rtl:left-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-outline-gray-modals pointer-events-none mx-0.5 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
26
banking/src/components/ui/separator.tsx
Normal file
26
banking/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-outline-gray-modals shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
273
banking/src/components/ui/settings-dialog.tsx
Normal file
273
banking/src/components/ui/settings-dialog.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import * as React from "react"
|
||||
import { Tabs as TabsPrimitive, Dialog as DialogPrimitive } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DialogContent } from "./dialog"
|
||||
|
||||
/**
|
||||
* Sample Usage:
|
||||
*
|
||||
* <Dialog open={open} onOpenChange={setOpen}>
|
||||
* <DialogTrigger>
|
||||
* ...your content...
|
||||
* </DialogTrigger>
|
||||
*
|
||||
* <SettingsDialog onClose={() => setOpen(false)} defaultValue="preferences">
|
||||
* <SettingsTabs>
|
||||
* <SettingsTabGroup header="Configuration">
|
||||
* <SettingsTabItem icon={<SlidersVerticalIcon />} label="Preferences" value="preferences" />
|
||||
* <SettingsTabItem icon={<ZapIcon />} label="Matching Rules" value="rules" />
|
||||
* </SettingsTabGroup>
|
||||
* <SettingsTabGroup header="Setup">
|
||||
* <SettingsTabItem icon={<LandmarkIcon />} label="Bank Accounts" value="bank-accounts" />
|
||||
* <SettingsTabItem icon={<ListIcon />} label="Masters" value="masters" />
|
||||
* </SettingsTabGroup>
|
||||
* </SettingsTabs>
|
||||
*
|
||||
* <SettingsPanels>
|
||||
* <SettingsPanel value="preferences"><Preferences /></SettingsPanel>
|
||||
* <SettingsPanel value="rules"><MatchingRules /></SettingsPanel>
|
||||
* <SettingsPanel value="bank-accounts"><BankAccounts /></SettingsPanel>
|
||||
* <SettingsPanel value="masters"><Masters /></SettingsPanel>
|
||||
* </SettingsPanels>
|
||||
* </SettingsDialog>
|
||||
* </Dialog>
|
||||
*/
|
||||
|
||||
type SettingsDialogContextValue = {
|
||||
onClose?: VoidFunction
|
||||
}
|
||||
|
||||
const SettingsDialogContext = React.createContext<SettingsDialogContextValue>({})
|
||||
|
||||
/**
|
||||
* Exposes `onClose` to descendant panels so they can dismiss the dialog after
|
||||
* a successful save without prop-drilling.
|
||||
*/
|
||||
export const useSettingsDialog = () => React.useContext(SettingsDialogContext)
|
||||
|
||||
type SettingsDialogProps = Omit<
|
||||
React.ComponentProps<typeof TabsPrimitive.Root>,
|
||||
"orientation"
|
||||
> & {
|
||||
onClose?: VoidFunction
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
function SettingsDialog({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
onClose,
|
||||
...props
|
||||
}: SettingsDialogProps) {
|
||||
const contextValue = React.useMemo(() => ({ onClose }), [onClose])
|
||||
|
||||
return (
|
||||
<DialogContent className={cn("min-w-5xl max-lg:min-w-[98vw] p-0 overflow-y-hidden", contentClassName)} showCloseButton={false}>
|
||||
<SettingsDialogContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
data-slot="settings-dialog"
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"flex h-[calc(100vh-8rem)] bg-surface-menu-bar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Root>
|
||||
</SettingsDialogContext.Provider>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsTabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="settings-tabs"
|
||||
className={cn(
|
||||
"flex flex-col w-56 bg-surface-menu-bar rounded-s-lg shrink-0 overflow-y-auto m-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsTabGroupProps = React.ComponentProps<"div"> & {
|
||||
header?: React.ReactNode
|
||||
}
|
||||
|
||||
function SettingsTabGroup({
|
||||
children,
|
||||
header,
|
||||
className,
|
||||
...props
|
||||
}: SettingsTabGroupProps) {
|
||||
return (
|
||||
<div data-slot="settings-tab-group" className={className} {...props}>
|
||||
{header && (
|
||||
<div className="h-7.5 px-2 py-[7px] my-[3px] flex cursor-default gap-1.5 text-xs font-medium text-ink-gray-5 transition-all duration-300 ease-in-out sticky top-0 z-10 bg-surface-menu-bar">
|
||||
<span>{header}</span>
|
||||
</div>
|
||||
)}
|
||||
<nav className="space-y-[3px] px-1">{children}</nav>
|
||||
<div className="mb-0.5 mt-[5px]"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsTabItemProps = React.ComponentProps<typeof TabsPrimitive.Trigger> & {
|
||||
icon?: React.ReactNode
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
function SettingsTabItem({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
...props
|
||||
}: SettingsTabItemProps) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="settings-tab-item"
|
||||
className={cn(
|
||||
"flex h-7.5 cursor-pointer items-center rounded text-ink-gray-6 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-outline-gray-3 w-full",
|
||||
"hover:bg-surface-gray-3",
|
||||
"data-[state=active]:bg-surface-selected data-[state=active]:shadow-sm data-[state=active]:hover:bg-surface-selected",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between duration-300 ease-in-out px-2 py-[7px]">
|
||||
<div className="flex items-center truncate">
|
||||
{icon && (
|
||||
<div className="[&_svg:not([class*='size-'])]:size-4 text-ink-gray-6 [&_svg:not([class*='text-'])]:text-ink-gray-6">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
||||
icon && "ms-2"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanels({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="settings-panels"
|
||||
className={cn(
|
||||
"flex flex-col flex-1 overflow-y-auto bg-surface-modal",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="settings-panel"
|
||||
className={cn("flex flex-col h-full w-full text-ink-gray-8 py-8 px-6 gap-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage:
|
||||
*
|
||||
* <SettingsPanelHeader actions={<><Button>Add</Button></>}>
|
||||
*
|
||||
* <SettingsPanelTitle>Settings</SettingsPanelTitle>
|
||||
* <SettingsPanelDescription>Settings description</SettingsPanelDescription>
|
||||
*
|
||||
* </SettingsPanelHeader>
|
||||
*/
|
||||
function SettingsPanelHeader({
|
||||
className,
|
||||
children,
|
||||
actions,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { actions?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex justify-between items-start px-2 text-ink-gray-7", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex item-center space-x-2 w-fit justify-end">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanelTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("flex gap-2 text-xl font-semibold leading-none h-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanelDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-p-base text-ink-gray-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanelContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col overflow-y-auto px-2", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
SettingsDialog,
|
||||
SettingsTabs,
|
||||
SettingsTabGroup,
|
||||
SettingsTabItem,
|
||||
SettingsPanels,
|
||||
SettingsPanel,
|
||||
SettingsPanelHeader,
|
||||
SettingsPanelTitle,
|
||||
SettingsPanelDescription,
|
||||
SettingsPanelContent
|
||||
}
|
||||
13
banking/src/components/ui/skeleton.tsx
Normal file
13
banking/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-surface-gray-2 animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
53
banking/src/components/ui/sonner.tsx
Normal file
53
banking/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
import { useTheme } from "./theme-provider"
|
||||
|
||||
const themeMap = {
|
||||
"Automatic": "system",
|
||||
"Dark": "dark",
|
||||
"Light": "light",
|
||||
}
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "Automatic" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={themeMap[theme as keyof typeof themeMap] as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--surface-gray-1)",
|
||||
"--normal-text": "var(--text-ink-gray-8)",
|
||||
"--normal-border": "var(--outline-gray-1)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
13
banking/src/components/ui/stats.tsx
Normal file
13
banking/src/components/ui/stats.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const StatContainer = ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
return <div className={cn("flex flex-col gap-1.5 p-2", className)}>{children}</div>
|
||||
}
|
||||
|
||||
export const StatLabel = ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
return <span className={cn("uppercase text-2xs font-medium text-ink-gray-6", className)}>{children}</span>
|
||||
}
|
||||
|
||||
export const StatValue = ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
return <span className={cn("text-xl text-ink-gray-8 font-semibold tabular-nums", className)}>{children}</span>
|
||||
}
|
||||
45
banking/src/components/ui/switch.tsx
Normal file
45
banking/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "md"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer cursor-pointer group/switch inline-flex shrink-0 items-center rounded-full transition-all outline-none disabled:cursor-not-allowed",
|
||||
"data-[state=unchecked]:bg-ink-gray-2 data-[state=unchecked]:hover:bg-ink-gray-3 data-[state=unchecked]:active:bg-ink-gray-4 data-[state=unchecked]:disabled:bg-ink-gray-1",
|
||||
"data-[state=checked]:bg-ink-gray-8 data-[state=checked]:hover:bg-ink-gray-7 data-[state=checked]:active:bg-ink-gray-6 data-[state=checked]:disabled:bg-ink-gray-1",
|
||||
"data-[size=sm]:h-4 data-[size=sm]:w-6.5 data-[size=md]:h-5 data-[size=md]:w-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"shadow-switch block pointer-events-none rounded-full ring-0 transition-transform bg-ink-white",
|
||||
"group-data-[size=sm]/switch:size-3 group-data-[size=md]/switch:size-3.5",
|
||||
// Unchecked: keep thumb near the start edge (mirrored by dir)
|
||||
"ltr:data-[state=unchecked]:group-data-[size=sm]/switch:translate-x-0.5",
|
||||
"ltr:data-[state=unchecked]:group-data-[size=md]/switch:translate-x-[3px]",
|
||||
"rtl:data-[state=unchecked]:group-data-[size=sm]/switch:-translate-x-0.5",
|
||||
"rtl:data-[state=unchecked]:group-data-[size=md]/switch:-translate-x-[3px]",
|
||||
// Checked: move to opposite edge (mirrored by dir)
|
||||
"ltr:data-[state=checked]:translate-x-[calc(100%-0px)]",
|
||||
"rtl:data-[state=checked]:-translate-x-[calc(100%-0px)]",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
114
banking/src/components/ui/table.tsx
Normal file
114
banking/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, containerClassName, ...props }: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className={cn("relative w-full overflow-x-auto rounded border-outline-gray-1 border", containerClassName)}
|
||||
>
|
||||
<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(
|
||||
"bg-surface-gray-2 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-surface-gray-1 data-[state=selected]:bg-surface-gray-2 border-b transition-all",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"bg-surface-gray-2 text-ink-gray-5 text-sm p-2 text-start align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle text-base whitespace-nowrap [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-ink-gray-5 my-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
168
banking/src/components/ui/tabs.tsx
Normal file
168
banking/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list text-ink-gray-5 inline-flex group-data-[orientation=horizontal]/tabs:w-full group-data-[orientation=vertical]/tabs:w-fit items-center justify-start group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
subtle: "bg-surface-gray-2 p-px",
|
||||
outline: "p-px border border-outline-gray-1",
|
||||
underline: "group-data-[orientation=horizontal]/tabs:border-b border-outline-gray-1 group-data-[orientation=vertical]/tabs:border-e group-data-[orientation=horizontal]/tabs:gap-6 group-data-[orientation=vertical]/tabs:gap-2",
|
||||
},
|
||||
size: {
|
||||
sm: "group-data-[orientation=horizontal]/tabs:h-7",
|
||||
md: "group-data-[orientation=horizontal]/tabs:h-7.5"
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "subtle",
|
||||
size: "sm",
|
||||
className: "rounded gap-1"
|
||||
},
|
||||
{
|
||||
variant: "subtle",
|
||||
size: "md",
|
||||
className: "rounded-md gap-1.5 font-medium"
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
className: "rounded gap-1",
|
||||
},
|
||||
{
|
||||
variant: "outline",
|
||||
size: "md",
|
||||
className: "rounded-md gap-1.5 font-medium",
|
||||
},
|
||||
{
|
||||
variant: "underline",
|
||||
size: "sm",
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
variant: "underline",
|
||||
size: "md",
|
||||
className: "",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "underline",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "underline",
|
||||
size = "md",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(tabsListVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
// Common
|
||||
"whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50",
|
||||
"text-ink-gray-5 text-base data-[state=active]:text-ink-gray-8 hover:text-ink-gray-8 relative gap-2",
|
||||
"flex items-center justify-center group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
|
||||
// Icon Sizes - 16px for sm, 18px for md
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[size=sm]/tabs-list:[&_svg:not([class*='size-'])]:size-4 group-data-[size=md]/tabs-list:[&_svg:not([class*='size-'])]:size-4.5",
|
||||
|
||||
// Variant: subtle, size: sm
|
||||
"group-data-[variant=subtle]/tabs-list:group-data-[size=sm]/tabs-list:py-[5px] group-data-[variant=subtle]/tabs-list:group-data-[size=sm]/tabs-list:px-2 group-data-[variant=subtle]/tabs-list:group-data-[size=sm]/tabs-list:rounded-[7px]",
|
||||
// Variant: subtle, size: md
|
||||
"group-data-[variant=subtle]/tabs-list:group-data-[size=md]/tabs-list:py-1.5 group-data-[variant=subtle]/tabs-list:group-data-[size=md]/tabs-list:px-2.5 group-data-[variant=subtle]/tabs-list:group-data-[size=md]/tabs-list:rounded-[9px]",
|
||||
// Variant: subtle - active - background, text color and shadow applied
|
||||
"group-data-[variant=subtle]/tabs-list:data-[state=active]:bg-surface-selected group-data-[variant=subtle]/tabs-list:data-[state=active]:shadow",
|
||||
|
||||
|
||||
// Variant: outline, size: sm
|
||||
"group-data-[variant=outline]/tabs-list:group-data-[size=sm]/tabs-list:py-[5px] group-data-[variant=outline]/tabs-list:group-data-[size=sm]/tabs-list:px-2 group-data-[variant=outline]/tabs-list:group-data-[size=sm]/tabs-list:rounded-[7px]",
|
||||
// Variant: outline, size: md
|
||||
"group-data-[variant=outline]/tabs-list:group-data-[size=md]/tabs-list:py-1.5 group-data-[variant=outline]/tabs-list:group-data-[size=md]/tabs-list:px-2.5 group-data-[variant=outline]/tabs-list:group-data-[size=md]/tabs-list:rounded-[9px]",
|
||||
// Variant: outline - active - background, text color and shadow applied
|
||||
"group-data-[variant=outline]/tabs-list:data-[state=active]:bg-surface-selected group-data-[variant=outline]/tabs-list:data-[state=active]:shadow",
|
||||
|
||||
// Variant: underline - horizontal
|
||||
"group-data-[variant=underline]/tabs-list:rounded-none ",
|
||||
// Variant: underline - horizontal - no radius
|
||||
"group-data-[orientation=horizontal]/tabs:group-data-[variant=underline]/tabs-list:px-0",
|
||||
// Variant: underline, size: sm
|
||||
"group-data-[orientation=horizontal]/tabs:group-data-[variant=underline]/tabs-list:group-data-[size=sm]/tabs-list:py-1.5 group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:group-data-[size=sm]/tabs-list:px-1.5",
|
||||
// Variant: underline, size: md
|
||||
"group-data-[orientation=horizontal]/tabs:group-data-[variant=underline]/tabs-list:group-data-[size=md]/tabs-list:py-[7px] group-data-[variant=underline]/tabs-list:group-data-[size=md]/tabs-list:font-medium",
|
||||
// Variant: underline - horizontal - active - border applied
|
||||
"group-data-[orientation=horizontal]/tabs:group-data-[variant=underline]/tabs-list:border-b group-data-[orientation=horizontal]/tabs:group-data-[variant=underline]/tabs-list:border-b-transparent group-data-[orientation=horizontal]/tabs:group-data-[variant=underline]/tabs-list:data-[state=active]:border-b-ink-gray-8 group-data-[orientation=horizontal]/tabs:group-data-[variant=underline]/tabs-list:bottom-px",
|
||||
|
||||
|
||||
// Variant: underline - Vertical
|
||||
"group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:ps-0",
|
||||
// Variant: underline, size: sm
|
||||
"group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:group-data-[size=sm]/tabs-list:py-1.5 group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:group-data-[size=sm]/tabs-list:pe-1.5",
|
||||
// Variant: underline, size: md
|
||||
"group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:group-data-[size=md]/tabs-list:py-2 group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:group-data-[size=md]/tabs-list:pe-2",
|
||||
// Variant: underline - vertical - active - border applied
|
||||
"group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:border-e group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:border-e-transparent group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:data-[state=active]:border-e-ink-gray-8 group-data-[orientation=vertical]/tabs:group-data-[variant=underline]/tabs-list:-right-px",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none group-data-[orientation=vertical]/tabs:px-2 group-data-[orientation=horizontal]/tabs:py-2 group-data-[orientation=vertical]/tabs:h-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
43
banking/src/components/ui/textarea.tsx
Normal file
43
banking/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
|
||||
const textareaVariants = cva(cn("flex field-sizing-content w-full transition-all focus-visible:bg-surface-white focus-visible:border-outline-gray-4 focus-visible:shadow-focus-gray active:bg-surface-white active:shadow-textarea-active active:border-outline-gray-4 outline-none border border-transparent placeholder:text-ink-gray-4 text-ink-gray-7 disabled:bg-surface-gray-1 disabled:placeholder:text-ink-gray-3 disabled:text-ink-gray-3 disabled:cursor-not-allowed aria-readonly:bg-surface-gray-1 aria-readonly:text-ink-gray-6 aria-readonly:pointer-events-none aria-invalid:shadow-focus-red aria-invalid:border-outline-red-3",
|
||||
"in-data-[slot=input-group]:border-transparent! in-data-[slot=input-group]:focus-visible:shadow-none! in-data-[slot=input-group]:bg-transparent!"),
|
||||
{
|
||||
variants: {
|
||||
inputSize: {
|
||||
sm: "text-p-base rounded py-1.5 px-2 min-h-15",
|
||||
md: "text-p-base rounded-md py-2.5 px-3 min-h-20.5",
|
||||
lg: "text-p-lg rounded-md py-3 px-3.5 min-h-25.5",
|
||||
},
|
||||
variant: {
|
||||
subtle: "bg-surface-gray-2 hover:bg-surface-gray-3 aria-invalid:bg-surface-red-1",
|
||||
outline: "bg-surface-white border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-4 disabled:border-outline-gray-2",
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
inputSize: "md",
|
||||
variant: "subtle"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function Textarea({ inputSize = "md", variant = "subtle", className, ...props }: React.ComponentProps<"textarea"> & VariantProps<typeof textareaVariants>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
data-input-size={inputSize}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
textareaVariants({ inputSize, variant }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
89
banking/src/components/ui/theme-provider.tsx
Normal file
89
banking/src/components/ui/theme-provider.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useFrappePostCall } from "frappe-react-sdk"
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "Dark" | "Light" | "Automatic"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
/** Theme value selected by the user - Light, Dark, Automatic */
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void,
|
||||
/** Resolved theme value - used to apply the theme to the UI - this resolves "Automatic" to "Light" or "Dark" based on the system preference */
|
||||
themeValue: "Light" | "Dark"
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "Light",
|
||||
setTheme: () => null,
|
||||
themeValue: "Light",
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "Light",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(defaultTheme)
|
||||
const [themeValue, setThemeValue] = useState<"Light" | "Dark">(defaultTheme === "Automatic" ? "Light" : defaultTheme)
|
||||
|
||||
const { call: switchTheme } = useFrappePostCall('frappe.core.doctype.user.user.switch_theme')
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
|
||||
const applySystemTheme = () => {
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(mediaQuery.matches ? "dark" : "light")
|
||||
setThemeValue(mediaQuery.matches ? "Dark" : "Light")
|
||||
}
|
||||
|
||||
if (theme !== "Automatic") {
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(theme.toLowerCase())
|
||||
setThemeValue(theme)
|
||||
return () => { }
|
||||
}
|
||||
|
||||
applySystemTheme()
|
||||
mediaQuery.addEventListener("change", applySystemTheme)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", applySystemTheme)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
themeValue,
|
||||
setTheme: (theme: Theme) => {
|
||||
switchTheme({
|
||||
theme: theme,
|
||||
}).then(() => {
|
||||
setTheme(theme)
|
||||
})
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
56
banking/src/components/ui/tooltip.tsx
Normal file
56
banking/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 500,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
arrowClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content> & { arrowClassName?: string }) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-surface-gray-7 shadow text-ink-white animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded px-2 py-[5px] text-base text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className={cn("bg-surface-gray-7 fill-surface-gray-7 z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-xs", arrowClassName)} />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
45
banking/src/components/ui/typography.tsx
Normal file
45
banking/src/components/ui/typography.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function H1({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
return (
|
||||
<h1 className={cn("scroll-m-20 text-3xl font-extrabold text-balance", className)}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export function H2({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
return (
|
||||
<h2 className={cn("scroll-m-20 border-b pb-2 text-2xl font-semibold first:mt-0", className)}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function H3({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
return (
|
||||
<h3 className={cn("scroll-m-20 text-xl font-semibold", className)}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
export function H4({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
return (
|
||||
<h4 className={cn("scroll-m-20 text-lg font-semibold", className)}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
|
||||
export function Paragraph({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
return (
|
||||
<p className={cn("text-p-base", className)}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user