From d7efede122e19dc4393619babfd6d1b37f29eb0f Mon Sep 17 00:00:00 2001 From: Temuulen Ankhbayar Date: Mon, 9 Mar 2026 17:21:36 +0900 Subject: [PATCH] refactor: complete shadcn/ui migration and unify raw HTML with component library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate all molecule components (DataTable, PaginationBar, FilterDropdown, AlertBanner, FormField, SectionCard, SubCard, MetricCard, AnimatedCard, OtpInput) to shadcn/ui primitives with legacy backups and comparison stories - Install 24 shadcn/ui primitives (accordion, alert, badge, button, card, checkbox, collapsible, dialog, dropdown-menu, input-otp, input, label, pagination, popover, radio-group, select, separator, sheet, skeleton, table, tabs, toggle-group, toggle, tooltip) with barrel exports - Replace 69 raw HTML elements across all features with shadcn components: 35+ + )} + + + ); +} + +export type { AlertBannerProps }; diff --git a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx index 58702d67..b48c70ba 100644 --- a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx +++ b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AlertBanner } from "./AlertBanner"; +import { AlertBanner as AlertBannerLegacy } from "./AlertBanner.legacy"; const meta: Meta = { title: "Molecules/AlertBanner", @@ -77,3 +78,61 @@ export const Closable: Story = { export const Small: Story = { args: { variant: "warning", title: "Heads up", size: "sm" }, }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+ {(["info", "success", "warning", "error"] as const).map(variant => ( +
+

{variant}

+
+
+

New (shadcn Alert)

+ + This is the {variant} message body. + +
+
+

Legacy

+ + This is the {variant} message body. + +
+
+
+ ))} + +
+

closable

+
+
+

New (shadcn Alert)

+ {}}> + Click the X to close. + +
+
+

Legacy

+ {}}> + Click the X to close. + +
+
+
+ +
+

small + elevated

+
+
+

New (shadcn Alert)

+ +
+
+

Legacy

+ +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx index 0aa2125e..735e3765 100644 --- a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx +++ b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx @@ -7,11 +7,13 @@ import { ExclamationTriangleIcon, XCircleIcon, } from "@heroicons/react/24/outline"; +import { cn } from "@/shared/utils"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; type Variant = "success" | "info" | "warning" | "error"; type IconType = React.ComponentType>; -const variantClasses: Record< +const variantStyles: Record< Variant, { bg: string; border: string; text: string; icon: string; Icon: IconType } > = { @@ -66,42 +68,39 @@ export function AlertBanner({ className, ...rest }: AlertBannerProps) { - const styles = variantClasses[variant]; + const styles = variantStyles[variant]; const Icon = styles.Icon; - const padding = size === "sm" ? "p-3" : "p-4"; - const radius = "rounded-xl"; - const shadow = elevated ? "shadow-sm" : ""; - const role = variant === "error" || variant === "warning" ? "alert" : "status"; + const padding = size === "sm" ? "px-3 py-2" : "px-4 py-3"; return ( -
-
-
- {icon ? icon : } -
-
- {title &&

{title}

} - {children && ( -
{children}
- )} -
- {onClose && ( - - )} -
-
+ {icon ? ( + {icon} + ) : ( + + )} + {title && {title}} + {children && {children}} + {onClose && ( + + )} + ); } diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.legacy.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.legacy.tsx new file mode 100644 index 00000000..78603431 --- /dev/null +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.legacy.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ReactNode } from "react"; + +interface AnimatedCardProps { + children: ReactNode; + className?: string | undefined; + variant?: "default" | "highlighted" | "success" | "static" | undefined; + onClick?: (() => void) | undefined; + disabled?: boolean | undefined; +} + +const SHADOW_BASE = "0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06)"; +const SHADOW_ELEVATED = "0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07)"; + +export function AnimatedCard({ + children, + className = "", + variant = "default", + onClick, + disabled = false, +}: AnimatedCardProps) { + const baseClasses = "bg-card text-card-foreground rounded-xl border"; + + const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = { + default: "border-border", + highlighted: "border-primary/35 ring-1 ring-primary/15", + success: "border-success/25 ring-1 ring-success/15", + static: "border-border", + }; + + const interactiveClasses = onClick && !disabled ? "cursor-pointer" : ""; + const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : ""; + + const isStatic = variant === "static" || disabled; + + return ( + + {children} + + ); +} + +export type { AnimatedCardProps }; diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx index 72e722a4..cc7aeac9 100644 --- a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AnimatedCard } from "./AnimatedCard"; +import { AnimatedCard as AnimatedCardLegacy } from "./AnimatedCard.legacy"; const meta: Meta = { title: "Molecules/AnimatedCard", @@ -52,3 +53,46 @@ export const Disabled: Story = { children:
Disabled card
, }, }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+
+

+ Current (shadcn/ui Card classes) +

+
+ +
Default
+
+ +
Highlighted
+
+ +
Success
+
+ +
Static
+
+
+
+
+

Legacy

+
+ +
Default
+
+ +
Highlighted
+
+ +
Success
+
+ +
Static
+
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx index 78603431..741e3561 100644 --- a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx @@ -2,6 +2,7 @@ import { motion } from "framer-motion"; import { ReactNode } from "react"; +import { cn } from "@/shared/utils"; interface AnimatedCardProps { children: ReactNode; @@ -21,8 +22,6 @@ export function AnimatedCard({ onClick, disabled = false, }: AnimatedCardProps) { - const baseClasses = "bg-card text-card-foreground rounded-xl border"; - const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = { default: "border-border", highlighted: "border-primary/35 ring-1 ring-primary/15", @@ -37,7 +36,13 @@ export function AnimatedCard({ return ( { + key: string; + header: string; + render: (item: T) => ReactNode; + className?: string; + /** If true, this column will be emphasized in mobile card view (shown first/larger) */ + primary?: boolean; + /** If true, this column will be hidden in mobile card view */ + hideOnMobile?: boolean; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + emptyState?: { + icon: ReactNode; + title: string; + description: string; + }; + onRowClick?: (item: T) => void; + className?: string; + /** Force table view even on mobile (not recommended for UX) */ + forceTableView?: boolean; +} + +function MobileCardView({ + data, + columns, + onRowClick, +}: { + data: T[]; + columns: Column[]; + onRowClick: ((item: T) => void) | undefined; +}) { + const primaryColumn = columns.find(col => col.primary); + const mobileColumns = columns.filter(col => !col.hideOnMobile && !col.primary); + + return ( +
+ {data.map((item, index) => ( +
onRowClick?.(item)} + role={onRowClick ? "button" : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={e => { + if (onRowClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onRowClick(item); + } + }} + style={{ animationDelay: `${index * 50}ms` }} + > +
+
+
+ {(primaryColumn ?? columns[0])?.render(item)} +
+
+ {onRowClick && ( + + )} +
+
+ {mobileColumns.map((column, colIndex) => { + if (!primaryColumn && colIndex === 0 && column === columns[0]) return null; + return ( +
+ + {column.header} + + + {column.render(item)} + +
+ ); + })} +
+
+ ))} +
+ ); +} + +function DesktopTableView({ + data, + columns, + onRowClick, + className = "", + forceTableView = false, +}: { + data: T[]; + columns: Column[]; + onRowClick: ((item: T) => void) | undefined; + className: string; + forceTableView: boolean; +}) { + return ( +
+ + + + {columns.map(column => ( + + ))} + + + + {data.map(item => ( + onRowClick?.(item)} + > + {columns.map(column => ( + + ))} + + ))} + +
+ {column.header} +
+ {column.render(item)} +
+
+ ); +} + +export function DataTable({ + data, + columns, + emptyState, + onRowClick, + className = "", + forceTableView = false, +}: DataTableProps) { + if (data.length === 0 && emptyState) { + return ( + + ); + } + + return ( + <> + {!forceTableView && } + + + ); +} + +export type { DataTableProps, Column }; diff --git a/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx b/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx index b41befb6..2acd4074 100644 --- a/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx +++ b/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { DataTable } from "./DataTable"; +import { DataTable as LegacyDataTable } from "./DataTable.legacy"; import { InboxIcon } from "@heroicons/react/24/outline"; interface SampleRow { @@ -80,3 +81,18 @@ export const Empty: Story = { }, }, }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+
+

Current (shadcn/ui Table)

+ +
+
+

Legacy (custom table)

+ +
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/DataTable/DataTable.tsx b/apps/portal/src/components/molecules/DataTable/DataTable.tsx index 3c1628d3..fde5db2f 100644 --- a/apps/portal/src/components/molecules/DataTable/DataTable.tsx +++ b/apps/portal/src/components/molecules/DataTable/DataTable.tsx @@ -1,5 +1,14 @@ import type { ReactNode } from "react"; import { EmptyState } from "@/components/atoms/empty-state"; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@/components/ui/table"; +import { cn } from "@/shared/utils"; import { ChevronRightIcon } from "@heroicons/react/24/outline"; interface Column { @@ -44,13 +53,13 @@ function MobileCardView({ {data.map((item, index) => (
onRowClick?.(item)} role={onRowClick ? "button" : undefined} tabIndex={onRowClick ? 0 : undefined} @@ -107,39 +116,39 @@ function DesktopTableView({ forceTableView: boolean; }) { return ( -
- - - +
+
+ + {columns.map(column => ( - + ))} - - - + + + {data.map(item => ( - onRowClick?.(item)} > {columns.map(column => ( - + ))} - + ))} - -
{column.header} -
+ {column.render(item)} -
+ +
); } diff --git a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.legacy.tsx b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.legacy.tsx new file mode 100644 index 00000000..5fb34980 --- /dev/null +++ b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.legacy.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { FunnelIcon } from "@heroicons/react/24/outline"; +import { cn } from "@/shared/utils"; + +export interface FilterOption { + value: string; + label: string; +} + +export interface FilterDropdownProps { + /** Current selected value */ + value: string; + /** Callback when value changes */ + onChange: (value: string) => void; + /** Array of filter options */ + options: FilterOption[]; + /** Accessible label for the dropdown */ + label: string; + /** Optional width class (default: "w-40") */ + width?: string; + /** Optional additional class names */ + className?: string; +} + +/** + * FilterDropdown (Legacy) - Reusable filter dropdown component with consistent styling. + * + * Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc. + */ +export function FilterDropdown({ + value, + onChange, + options, + label, + width = "w-40", + className, +}: FilterDropdownProps) { + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx index 4baca8ae..92faca9a 100644 --- a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx +++ b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { FilterDropdown } from "./FilterDropdown"; +import { FilterDropdown as FilterDropdownLegacy } from "./FilterDropdown.legacy"; const meta: Meta = { title: "Molecules/FilterDropdown", @@ -10,6 +11,20 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const statusOptions = [ + { value: "all", label: "All Statuses" }, + { value: "active", label: "Active" }, + { value: "pending", label: "Pending" }, + { value: "cancelled", label: "Cancelled" }, +]; + +const categoryOptions = [ + { value: "all", label: "All Categories" }, + { value: "billing", label: "Billing" }, + { value: "technical", label: "Technical" }, + { value: "general", label: "General" }, +]; + export const Default: Story = { render: function Render() { const [value, setValue] = useState("all"); @@ -17,12 +32,7 @@ export const Default: Story = { ); @@ -36,15 +46,39 @@ export const CustomWidth: Story = { ); }, }; + +export const ComparisonWithLegacy: Story = { + render: function Render() { + const [value, setValue] = useState("all"); + const [legacyValue, setLegacyValue] = useState("all"); + return ( +
+
+ shadcn/ui (new) + +
+
+ Legacy + +
+
+ ); + }, +}; diff --git a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx index a7c47c5b..2ec8932a 100644 --- a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx +++ b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx @@ -2,6 +2,13 @@ import { FunnelIcon } from "@heroicons/react/24/outline"; import { cn } from "@/shared/utils"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; export interface FilterOption { value: string; @@ -24,7 +31,7 @@ export interface FilterDropdownProps { } /** - * FilterDropdown - Reusable filter dropdown component with consistent styling. + * FilterDropdown - Reusable filter dropdown component built on shadcn/ui Select. * * Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc. */ @@ -37,28 +44,18 @@ export function FilterDropdown({ className, }: FilterDropdownProps) { return ( -
- + + + + + {options.map(option => ( - + ))} - -
- -
-
+ + ); } diff --git a/apps/portal/src/components/molecules/FormField/FormField.legacy.tsx b/apps/portal/src/components/molecules/FormField/FormField.legacy.tsx new file mode 100644 index 00000000..85c98761 --- /dev/null +++ b/apps/portal/src/components/molecules/FormField/FormField.legacy.tsx @@ -0,0 +1,109 @@ +import { forwardRef, cloneElement, isValidElement, useId } from "react"; +import { cn } from "@/shared/utils"; +import { Label, type LabelProps } from "@/components/atoms/label"; +import { Input, type InputProps } from "@/components/atoms/input"; +import { ErrorMessage } from "@/components/atoms/error-message"; + +interface FormFieldProps extends Omit< + InputProps, + "id" | "aria-describedby" | "aria-invalid" | "children" | "dangerouslySetInnerHTML" +> { + label?: string | undefined; + error?: string | undefined; + helperText?: string | undefined; + required?: boolean | undefined; + labelProps?: Omit | undefined; + fieldId?: string | undefined; + children?: React.ReactNode | undefined; + containerClassName?: string | undefined; + inputClassName?: string | undefined; +} + +const FormField = forwardRef( + ( + { + label, + error, + helperText, + required, + labelProps, + fieldId, + containerClassName, + inputClassName, + children, + ...inputProps + }, + ref + ) => { + const generatedId = useId(); + const id = fieldId || generatedId; + const errorId = error ? `${id}-error` : undefined; + const helperTextId = helperText ? `${id}-helper` : undefined; + const describedBy = cn(errorId, helperTextId) || undefined; + + const { className: inputPropsClassName, ...restInputProps } = inputProps; + + const renderInput = () => { + if (!children) { + return ( + + ); + } + + if (isValidElement(children)) { + return cloneElement(children, { + id, + "aria-invalid": error ? "true" : undefined, + "aria-describedby": describedBy, + } as Record); + } + + return children; + }; + + return ( +
+ {label && ( + + )} + {renderInput()} + {error && {error}} + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ); + } +); +FormField.displayName = "FormField"; + +export { FormField }; +export type { FormFieldProps }; diff --git a/apps/portal/src/components/molecules/FormField/FormField.stories.tsx b/apps/portal/src/components/molecules/FormField/FormField.stories.tsx index 2ff82b8e..64b26df1 100644 --- a/apps/portal/src/components/molecules/FormField/FormField.stories.tsx +++ b/apps/portal/src/components/molecules/FormField/FormField.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { FormField } from "./FormField"; +import { FormField as FormFieldLegacy } from "./FormField.legacy"; const meta: Meta = { title: "Molecules/FormField", @@ -41,3 +42,71 @@ export const FormExample: Story = {
), }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+
+

default

+
+
+

New (destructive tokens)

+ +
+
+

Legacy (danger tokens)

+ +
+
+
+ +
+

with error

+
+
+

New (destructive tokens)

+ +
+
+

Legacy (danger tokens)

+ +
+
+
+ +
+

required with helper

+
+
+

New (destructive tokens)

+ +
+
+

Legacy (danger tokens)

+ +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/FormField/FormField.tsx b/apps/portal/src/components/molecules/FormField/FormField.tsx index 85c98761..371c2d70 100644 --- a/apps/portal/src/components/molecules/FormField/FormField.tsx +++ b/apps/portal/src/components/molecules/FormField/FormField.tsx @@ -52,7 +52,8 @@ const FormField = forwardRef( aria-invalid={error ? "true" : undefined} aria-describedby={describedBy} className={cn( - error && "border-danger focus-visible:ring-danger focus-visible:ring-offset-2", + error && + "border-destructive focus-visible:ring-destructive focus-visible:ring-offset-2", inputClassName, inputPropsClassName )} @@ -79,14 +80,14 @@ const FormField = forwardRef( htmlFor={id} className={cn( "block text-sm font-medium text-muted-foreground", - error && "text-danger", + error && "text-destructive", labelProps?.className )} {...(labelProps ? { ...labelProps, className: undefined } : undefined)} > {label} {required && ( -