Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// DASHBOARD is one navigation mode. Slots contain Dashboard-owned Header/Menu/Sidebar, never external GNB or general LNB patterns.
|
||||
// DashboardMain owns vertical scrolling; the outer frame scrolls the entire Dashboard horizontally below 960px (1232px with sidebar).
|
||||
// Dashboard navigation keeps its geometry at every viewport width, independent of the GNB/LNB responsive policies.
|
||||
|
||||
type DashboardLayoutProps = Omit<React.ComponentProps<"div">, "children"> & {
|
||||
/** Dashboard navigation header; do not pass a GNB. */
|
||||
header?: React.ReactNode
|
||||
/** Dashboard navigation sidebar; do not pass a general LNB. */
|
||||
sidebar?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function DashboardLayout({ header, sidebar, className, children, ...props }: DashboardLayoutProps) {
|
||||
return (
|
||||
<div role="region" aria-label="Dashboard" tabIndex={0} className={cn(className)} {...props} data-slot="dashboard-scroll" data-navigation-mode="DASHBOARD">
|
||||
<div
|
||||
data-slot="dashboard-layout"
|
||||
data-header={header ? "" : undefined}
|
||||
data-sidebar={sidebar ? "" : undefined}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{header}
|
||||
<div data-slot="dashboard-body" className="flex">
|
||||
{sidebar}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Internal navigation elements: share Semantic tokens, not GNB/LNB layout or responsive variables.
|
||||
function DashboardHeader({ className, ...props }: React.ComponentProps<"header">) {
|
||||
return <header className={cn(className)} {...props} data-slot="dashboard-header" />
|
||||
}
|
||||
|
||||
function DashboardSidebar({ className, ...props }: React.ComponentProps<"aside">) {
|
||||
return <aside className={cn("flex flex-col", className)} {...props} data-slot="dashboard-sidebar" />
|
||||
}
|
||||
|
||||
// The content surface and vertical scroll container of the screen (`<main>`). `min-width: 0` keeps wide children (tables, carousels,
|
||||
// comboboxes) from widening the page; they scroll or shrink inside it.
|
||||
function DashboardMain({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return <main data-slot="dashboard-main" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
// Vertical stack of sections (cards, Filter Bar + Data Table...) with the layout gap. It styles no section itself.
|
||||
function DashboardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="dashboard-content" className={cn("grid", className)} {...props} />
|
||||
}
|
||||
|
||||
export { DashboardContent, DashboardHeader, DashboardLayout, DashboardMain, DashboardSidebar }
|
||||
export type { DashboardLayoutProps }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DashboardContent, DashboardHeader, DashboardLayout, DashboardMain, DashboardSidebar } from "./dashboard-layout"
|
||||
export type { DashboardLayoutProps } from "./dashboard-layout"
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Runtime-neutral Dashboard work status contract.
|
||||
*
|
||||
* This file intentionally has no React or DOM dependency. React, Static HTML,
|
||||
* and other runtimes consume the same ids, labels, matching rules, and
|
||||
* `data-*` state vocabulary.
|
||||
*/
|
||||
export type DashboardStatusId = "active" | "hold" | "done"
|
||||
export type DashboardFilterId = "all" | DashboardStatusId | "issue"
|
||||
export type DashboardStatusTone = "progress" | "hold" | "complete" | "issue"
|
||||
|
||||
export type DashboardStatusDefinition = {
|
||||
id: DashboardStatusId
|
||||
label: string
|
||||
tone: DashboardStatusTone
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
export type DashboardFilterDefinition =
|
||||
| { id: "all"; label: string; ariaLabel: string }
|
||||
| (DashboardStatusDefinition & { id: DashboardStatusId })
|
||||
| { id: "issue"; label: string; tone: "issue"; ariaLabel: string; independentFlag: true }
|
||||
|
||||
export const dashboardStatusDefinitions: Record<DashboardStatusId, DashboardStatusDefinition> = {
|
||||
active: { id: "active", label: "진행", tone: "progress", ariaLabel: "진행 상태" },
|
||||
hold: { id: "hold", label: "보류", tone: "hold", ariaLabel: "보류 상태" },
|
||||
done: { id: "done", label: "완료", tone: "complete", ariaLabel: "완료 상태" },
|
||||
}
|
||||
|
||||
export const dashboardFilterDefinitions: Record<DashboardFilterId, DashboardFilterDefinition> = {
|
||||
all: { id: "all", label: "전체", ariaLabel: "전체 업무" },
|
||||
...dashboardStatusDefinitions,
|
||||
issue: { id: "issue", label: "이슈", tone: "issue", ariaLabel: "이슈 업무", independentFlag: true },
|
||||
}
|
||||
|
||||
export const dashboardFilterIds: DashboardFilterId[] = ["all", "active", "hold", "done", "issue"]
|
||||
|
||||
export type DashboardStatusRecord = { status: DashboardStatusId; issue?: boolean }
|
||||
|
||||
export function matchesDashboardFilter(record: DashboardStatusRecord, filter: DashboardFilterId): boolean {
|
||||
return filter === "all" || (filter === "issue" ? record.issue === true : record.status === filter)
|
||||
}
|
||||
|
||||
export function countDashboardFilter<T extends DashboardStatusRecord>(records: readonly T[], filter: DashboardFilterId): number {
|
||||
return records.filter((record) => matchesDashboardFilter(record, filter)).length
|
||||
}
|
||||
|
||||
/** Stable DOM vocabulary for React, Static HTML, and other adapters. */
|
||||
export const dashboardStatusAttributes = {
|
||||
status: "data-status",
|
||||
issue: "data-issue",
|
||||
filter: "data-filter",
|
||||
selected: "aria-pressed",
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Runtime-neutral compatibility contract for the existing Dashboard Story status colors.
|
||||
These are not public Theme tokens. They preserve the approved Story values until
|
||||
a Production-wide semantic status palette is explicitly introduced. */
|
||||
:root {
|
||||
--hm-dashboard-status-active: #00c896;
|
||||
--hm-dashboard-status-hold: #ff9500;
|
||||
--hm-dashboard-status-issue: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Resolve the Theme-dependent alias in each independent HM Theme scope. */
|
||||
:root, [data-hm-theme] {
|
||||
--hm-dashboard-status-done: var(--hm-sem-palette-white);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
countDashboardFilter,
|
||||
dashboardFilterDefinitions,
|
||||
dashboardFilterIds,
|
||||
dashboardStatusAttributes,
|
||||
dashboardStatusDefinitions,
|
||||
matchesDashboardFilter,
|
||||
} from "./contract"
|
||||
export type { DashboardFilterDefinition, DashboardFilterId, DashboardStatusDefinition, DashboardStatusId, DashboardStatusRecord, DashboardStatusTone } from "./contract"
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import {
|
||||
columnFilteringFeature,
|
||||
columnVisibilityFeature,
|
||||
createFilteredRowModel,
|
||||
createPaginatedRowModel,
|
||||
createSortedRowModel,
|
||||
filterFn_includesString,
|
||||
filterFn_equalsString,
|
||||
globalFilteringFeature,
|
||||
rowPaginationFeature,
|
||||
rowSelectionFeature,
|
||||
rowSortingFeature,
|
||||
sortFn_alphanumeric,
|
||||
sortFn_basic,
|
||||
sortFn_datetime,
|
||||
tableFeatures,
|
||||
useTable,
|
||||
type Column,
|
||||
type ColumnDef,
|
||||
type ReactTable,
|
||||
type RowData,
|
||||
type TableOptions,
|
||||
} from "@tanstack/react-table"
|
||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Checkbox } from "../../components/ui/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../components/ui/dropdown-menu"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "../../components/ui/pagination"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"
|
||||
import { Skeleton } from "../../components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||
|
||||
// Data Table pattern: the HM Table primitive (presentation only) driven by TanStack Table v9. The table state model (sorting, filters,
|
||||
// selection, pagination, column visibility) is TanStack's own; this file registers the features and composes the HM primitives.
|
||||
// Cell / header looks stay in src/theme/table.css; only the toolbar, footer, empty and loading wrappers are styled here (src/theme/data-table.css).
|
||||
const dataTableFeatures = tableFeatures({
|
||||
columnFilteringFeature,
|
||||
globalFilteringFeature,
|
||||
rowSortingFeature,
|
||||
rowSelectionFeature,
|
||||
rowPaginationFeature,
|
||||
columnVisibilityFeature,
|
||||
filteredRowModel: createFilteredRowModel(),
|
||||
sortedRowModel: createSortedRowModel(),
|
||||
paginatedRowModel: createPaginatedRowModel(),
|
||||
filterFns: { includesString: filterFn_includesString, equalsString: filterFn_equalsString },
|
||||
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic, datetime: sortFn_datetime },
|
||||
})
|
||||
|
||||
type DataTableFeatures = typeof dataTableFeatures
|
||||
type DataTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<DataTableFeatures, TData, TValue>
|
||||
type DataTableInstance<TData extends RowData> = ReactTable<DataTableFeatures, TData>
|
||||
type DataTableOptions<TData extends RowData> = Omit<TableOptions<DataTableFeatures, TData>, "features">
|
||||
|
||||
// Keep `columns` and `data` referentially stable (module scope, useMemo or state): a new array each render rebuilds every row model.
|
||||
function useDataTable<TData extends RowData>(options: DataTableOptions<TData>): DataTableInstance<TData> {
|
||||
return useTable({ features: dataTableFeatures, ...options } as TableOptions<DataTableFeatures, TData>)
|
||||
}
|
||||
|
||||
// Selection column: header checkbox (all rows of the page, mixed when some) and one checkbox per row, both the HM Checkbox.
|
||||
function dataTableSelectColumn<TData extends RowData>(): DataTableColumnDef<TData, unknown> {
|
||||
return {
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
aria-label="현재 페이지 전체 선택"
|
||||
checked={table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? "indeterminate" : false}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(value === true)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox aria-label="행 선택" checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(value === true)} />
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Sortable header: a button that cycles none → asc → desc → none (TanStack's own order). The sort state is announced by `aria-sort`
|
||||
// on the <th>, which DataTable sets from the column.
|
||||
function DataTableColumnHeader<TData extends RowData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: Column<DataTableFeatures, TData, TValue>
|
||||
title: string
|
||||
className?: string
|
||||
}) {
|
||||
if (!column.getCanSort()) return <span className={className}>{title}</span>
|
||||
const sorted = column.getIsSorted()
|
||||
const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : ArrowUpDownIcon
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="data-table-sort"
|
||||
data-sorted={sorted || undefined}
|
||||
className={cn("inline-flex items-center", className)}
|
||||
onClick={() => (sorted === "desc" ? column.clearSorting() : column.toggleSorting(sorted === "asc"))}
|
||||
aria-label={`${title} 정렬${sorted === "asc" ? ", 오름차순" : sorted === "desc" ? ", 내림차순" : ""}`}
|
||||
>
|
||||
{title}
|
||||
<Icon aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Column visibility menu: the HM Dropdown Menu with one checkbox item per hideable column (labelled from `labels[column.id]`, else the column id).
|
||||
function DataTableViewOptions<TData extends RowData>({
|
||||
table,
|
||||
label = "열",
|
||||
labels,
|
||||
className,
|
||||
}: {
|
||||
table: DataTableInstance<TData>
|
||||
label?: string
|
||||
/** Menu text per column id (a column header can be a component, so it cannot be read back). */
|
||||
labels?: Record<string, string>
|
||||
className?: string
|
||||
}) {
|
||||
const columns = table.getAllLeafColumns().filter((column) => column.getCanHide())
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="primary" appearance="outline" className={className}>
|
||||
{label}
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuLabel>표시할 열</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{columns.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(value)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{labels?.[column.id] ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Page list with ellipses: 1 … (p-1 p p+1) … total.
|
||||
function pageRange(page: number, total: number): (number | "start" | "end")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
const out: (number | "start" | "end")[] = [1]
|
||||
const from = Math.max(2, Math.min(page - 1, total - 4))
|
||||
const to = Math.min(total - 1, Math.max(page + 1, 5))
|
||||
if (from > 2) out.push("start")
|
||||
for (let p = from; p <= to; p++) out.push(p)
|
||||
if (to < total - 1) out.push("end")
|
||||
out.push(total)
|
||||
return out
|
||||
}
|
||||
|
||||
// Footer: selected count, page size (HM Select) and the HM Pagination bound to the TanStack page state.
|
||||
function DataTablePagination<TData extends RowData>({
|
||||
table,
|
||||
pageSizeOptions = [10, 20, 50],
|
||||
className,
|
||||
}: {
|
||||
table: DataTableInstance<TData>
|
||||
pageSizeOptions?: number[]
|
||||
className?: string
|
||||
}) {
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
const pageCount = Math.max(table.getPageCount(), 1)
|
||||
const page = pageIndex + 1
|
||||
const selected = table.getFilteredSelectedRowModel().rows.length
|
||||
const total = table.getFilteredRowModel().rows.length
|
||||
const go = (next: number) => (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
table.setPageIndex(next - 1)
|
||||
}
|
||||
return (
|
||||
<div data-slot="data-table-footer" className={className}>
|
||||
<p data-slot="data-table-summary" role="status">
|
||||
{selected > 0 ? `${selected}개 선택됨 · 전체 ${total}개` : `전체 ${total}개`}
|
||||
</p>
|
||||
<div data-slot="data-table-page-size">
|
||||
<span id="data-table-page-size-label">페이지당</span>
|
||||
<Select value={String(pageSize)} onValueChange={(value) => table.setPageSize(Number(value))}>
|
||||
<SelectTrigger size="medium" aria-labelledby="data-table-page-size-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}개
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Pagination data-slot="data-table-pagination">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" aria-disabled={!table.getCanPreviousPage()} onClick={go(page - 1)} />
|
||||
</PaginationItem>
|
||||
{pageRange(page, pageCount).map((entry) =>
|
||||
typeof entry === "number" ? (
|
||||
<PaginationItem key={entry}>
|
||||
<PaginationLink href="#" isActive={entry === page} onClick={go(entry)}>
|
||||
{entry}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={entry}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" aria-disabled={!table.getCanNextPage()} onClick={go(page + 1)} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DataTableProps<TData extends RowData> = {
|
||||
/** The table instance from `useDataTable`. */
|
||||
table: DataTableInstance<TData>
|
||||
/** Shows skeleton rows instead of data (the header stays). */
|
||||
loading?: boolean
|
||||
/** Shown in one full-width row when there are no rows (no data, or every row filtered out). */
|
||||
emptyMessage?: React.ReactNode
|
||||
/** Footer with selected count, page size and Pagination. Default true. */
|
||||
pagination?: boolean
|
||||
pageSizeOptions?: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataTable<TData extends RowData>({
|
||||
table,
|
||||
loading = false,
|
||||
emptyMessage = "표시할 데이터가 없습니다.",
|
||||
pagination = true,
|
||||
pageSizeOptions,
|
||||
className,
|
||||
}: DataTableProps<TData>) {
|
||||
const rows = table.getRowModel().rows
|
||||
const columnCount = table.getVisibleLeafColumns().length
|
||||
const skeletonRows = Math.min(table.state.pagination.pageSize, 5)
|
||||
return (
|
||||
<div data-slot="data-table" className={className}>
|
||||
<Table aria-busy={loading || undefined}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
{group.headers.map((header) => {
|
||||
const sorted = header.column.getIsSorted()
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
|
||||
>
|
||||
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: skeletonRows }, (_, index) => (
|
||||
<TableRow key={index}>
|
||||
{table.getVisibleLeafColumns().map((column) => (
|
||||
<TableCell key={column.id}>
|
||||
<Skeleton data-slot="data-table-skeleton" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : rows.length ? (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<table.FlexRender cell={cell} />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={Math.max(columnCount, 1)} data-slot="data-table-empty">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{pagination ? <DataTablePagination table={table} pageSizeOptions={pageSizeOptions} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
DataTablePagination,
|
||||
DataTableViewOptions,
|
||||
dataTableSelectColumn,
|
||||
useDataTable,
|
||||
}
|
||||
export type { DataTableColumnDef, DataTableInstance, DataTableOptions, DataTableProps }
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
DataTablePagination,
|
||||
DataTableViewOptions,
|
||||
dataTableSelectColumn,
|
||||
useDataTable,
|
||||
} from "./data-table"
|
||||
export type { DataTableColumnDef, DataTableInstance, DataTableOptions, DataTableProps } from "./data-table"
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { SearchIcon, XIcon } from "lucide-react"
|
||||
|
||||
import { Input, type InputSize } from "../../components/ui/input"
|
||||
|
||||
// Filter Bar pattern: the search / filter condition row of a list screen. It is a layout (flex, wrap, control widths) plus one helper;
|
||||
// the controls inside are the HM Input / Select / Combobox / Date Picker / Button themselves, composed as children, and the consumer
|
||||
// owns the filter values. It has no dependency on Data Table. The wrapper adds no role (each control keeps its own semantics).
|
||||
// <FilterBar>
|
||||
// <FilterBarSearch value onValueChange />
|
||||
// <Select>…</Select> <Combobox … /> <DatePicker … />
|
||||
// <FilterBarActions><Button onClick={reset}>초기화</Button></FilterBarActions>
|
||||
// </FilterBar>
|
||||
function FilterBar({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="filter-bar" className={cn("flex flex-wrap items-center", className)} {...props} />
|
||||
}
|
||||
|
||||
type FilterBarSearchProps = Omit<React.ComponentProps<"input">, "size" | "value" | "defaultValue" | "onChange" | "type"> & {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
size?: InputSize
|
||||
}
|
||||
|
||||
// Search icon + HM Input + a clear button once there is text. `aria-label` defaults to the placeholder so the field always has a name.
|
||||
function FilterBarSearch({
|
||||
className,
|
||||
value,
|
||||
onValueChange,
|
||||
size = "medium",
|
||||
placeholder = "검색",
|
||||
"aria-label": ariaLabel,
|
||||
...props
|
||||
}: FilterBarSearchProps) {
|
||||
return (
|
||||
<div data-slot="filter-bar-search" className={cn("relative", className)}>
|
||||
<SearchIcon aria-hidden="true" data-slot="filter-bar-search-icon" className="pointer-events-none absolute" />
|
||||
<Input
|
||||
type="search"
|
||||
size={size}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel ?? placeholder}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
{value ? (
|
||||
<button type="button" data-slot="filter-bar-search-clear" aria-label="검색어 지우기" className="absolute" onClick={() => onValueChange("")}>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pushes its content (Reset, Apply, ...) to the end of the row; on a narrow screen it wraps to its own line.
|
||||
function FilterBarActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="filter-bar-actions" className={cn("flex items-center", className)} {...props} />
|
||||
}
|
||||
|
||||
export { FilterBar, FilterBarActions, FilterBarSearch }
|
||||
export type { FilterBarSearchProps }
|
||||
@@ -0,0 +1 @@
|
||||
export { FilterBar, FilterBarActions, FilterBarSearch } from "./filter-bar"
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { MenuIcon } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "../../components/ui/sheet"
|
||||
|
||||
// GNB pattern: the global bar of a service (brand, primary navigation, utilities). It owns the bar (height, surface, border, padding,
|
||||
// gaps, the desktop / mobile switch) and nothing inside it: the primary navigation is the HM Navigation Menu, the user menu is
|
||||
// Dropdown Menu + Avatar, the mobile menu is a Sheet, icon actions are Buttons with Tooltip / Badge / Popover. All of them are composed
|
||||
// as children and keep their own look and behavior. No menu data lives here; the consumer renders its own items.
|
||||
// <GNB>
|
||||
// <GNBMobileMenu>…<GNBMobileLink href active>…</GNBMobileMenu> (mobile only: hamburger → Sheet)
|
||||
// <GNBBrand href="/">…</GNBBrand>
|
||||
// <GNBNav><NavigationMenu viewport={false} aria-label="주 메뉴">…</NavigationMenu></GNBNav> (desktop only)
|
||||
// <GNBUtilities>… <DropdownMenu><DropdownMenuTrigger asChild><GNBUser name description><Avatar/></GNBUser></…></DropdownMenu></GNBUtilities>
|
||||
// </GNB>
|
||||
// Position is static by default; `sticky` pins it to the top of the scroll container. It is never fixed: the page layout decides.
|
||||
// The bar height is exposed as `--hm-gnb-height` for the content layout of GNB Mode.
|
||||
function GNB({ className, sticky = false, ...props }: React.ComponentProps<"header"> & { sticky?: boolean }) {
|
||||
return <header data-slot="gnb" data-sticky={sticky ? "" : undefined} className={className} {...props} />
|
||||
}
|
||||
|
||||
// Brand link, horizontal: an optional `img` / `svg` logo (only when you have a logo asset; no placeholder, no empty space) then one
|
||||
// `<span>` with an optional `<small>` eyebrow and a `<strong>` name; both shorten with an ellipsis when the bar is tight.
|
||||
// `asChild` renders the consumer's router link.
|
||||
function GNBBrand({ className, asChild = false, ...props }: React.ComponentProps<"a"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
return <Comp data-slot="gnb-brand" className={cn("flex items-center", className)} {...props} />
|
||||
}
|
||||
|
||||
// Primary navigation region (desktop only; hidden below the mobile breakpoint), centered on the whole bar. It holds the HM
|
||||
// `<NavigationMenu viewport={false} aria-label>`, which already renders the `<nav>` landmark, so this wrapper adds no role.
|
||||
function GNBNav({ className, ref, ...props }: React.ComponentProps<"div">) {
|
||||
const region = React.useRef<HTMLDivElement>(null)
|
||||
React.useImperativeHandle(ref, () => region.current!, [])
|
||||
React.useLayoutEffect(() => {
|
||||
const node = region.current
|
||||
if (!node) return
|
||||
const view = node.ownerDocument.defaultView!
|
||||
const desktop = view.matchMedia("(min-width: 1024px)")
|
||||
const resize = new ResizeObserver(() => alignPanels())
|
||||
const panels = new Set<HTMLElement>()
|
||||
const alignPanels = () => {
|
||||
if (!desktop.matches) return
|
||||
const gnb = node.closest<HTMLElement>('[data-slot="gnb"]')!
|
||||
const edge = parseFloat(view.getComputedStyle(gnb).paddingLeft)
|
||||
node.querySelectorAll<HTMLElement>('[data-slot="navigation-menu-content"]').forEach((panel) => {
|
||||
if (!panels.has(panel)) { panels.add(panel); resize.observe(panel) }
|
||||
// Use layout geometry, unaffected by Radix's opening scale animation or our correction.
|
||||
const parent = panel.offsetParent as HTMLElement | null
|
||||
if (!parent) return
|
||||
const trigger = parent.querySelector<HTMLElement>('[data-slot="navigation-menu-trigger"]')
|
||||
if (!trigger) return
|
||||
const parentLeft = parent.getBoundingClientRect().left + parent.clientLeft
|
||||
const triggerBounds = trigger.getBoundingClientRect()
|
||||
const centeredLeft = triggerBounds.left + (triggerBounds.width - panel.offsetWidth) / 2
|
||||
const left = Math.max(edge, Math.min(centeredLeft, node.ownerDocument.documentElement.clientWidth - edge - panel.offsetWidth))
|
||||
const offset = left - parentLeft
|
||||
const value = `${offset}px`
|
||||
if (panel.style.getPropertyValue("--hm-gnb-panel-offset-x") !== value) {
|
||||
panel.style.setProperty("--hm-gnb-panel-offset-x", value)
|
||||
}
|
||||
// Integer layout widths ignore equal-panel fractional measurement noise.
|
||||
// Reuse the same clamped geometry; joins may extend on only one side.
|
||||
const wider = panel.offsetWidth > trigger.offsetWidth
|
||||
const extensions = {
|
||||
"--hm-gnb-panel-left-extension": wider ? Math.max(0, triggerBounds.left - left) : 0,
|
||||
"--hm-gnb-panel-right-extension": wider ? Math.max(0, left + panel.offsetWidth - triggerBounds.right) : 0,
|
||||
}
|
||||
Object.entries(extensions).forEach(([property, extension]) => {
|
||||
const value = `${extension}px`
|
||||
if (trigger.style.getPropertyValue(property) !== value) trigger.style.setProperty(property, value)
|
||||
})
|
||||
})
|
||||
panels.forEach((panel) => { if (!node.contains(panel)) { resize.unobserve(panel); panels.delete(panel) } })
|
||||
}
|
||||
// NavigationMenu has no collision-positioning API. Observe only the GNB region;
|
||||
// Radix still owns disclosure, focus and keyboard navigation.
|
||||
const mutation = new MutationObserver(alignPanels)
|
||||
mutation.observe(node, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state"] })
|
||||
resize.observe(node)
|
||||
view.addEventListener("resize", alignPanels)
|
||||
desktop.addEventListener("change", alignPanels)
|
||||
alignPanels()
|
||||
return () => {
|
||||
mutation.disconnect()
|
||||
resize.disconnect()
|
||||
view.removeEventListener("resize", alignPanels)
|
||||
desktop.removeEventListener("change", alignPanels)
|
||||
panels.forEach((panel) => panel.style.removeProperty("--hm-gnb-panel-offset-x"))
|
||||
node.querySelectorAll<HTMLElement>('[data-slot="navigation-menu-trigger"]').forEach((trigger) => {
|
||||
trigger.style.removeProperty("--hm-gnb-panel-left-extension")
|
||||
trigger.style.removeProperty("--hm-gnb-panel-right-extension")
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
return <div ref={region} data-slot="gnb-nav" className={cn("flex items-center", className)} {...props} />
|
||||
}
|
||||
|
||||
// Right-aligned utility actions (search, notification, user menu...). Plain container; the order and the set are the consumer's.
|
||||
function GNBUtilities({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="gnb-utilities" className={cn("flex items-center", className)} {...props} />
|
||||
}
|
||||
|
||||
type GNBUserProps = Omit<React.ComponentProps<"button">, "children"> & {
|
||||
name: string
|
||||
description?: string
|
||||
/** The Avatar (mobile bar only). */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
// Trigger button of the user menu: name / role text on the desktop bar; the Avatar (children) shows on the mobile bar only, where the
|
||||
// text is hidden (ui-viz's profile is text-only, so the desktop bar has no image). Because of that the
|
||||
// accessible name defaults to "<name> 사용자 메뉴". Use it as `<DropdownMenuTrigger asChild>`; the data-slot comes after the
|
||||
// spread so the trigger's own slot does not replace it (same rule as Button).
|
||||
function GNBUser({ className, name, description, children, "aria-label": ariaLabel, ...props }: GNBUserProps) {
|
||||
return (
|
||||
<button type="button" aria-label={ariaLabel ?? `${name} 사용자 메뉴`} className={cn("flex items-center", className)} {...props} data-slot="gnb-user">
|
||||
{children}
|
||||
<span data-slot="gnb-user-text">
|
||||
<span data-slot="gnb-user-name">{name}</span>
|
||||
{description ? <span data-slot="gnb-user-description">{description}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type GNBMobileMenuProps = React.ComponentProps<typeof Sheet> & {
|
||||
/** Names the trigger button, the Sheet title and the navigation. */
|
||||
title?: string
|
||||
/** Sheet side. */
|
||||
side?: "left" | "right"
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mobile navigation: a hamburger button (mobile only) that opens an HM Sheet with the destinations as plain links. It replaces the
|
||||
// desktop Navigation Menu on a narrow screen instead of squeezing it. Radix keeps the focus trap, Escape and focus return.
|
||||
// `GNBMobileLink` closes it on click. `open` / `defaultOpen` / `onOpenChange` work as on Sheet.
|
||||
const GNBMobileMenuContext = React.createContext<{ close: () => void } | null>(null)
|
||||
|
||||
function GNBMobileMenu({ title = "메뉴", side = "left", className, children, open: openProp, defaultOpen = false, onOpenChange, ...props }: GNBMobileMenuProps) {
|
||||
const [openState, setOpenState] = React.useState(defaultOpen)
|
||||
const open = openProp ?? openState
|
||||
const setOpen = (next: boolean) => {
|
||||
if (openProp === undefined) setOpenState(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
const context = React.useMemo(() => ({ close: () => setOpen(false) }), [openProp, onOpenChange])
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen} {...props}>
|
||||
<SheetTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={title} className={cn("lg:hidden", className)}>
|
||||
<MenuIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side={side} aria-describedby={undefined}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
<GNBMobileMenuContext.Provider value={context}>
|
||||
<nav data-slot="gnb-mobile-nav" aria-label={title}>
|
||||
{children}
|
||||
</nav>
|
||||
</GNBMobileMenuContext.Provider>
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// A destination link inside the mobile menu. `active` marks the current location (`aria-current="page"`). It closes the Sheet on click
|
||||
// even when the consumer's router handler calls preventDefault (a Radix Close would skip a prevented click), so it must be used inside
|
||||
// `GNBMobileMenu`. `asChild` renders the consumer's router link.
|
||||
function GNBMobileLink({
|
||||
className,
|
||||
active = false,
|
||||
asChild = false,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & { active?: boolean; asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
const menu = React.useContext(GNBMobileMenuContext)
|
||||
return (
|
||||
<Comp
|
||||
data-slot="gnb-mobile-link"
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn("flex items-center", className)}
|
||||
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event)
|
||||
menu?.close()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// A titled group of links in the mobile menu (the second level of a desktop Navigation Menu content).
|
||||
function GNBMobileGroup({ className, label, children, ...props }: Omit<React.ComponentProps<"div">, "children"> & { label: string; children: React.ReactNode }) {
|
||||
const labelId = React.useId()
|
||||
return (
|
||||
<div data-slot="gnb-mobile-group" role="group" aria-labelledby={labelId} className={cn(className)} {...props}>
|
||||
<p data-slot="gnb-mobile-group-label" id={labelId}>
|
||||
{label}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { GNB, GNBBrand, GNBMobileGroup, GNBMobileLink, GNBMobileMenu, GNBNav, GNBUser, GNBUtilities }
|
||||
export type { GNBMobileMenuProps, GNBUserProps }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GNB, GNBBrand, GNBMobileGroup, GNBMobileLink, GNBMobileMenu, GNBNav, GNBUser, GNBUtilities } from "./gnb"
|
||||
export type { GNBMobileMenuProps, GNBUserProps } from "./gnb"
|
||||
@@ -0,0 +1,5 @@
|
||||
export { LNB, LNBBrand, LNBGroup, LNBItem, LNBNav, LNBProfile } from "./lnb"
|
||||
export type { LNBGroupProps, LNBProfileProps } from "./lnb"
|
||||
|
||||
export { LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink, LNBMobileGroup } from "./lnb-mobile"
|
||||
export type { LNBMobileMenuProps } from "./lnb-mobile"
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { MenuIcon } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "../../components/ui/sheet"
|
||||
|
||||
// Responsive representation of LNB Mode. Hidden at >=1024px; shares destinations and state with the desktop LNB.
|
||||
function LNBMobileHeader({ className, ...props }: React.ComponentProps<"header">) {
|
||||
return <header className={cn(className)} {...props} data-slot="lnb-mobile-header" />
|
||||
}
|
||||
|
||||
function LNBMobileBrand({ className, asChild = false, ...props }: React.ComponentProps<"a"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
return <Comp className={cn("flex items-center", className)} {...props} data-slot="lnb-mobile-brand" />
|
||||
}
|
||||
|
||||
type LNBMobileMenuProps = React.ComponentProps<typeof Sheet> & {
|
||||
/** Names the trigger button, the Sheet title and the navigation. */
|
||||
title?: string
|
||||
/** Sheet side. */
|
||||
side?: "left" | "right"
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mobile navigation: a hamburger button (mobile only) that opens an HM Sheet with the destinations as plain links. It replaces the
|
||||
// desktop LNB on a narrow screen instead of squeezing it. Radix keeps the focus trap, Escape and focus return.
|
||||
// `LNBMobileLink` closes it on click. `open` / `defaultOpen` / `onOpenChange` work as on Sheet.
|
||||
const LNBMobileMenuContext = React.createContext<{ close: () => void } | null>(null)
|
||||
|
||||
function LNBMobileMenu({ title = "메뉴", side = "left", className, children, open: openProp, defaultOpen = false, onOpenChange, ...props }: LNBMobileMenuProps) {
|
||||
const [openState, setOpenState] = React.useState(defaultOpen)
|
||||
const [mobile, setMobile] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
const media = window.matchMedia("(max-width: 1023px)")
|
||||
const sync = () => setMobile(media.matches)
|
||||
sync()
|
||||
media.addEventListener("change", sync)
|
||||
return () => media.removeEventListener("change", sync)
|
||||
}, [])
|
||||
const open = openProp ?? openState
|
||||
const setOpen = (next: boolean) => {
|
||||
if (openProp === undefined) setOpenState(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
const context = React.useMemo(() => ({ close: () => setOpen(false) }), [openProp, onOpenChange])
|
||||
return (
|
||||
<Sheet open={mobile && open} onOpenChange={setOpen} {...props}>
|
||||
<SheetTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={title} className={cn("lg:hidden", className)}>
|
||||
<MenuIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side={side} aria-describedby={undefined}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
<LNBMobileMenuContext.Provider value={context}>
|
||||
<nav data-slot="lnb-mobile-nav" aria-label={title}>
|
||||
{children}
|
||||
</nav>
|
||||
</LNBMobileMenuContext.Provider>
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// A destination link inside the mobile menu. `active` marks the current location (`aria-current="page"`). It closes the Sheet on click
|
||||
// even when the consumer's router handler calls preventDefault (a Radix Close would skip a prevented click), so it must be used inside
|
||||
// `LNBMobileMenu`. `asChild` renders the consumer's router link.
|
||||
function LNBMobileLink({
|
||||
className,
|
||||
active = false,
|
||||
asChild = false,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & { active?: boolean; asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
const menu = React.useContext(LNBMobileMenuContext)
|
||||
return (
|
||||
<Comp
|
||||
data-slot="lnb-mobile-link"
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn("flex items-center", className)}
|
||||
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event)
|
||||
menu?.close()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// A titled group of links in the mobile representation of the desktop LNB tree.
|
||||
function LNBMobileGroup({ className, label, children, ...props }: Omit<React.ComponentProps<"div">, "children"> & { label: string; children: React.ReactNode }) {
|
||||
const labelId = React.useId()
|
||||
return (
|
||||
<div data-slot="lnb-mobile-group" role="group" aria-labelledby={labelId} className={cn(className)} {...props}>
|
||||
<p data-slot="lnb-mobile-group-label" id={labelId}>
|
||||
{label}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export { LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink, LNBMobileGroup }
|
||||
export type { LNBMobileMenuProps }
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../components/ui/collapsible"
|
||||
|
||||
// Internal composition depth; consumers keep the existing nested-group API.
|
||||
const LNBGroupDepthContext = React.createContext(0)
|
||||
|
||||
// LNB pattern: the local navigation column of a service (brand, menu, profile). Layout visual reference is the ui-viz LNB; the parts
|
||||
// are plain elements plus the HM Collapsible for 2-depth groups. The pattern owns the column (width, green surface, spacing) and the
|
||||
// menu item look; menu data lives with the consumer. Below 1024px the desktop column is hidden; use the LNBMobileHeader and
|
||||
// LNBMobileMenu responsive representation of the same LNB Mode, never a GNB.
|
||||
// <LNB>
|
||||
// <LNBBrand><span><small>시스템</small><strong>서비스명</strong></span></LNBBrand>
|
||||
// <LNBNav aria-label="서비스 메뉴">
|
||||
// <LNBItem href="#a" active><LayoutGridIcon/>메뉴 1</LNBItem>
|
||||
// <LNBGroup label="메뉴 2" current><LNBItem href="#b" active>메뉴 2-1</LNBItem> …</LNBGroup>
|
||||
// </LNBNav>
|
||||
// <LNBProfile name="김한맥" description="프로젝트 관리자"><Avatar>…</Avatar></LNBProfile>
|
||||
// </LNB>
|
||||
// The column does not draw the content side: the content next to it must be a positioned surface after it in the DOM with the left
|
||||
// corners rounded by `--hm-lnb-join` (LNB Mode content contract); the green column continues behind those corners.
|
||||
function LNB({ className, ...props }: React.ComponentProps<"aside">) {
|
||||
return <aside data-slot="lnb" className={cn("flex flex-col", className)} {...props} />
|
||||
}
|
||||
|
||||
// Brand block, vertical: an optional `img` / `svg` logo (only when you have a logo asset; no placeholder, no empty space) above one
|
||||
// `<span>` with an optional `<small>` eyebrow and a `<strong>` service name on the same start line.
|
||||
function LNBBrand({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="lnb-brand" className={cn("flex flex-col", className)} {...props} />
|
||||
}
|
||||
|
||||
// Menu region (give it an `aria-label`). It scrolls when the menu is taller than the column; brand and profile stay put.
|
||||
function LNBNav({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav data-slot="lnb-nav" className={cn("flex flex-col", className)} {...props} />
|
||||
}
|
||||
|
||||
// One destination. `active` marks the current location (`aria-current="page"`, Beige face joined to the content). An optional leading
|
||||
// icon (lucide etc.) is any `svg` child. `asChild` renders the consumer's router link.
|
||||
function LNBItem({
|
||||
className,
|
||||
active = false,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & { active?: boolean; asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
const groupDepth = React.useContext(LNBGroupDepthContext)
|
||||
const link = <Comp data-slot="lnb-item" aria-current={active ? "page" : undefined} className={cn("flex items-center", className)} {...props} />
|
||||
return groupDepth === 2 ? <div data-slot="lnb-third-row">{link}</div> : link
|
||||
}
|
||||
|
||||
type LNBGroupProps = Omit<React.ComponentProps<typeof Collapsible>, "children"> & {
|
||||
label: string
|
||||
/** A child is the current location: the group name and arrow turn to the accent color (open or closed). */
|
||||
current?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// 2-depth group: the HM Collapsible (Radix disclosure: `aria-expanded`, `aria-controls`, Enter / Space) with an arrow, the group
|
||||
// name and its `LNBItem` children. Selecting a child does not open or close it; the consumer decides (`open`, `defaultOpen`).
|
||||
// 3-depth: nest an `LNBGroup` inside a group (no depth prop); its items get the 3-depth look and connector from lnb.css. Mark the
|
||||
// selected link `active` and every group on its path `current`. A closed group unmounts its content, so keep a nested group's `open`
|
||||
// in the consumer when it must survive closing the outer group.
|
||||
// <LNBGroup label="1뎁스" current open={a} onOpenChange={setA}>
|
||||
// <LNBGroup label="2뎁스" current open={b} onOpenChange={setB}><LNBItem href="#c" active>3뎁스</LNBItem>…</LNBGroup>
|
||||
// <LNBItem href="#d">2뎁스</LNBItem>
|
||||
// </LNBGroup>
|
||||
function LNBGroup({ label, current = false, className, children, ...props }: LNBGroupProps) {
|
||||
const groupDepth = React.useContext(LNBGroupDepthContext)
|
||||
return (
|
||||
<Collapsible data-slot="lnb-group" data-current={current ? "" : undefined} className={className} {...props}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" data-slot="lnb-group-trigger" className="flex items-center">
|
||||
<ChevronRightIcon aria-hidden="true" data-slot="lnb-group-arrow" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent asChild>
|
||||
<div data-slot="lnb-group-content">
|
||||
<LNBGroupDepthContext.Provider value={groupDepth + 1}>{children}</LNBGroupDepthContext.Provider>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
type LNBProfileProps = Omit<React.ComponentProps<"button">, "children"> & {
|
||||
name: string
|
||||
description?: string
|
||||
/** The Avatar. */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
// User-menu trigger at the bottom of the column: Avatar (children, default size) + name and role. The whole row is a native button so
|
||||
// it composes directly with `DropdownMenuTrigger asChild`; Radix owns Enter / Space, arrow navigation, Escape and focus return.
|
||||
function LNBProfile({ className, name, description, children, "aria-label": ariaLabel, ...props }: LNBProfileProps) {
|
||||
return (
|
||||
<button type="button" aria-label={ariaLabel ?? `${name} 사용자 메뉴`} className={cn(className)} {...props} data-slot="lnb-profile">
|
||||
{children}
|
||||
<div data-slot="lnb-profile-text">
|
||||
<strong>{name}</strong>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { LNB, LNBBrand, LNBGroup, LNBItem, LNBNav, LNBProfile }
|
||||
export type { LNBGroupProps, LNBProfileProps }
|
||||
@@ -0,0 +1 @@
|
||||
export { LoadingScreen } from "./loading-screen"
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
const BAR_COUNT = 24
|
||||
|
||||
// Loading Screen pattern: a green surface with a 24-bar spinner and a "Loading..." text, for a region or a whole screen while its content
|
||||
// is being prepared. The progress is unknown (use Progress when the amount is known, Skeleton when the content shape is known).
|
||||
// Visual reference is the ui-viz Dashboard LoadingScreen (inline / fullscreen). Layout and motion only: it has no async lifecycle, cancel
|
||||
// or close control, so the consumer mounts it while loading and removes it when done.
|
||||
// <LoadingScreen /> inline, fills the parent width
|
||||
// <LoadingScreen fullscreen /> covers the viewport (fixed; keep it out of a transformed ancestor)
|
||||
// <LoadingScreen>데이터를 불러오는 중</LoadingScreen> children replace the "Loading" label ("..." is added)
|
||||
// `role="status"` makes the label a polite live region; the bars are decorative (aria-hidden).
|
||||
function LoadingScreen({
|
||||
className,
|
||||
fullscreen = false,
|
||||
children = "Loading",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { fullscreen?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="loading-screen"
|
||||
data-variant={fullscreen ? "fullscreen" : "inline"}
|
||||
role="status"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<div data-slot="loading-screen-content">
|
||||
<div data-slot="loading-spinner" aria-hidden="true">
|
||||
{Array.from({ length: BAR_COUNT }, (_, index) => (
|
||||
<span key={index} style={{ "--hm-loading-index": index } as React.CSSProperties} />
|
||||
))}
|
||||
</div>
|
||||
<p data-slot="loading-text">{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { LoadingScreen }
|
||||
@@ -0,0 +1 @@
|
||||
export { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle } from "./page-header"
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// Page Header pattern: the top of every screen's content. Breadcrumb (the HM Breadcrumb, composed as a child), the page title (the
|
||||
// screen's only `h1`), an optional description or meta line, and the page actions (Buttons, Dropdown Menu...) at the end.
|
||||
// Layout only: the Breadcrumb and Buttons keep their own look. Layout visual reference is the ui-viz page heading.
|
||||
// <PageHeader>
|
||||
// <PageHeaderContent>
|
||||
// <Breadcrumb>…</Breadcrumb>
|
||||
// <PageHeaderTitle>업무 현황</PageHeaderTitle>
|
||||
// <PageHeaderDescription>…</PageHeaderDescription>
|
||||
// </PageHeaderContent>
|
||||
// <PageHeaderActions><Button>신규 등록</Button></PageHeaderActions>
|
||||
// </PageHeader>
|
||||
function PageHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="page-header" className={cn("flex", className)} {...props} />
|
||||
}
|
||||
|
||||
// Start side: Breadcrumb, title and description stacked.
|
||||
function PageHeaderContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="page-header-content" className={cn("grid", className)} {...props} />
|
||||
}
|
||||
|
||||
function PageHeaderTitle({ className, ...props }: React.ComponentProps<"h1">) {
|
||||
return <h1 data-slot="page-header-title" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
function PageHeaderDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return <p data-slot="page-header-description" className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
// End side: actions on one row, wrapping on a narrow screen. Pass the Buttons as children.
|
||||
function PageHeaderActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="page-header-actions" className={cn("flex flex-wrap items-center", className)} {...props} />
|
||||
}
|
||||
|
||||
export { PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle }
|
||||
Reference in new issue
Block a user