Merge remote-tracking branch 'origin/main' into iipbs/sn/dev
This commit is contained in:
@@ -13,9 +13,11 @@ export interface MenuItem {
|
||||
urlPath: string;
|
||||
menuLnkgTypeCd: string;
|
||||
menuLnkgSn: number;
|
||||
menuPath: string;
|
||||
qrcd: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
requiredUrlPath: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface SiteInfo {
|
||||
mdfcnDt: string;
|
||||
mdFr: string;
|
||||
rgTrNm: string;
|
||||
mdFrNm: string;
|
||||
mdfrNm: string;
|
||||
}
|
||||
|
||||
export const getSiteInfo = async (siteId: string) => {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
export const PORT = import.meta.env.VITE_PORT || "3000";
|
||||
export const PROXY_HOST =
|
||||
import.meta.env.VITE_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const PORT = process.env.PORT || "3000";
|
||||
export const PROXY_HOST = process.env.PROXY_HOST || `http://localhost:${PORT}`;
|
||||
|
||||
export const SERVER_API_URL = import.meta.env.VITE_SERVER_API_URL;
|
||||
export const SITE_ID = import.meta.env.VITE_SITE_ID || "CS";
|
||||
export const SERVER_API_URL = process.env.SERVER_API_URL;
|
||||
export const SITE_ID = process.env.SITE_ID || "CS";
|
||||
|
||||
export const CLAIM_NAME = import.meta.env.VITE_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN = import.meta.env.VITE_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =
|
||||
import.meta.env.VITE_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = import.meta.env.VITE_AUTH_USER_ID || "token-id";
|
||||
export const CLAIM_NAME = process.env.CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN = process.env.ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN = process.env.REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.AUTH_USER_ID || "token-id";
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./utils";
|
||||
export * from "./cookie";
|
||||
export * from "./utils";
|
||||
export * from "./use-mobile";
|
||||
|
||||
@@ -2,13 +2,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export function QueryProvider({
|
||||
children,
|
||||
client,
|
||||
queryClient,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
client?: QueryClient;
|
||||
queryClient?: QueryClient;
|
||||
}) {
|
||||
const queryClient = client ?? new QueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
if (!queryClient) {
|
||||
const queryClient = new QueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function Calendar({
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"!bg-primary !text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
};
|
||||
@@ -1,9 +1,18 @@
|
||||
import {
|
||||
flexRender,
|
||||
type RowData,
|
||||
type Table as TableType,
|
||||
type Table as TableInstance,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { cn } from "../../lib";
|
||||
import { Button } from "../button";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "../table";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -12,7 +21,50 @@ declare module "@tanstack/react-table" {
|
||||
}
|
||||
}
|
||||
|
||||
function THead<T>({ table }: { table: TableType<T> }) {
|
||||
type DataTableProps<T> = {
|
||||
table: TableInstance<T>;
|
||||
variant?: "default" | "secondary" | "line";
|
||||
className?: HTMLAttributes<HTMLTableElement>["className"];
|
||||
viewPagination?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
};
|
||||
|
||||
function DataTable<T>({
|
||||
table,
|
||||
variant = "default",
|
||||
className,
|
||||
viewPagination,
|
||||
onRowClick,
|
||||
}: DataTableProps<T>) {
|
||||
return (
|
||||
<>
|
||||
<Table className={cn("w-full", className)} variant={variant}>
|
||||
<THead table={table} />
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={cn(onRowClick && "cursor-pointer hover:bg-accent")}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const { meta } = cell.column.columnDef;
|
||||
return (
|
||||
<TableCell key={cell.id} align="center" {...meta}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
{viewPagination && <Pagination table={table} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function THead<T>({ table }: { table: TableInstance<T> }) {
|
||||
return (
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -36,34 +88,81 @@ function THead<T>({ table }: { table: TableType<T> }) {
|
||||
);
|
||||
}
|
||||
|
||||
type TableProps<T> = {
|
||||
table: TableType<T>;
|
||||
onRowClick?: (row: T) => void;
|
||||
};
|
||||
function Pagination<T>({ table }: { table: TableInstance<T> }) {
|
||||
const pageCount = table.getPageCount();
|
||||
const currentPage = table.getState().pagination.pageIndex;
|
||||
const maxVisiblePages = 10; // 한 번에 보여줄 최대 페이지 수
|
||||
|
||||
let startPage = Math.max(0, currentPage - maxVisiblePages / 2); // 시작 페이지
|
||||
const endPage = Math.min(pageCount, startPage + maxVisiblePages); // 끝 페이지
|
||||
|
||||
if (endPage - startPage < maxVisiblePages) {
|
||||
startPage = Math.max(0, endPage - maxVisiblePages);
|
||||
}
|
||||
|
||||
const pageNumbers = Array(endPage - startPage)
|
||||
.fill(0)
|
||||
.map((_, i) => startPage + i);
|
||||
|
||||
function DataTable<T>({ table, onRowClick }: TableProps<T>) {
|
||||
return (
|
||||
<Table className="w-full">
|
||||
<THead table={table} />
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-muted" : ""}
|
||||
<div className="mt-[24px] flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="hidden size-10 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">첫 페이지로</span>
|
||||
<DoubleArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="size-10 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">이전 페이지로</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
{pageNumbers.map((pageNumber) => (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"inline-block size-10 !bg-transparent p-0 text-foreground hover:!bg-accent",
|
||||
currentPage === pageNumber &&
|
||||
"!bg-primary !font-bold !text-primary-foreground hover:!bg-primary",
|
||||
)}
|
||||
onClick={() => table.setPageIndex(pageNumber)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const { meta } = cell.column.columnDef;
|
||||
return (
|
||||
<TableCell key={cell.id} align="center" {...meta}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
{pageNumber + 1}
|
||||
</Button>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="size-10 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">다음 페이지로</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="hidden size-10 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">마지막 페이지로</span>
|
||||
<DoubleArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
@layer components {
|
||||
.date-picker-trigger {
|
||||
@apply h-[3rem] w-[12rem] justify-between border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal leading-none text-muted-foreground hover:!bg-accent;
|
||||
@apply h-[3rem] w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DatePicker } from ".";
|
||||
import { useState } from "react";
|
||||
|
||||
const meta: Meta<typeof DatePicker> = {
|
||||
title: "Elements/DatePicker",
|
||||
|
||||
@@ -19,12 +19,12 @@ export function DatePicker({ className, ...props }: DatePickerProps) {
|
||||
variant="outline"
|
||||
className={cn("date-picker-trigger", className)}
|
||||
>
|
||||
<CalendarIcon className="!size-[1.6rem]" />
|
||||
{props.selected ? (
|
||||
format(props.selected, "yyyy-MM-dd")
|
||||
) : (
|
||||
<span>날짜 선택</span>
|
||||
)}
|
||||
<CalendarIcon className="!size-[1.6rem]" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../button";
|
||||
import { Calendar } from "../calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
||||
|
||||
type DateRangePickerProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
disabled?: boolean;
|
||||
handleDateChange?: (date: DateRange | undefined) => void;
|
||||
};
|
||||
|
||||
export function DateRangePicker({
|
||||
disabled,
|
||||
className,
|
||||
handleDateChange,
|
||||
}: DateRangePickerProps) {
|
||||
const [date, setDate] = useState<DateRange>();
|
||||
|
||||
const handleSelect = (date: DateRange | undefined) => {
|
||||
setDate(date);
|
||||
handleDateChange?.(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-2", className)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-[3.2rem] min-w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
|
||||
!date && "text-muted-foreground",
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CalendarIcon className="!size-[1.6rem]" />
|
||||
{date?.from ? (
|
||||
date.to ? (
|
||||
<>
|
||||
{format(date.from, "yyyy-MM-dd")} ~{" "}
|
||||
{format(date.to, "yyyy-MM-dd")}
|
||||
</>
|
||||
) : (
|
||||
format(date.from, "yyyy-MM-dd")
|
||||
)
|
||||
) : (
|
||||
<span>기간 선택</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={date?.from}
|
||||
selected={date}
|
||||
onSelect={handleSelect}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.file-search-container {
|
||||
@apply flex w-full max-w-sm items-center gap-2;
|
||||
}
|
||||
.file-search-preview {
|
||||
@apply h-12 w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
|
||||
}
|
||||
.file-search-label {
|
||||
@apply flex-shrink-0 cursor-pointer rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FileSearch } from ".";
|
||||
|
||||
const meta: Meta<typeof FileSearch> = {
|
||||
title: "UI/FileSearch",
|
||||
component: FileSearch,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
- [Docs](https://ui.shadcn.com/docs/components/input#file)
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FileSearch>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <FileSearch id="file-search" />,
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import "./file-search.css";
|
||||
|
||||
import { Input } from "../input";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { combineFileName, validFileType } from "./utils";
|
||||
|
||||
type FileSearchProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
id: string;
|
||||
files?: ReadonlyArray<File>;
|
||||
};
|
||||
|
||||
export function FileSearch({
|
||||
id,
|
||||
className,
|
||||
files,
|
||||
...props
|
||||
}: FileSearchProps) {
|
||||
return (
|
||||
<div className={cn("file-search-container", className)}>
|
||||
<Input id={id} type="file" className="sr-only" {...props} />
|
||||
<div className="file-search-preview">
|
||||
{files?.filter(validFileType).map(combineFileName).join(", ")}
|
||||
</div>
|
||||
<label htmlFor={id} className="file-search-label">
|
||||
파일 선택
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.file-uploader-container {
|
||||
@apply flex h-[3.2rem] w-full max-w-sm items-center gap-2;
|
||||
}
|
||||
.file-uploader-preview {
|
||||
@apply h-full w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
|
||||
}
|
||||
.file-uploader-label {
|
||||
@apply inline-flex h-full flex-shrink-0 cursor-pointer items-center rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FileUploader } from ".";
|
||||
|
||||
const meta: Meta<typeof FileUploader> = {
|
||||
title: "UI/FileUploader",
|
||||
component: FileUploader,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FileUploader>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <FileUploader id="file-uploader" onUpload={() => {}} />,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import "./file-uploader.css";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Input } from "../input";
|
||||
import { Label } from "../label";
|
||||
import { combineFileName, validFileType } from "./utils";
|
||||
|
||||
type FileUploaderProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
id: string;
|
||||
files?: ReadonlyArray<File>;
|
||||
customPreview?: React.ReactNode;
|
||||
onUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function FileUploader({
|
||||
id,
|
||||
files,
|
||||
customPreview,
|
||||
className,
|
||||
onUpload,
|
||||
...props
|
||||
}: FileUploaderProps) {
|
||||
return (
|
||||
<div className={cn("file-uploader-container", className)}>
|
||||
<Input
|
||||
id={id}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={onUpload}
|
||||
{...props}
|
||||
/>
|
||||
{customPreview || (
|
||||
<div className="file-uploader-preview">
|
||||
{files?.filter(validFileType).map(combineFileName).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
<Label htmlFor={id} className="file-uploader-label">
|
||||
파일 선택
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,27 @@ export * from "./accordion";
|
||||
export * from "./badge";
|
||||
export * from "./button";
|
||||
export * from "./calendar";
|
||||
export * from "./carousel";
|
||||
export * from "./checkbox";
|
||||
export * from "./data-table";
|
||||
export * from "./date-picker";
|
||||
export * from "./date-range-picker";
|
||||
export * from "./dialog";
|
||||
export * from "./dropdown-menu";
|
||||
export * from "./file-search";
|
||||
export * from "./file-uploader";
|
||||
export * from "./icon-finder";
|
||||
export * from "./input";
|
||||
export * from "./label";
|
||||
export * from "./layout";
|
||||
export * from "./loading";
|
||||
export * from "./page-title";
|
||||
export * from "./popover";
|
||||
export * from "./progress";
|
||||
export * from "./radio-group";
|
||||
export * from "./select";
|
||||
export * from "./slider";
|
||||
export * from "./skeleton";
|
||||
export * from "./switch";
|
||||
export * from "./table";
|
||||
export * from "./tabs";
|
||||
export * from "./textarea";
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { MenuItem } from "../../api";
|
||||
import { QueryProvider } from "../../provider/query-provider";
|
||||
import { SidebarProvider } from "../sidebar";
|
||||
import { LNB } from "../lnb";
|
||||
import { Header } from "../header";
|
||||
import { Footer } from "./footer";
|
||||
|
||||
import { SidebarProvider } from "../sidebar";
|
||||
import "./common.css";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function BaseLayout({
|
||||
children,
|
||||
queryClient,
|
||||
currentPath,
|
||||
handleMenuClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
queryClient?: QueryClient;
|
||||
currentPath?: string;
|
||||
handleMenuClick?: (item: MenuItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<QueryProvider client={queryClient}>
|
||||
<QueryProvider queryClient={queryClient}>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
<div className="flex h-[calc(100vh-50px)]">
|
||||
<LNB />
|
||||
<LNB currentPath={currentPath} handleMenuClick={handleMenuClick} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
|
||||
{children}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function VBaseLayout({
|
||||
queryClient?: QueryClient;
|
||||
}) {
|
||||
return (
|
||||
<QueryProvider client={queryClient}>
|
||||
<QueryProvider queryClient={queryClient}>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<Header viewOnly />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { cn, setCookie } from "../../lib";
|
||||
import { ENDPOINT, SITE_ID } from "../../config";
|
||||
import { getMenuList, type MenuItem } from "../../api";
|
||||
import {
|
||||
@@ -19,7 +19,15 @@ import {
|
||||
} from "../collapsible";
|
||||
import { getParentIds } from "./lib";
|
||||
|
||||
export function LNB({ viewOnly }: { viewOnly?: boolean }) {
|
||||
export function LNB({
|
||||
viewOnly,
|
||||
currentPath,
|
||||
handleMenuClick,
|
||||
}: {
|
||||
viewOnly?: boolean;
|
||||
currentPath?: string;
|
||||
handleMenuClick?: (item: MenuItem) => void;
|
||||
}) {
|
||||
const { data: menuList } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||
queryFn: () => getMenuList(SITE_ID),
|
||||
@@ -28,14 +36,32 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) {
|
||||
|
||||
const [openMenuIds, setOpenMenuIds] = useState<number[]>([]);
|
||||
|
||||
function isPathMatched(path: string) {
|
||||
if (!path) return false;
|
||||
return currentPath?.startsWith(path);
|
||||
}
|
||||
|
||||
function MenuItem({ item }: { item: MenuItem }) {
|
||||
const isMatched = isPathMatched(item.urlPath);
|
||||
|
||||
const handleClick = () => {
|
||||
setCookie("menu-code-key", item.menuCd.toString());
|
||||
handleMenuClick?.(item);
|
||||
};
|
||||
|
||||
if (item.children) {
|
||||
return <MenuGroup item={item} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent">
|
||||
<SidebarMenuButton
|
||||
className={cn(
|
||||
"h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent",
|
||||
isMatched && "bg-accent !text-primary",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<i className={cn("diveicon ml-2", `di-${item.iconNm}`)} />
|
||||
{item.menuKornNm}
|
||||
</SidebarMenuButton>
|
||||
@@ -44,7 +70,11 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) {
|
||||
}
|
||||
|
||||
function MenuGroup({ item }: { item: MenuItem }) {
|
||||
const isOpen = openMenuIds.includes(item.menuCd);
|
||||
const hasMatchedChild = item.children?.some((child) =>
|
||||
isPathMatched(child.urlPath),
|
||||
);
|
||||
|
||||
const isOpen = openMenuIds.includes(item.menuCd) || hasMatchedChild;
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
@@ -61,15 +91,15 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) {
|
||||
<Collapsible open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="group flex items-center justify-between rounded-sm py-4 pr-2 hover:bg-accent data-[state=open]:text-primary">
|
||||
<div className="group flex items-center justify-between rounded-sm py-4 pr-2 hover:bg-accent">
|
||||
<SidebarMenuButton className="group/menu-button text-[1.4rem] group-data-[state=open]:bg-transparent">
|
||||
<i
|
||||
className={cn(
|
||||
"diveicon ml-2 text-label group-data-[state=open]:text-primary",
|
||||
"diveicon ml-2 text-label",
|
||||
`di-${item.iconNm}`,
|
||||
)}
|
||||
/>
|
||||
<span className="text-label group-hover/menu-button:text-foreground group-data-[state=open]:text-primary">
|
||||
<span className="text-label group-hover/menu-button:text-foreground">
|
||||
{item.menuKornNm}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
const PageTitle = ({
|
||||
title,
|
||||
menuPaths,
|
||||
}: {
|
||||
title: string;
|
||||
menuPaths: string[];
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between px-[1.8rem] pt-[2rem]">
|
||||
<h2 className="text-[2.4rem] font-semibold text-title">{title}</h2>
|
||||
{menuPaths.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<i className="diveicon di-home-fill text-foreground" />
|
||||
{menuPaths?.map((path, i) => (
|
||||
<React.Fragment key={`${i}-${path}`}>
|
||||
{i !== 0 && (
|
||||
<i className="diveicon di-arrow-forward text-foreground" />
|
||||
)}
|
||||
<span className="text-xl text-foreground">{path}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { PageTitle };
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { PageTitle } from ".";
|
||||
|
||||
const meta: Meta<typeof PageTitle> = {
|
||||
title: "UI/PageTitle",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PageTitle>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-[40rem]">
|
||||
<PageTitle title="타이틀" menuPaths={["메뉴 1", "메뉴 2", "메뉴 3"]} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
// apps/cs/src/common/components/ui/pagination.tsx
|
||||
import { Button } from "../button";
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
// 표시할 페이지 번호 범위 계산
|
||||
const getPageRange = () => {
|
||||
const range: number[] = [];
|
||||
const maxButtons = 5; // 한 번에 보여줄 최대 페이지 버튼 수
|
||||
const halfMaxButtons = Math.floor(maxButtons / 2);
|
||||
|
||||
let startPage = Math.max(currentPage - halfMaxButtons, 1);
|
||||
let endPage = Math.min(startPage + maxButtons - 1, totalPages);
|
||||
|
||||
if (endPage - startPage + 1 < maxButtons) {
|
||||
startPage = Math.max(endPage - maxButtons + 1, 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 첫 페이지 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
{"<<"}
|
||||
</Button>
|
||||
|
||||
{/* 이전 페이지 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
{"<"}
|
||||
</Button>
|
||||
|
||||
{/* 페이지 번호 버튼들 */}
|
||||
{getPageRange().map((pageNum) => (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* 다음 페이지 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
{">"}
|
||||
</Button>
|
||||
|
||||
{/* 마지막 페이지 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
{">>"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Pagination };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -3,8 +3,7 @@ import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "../../lib/use-mobile";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { cn, useIsMobile } from "../../lib";
|
||||
import { Button } from "../button";
|
||||
import { Input } from "../input";
|
||||
import { Separator } from "../separator";
|
||||
|
||||
@@ -6,7 +6,7 @@ function Skeleton({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
className={cn("animate-pulse rounded-md bg-[rgba(0,0,0,0.1)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
||||
}
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
({ className, variant = "default", ...props }, ref) => (
|
||||
<div className="relative w-full">
|
||||
<table
|
||||
ref={ref}
|
||||
@@ -79,11 +79,7 @@ const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn("align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<th ref={ref} className={cn("align-middle", className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
@@ -93,10 +89,7 @@ const TableCell = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"align-middle text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
className={cn("align-middle text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,38 +1,50 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.table-ui tr {
|
||||
@apply border-0;
|
||||
}
|
||||
.table-ui th {
|
||||
@apply bg-[#5987bf] px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2] text-[#fff];
|
||||
@apply px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2];
|
||||
}
|
||||
.table-ui td {
|
||||
@apply border-b border-b-border px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2] text-label;
|
||||
@apply px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2];
|
||||
}
|
||||
|
||||
.table-ui.default th {
|
||||
@apply bg-[#5987bf] text-[#fff];
|
||||
}
|
||||
.table-ui.default td {
|
||||
@apply border-b border-b-border text-label;
|
||||
}
|
||||
.table-ui.secondary th {
|
||||
@apply border border-border bg-[#eaf3fb] text-[#647784];
|
||||
}
|
||||
|
||||
.table-ui.secondary td {
|
||||
@apply border border-border;
|
||||
}
|
||||
|
||||
.table-ui.line th {
|
||||
@apply border-0 border-b-2 border-t border-b-[#d4e0ea] border-t-[#d4e0ea] bg-[#fff] text-[#555];
|
||||
.table-ui.line tr {
|
||||
@apply border-b border-t border-[#d4e0ea] bg-transparent;
|
||||
}
|
||||
|
||||
.table-ui.line td {
|
||||
@apply border-b border-b-border;
|
||||
.table-ui.line th {
|
||||
@apply border-b-2 border-[#d4e0ea] text-[#555];
|
||||
}
|
||||
|
||||
[data-theme="black"] {
|
||||
.table-ui th {
|
||||
@apply bg-[#5e7b9c] text-[#fff];
|
||||
.table-ui.default tr {
|
||||
@apply bg-[#545556];
|
||||
}
|
||||
.table-ui.default th {
|
||||
@apply bg-[#62686d];
|
||||
}
|
||||
.table-ui.secondary tr {
|
||||
@apply bg-[#565b5f];
|
||||
}
|
||||
.table-ui.secondary th {
|
||||
@apply bg-[#1a1c1d];
|
||||
@apply bg-[#5e7b9c] text-[#fff];
|
||||
}
|
||||
.table-ui.line th {
|
||||
@apply bg-[#0a0a0a];
|
||||
@apply text-[#fff];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[6rem] w-full rounded-md border border-input bg-transparent p-3 text-base placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user