Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
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"
+216
View File
@@ -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 }
+2
View File
@@ -0,0 +1,2 @@
export { GNB, GNBBrand, GNBMobileGroup, GNBMobileLink, GNBMobileMenu, GNBNav, GNBUser, GNBUtilities } from "./gnb"
export type { GNBMobileMenuProps, GNBUserProps } from "./gnb"
+5
View File
@@ -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"
+113
View File
@@ -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 }
+113
View File
@@ -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 }