- Adopt the official LNB desktop spec as package defaults: 240px column, 14px menu text, 48px 1-depth rows and 42px 2/3-depth rows, with new item/subitem padding-block and profile min-height tokens. - Match --hm-lnb-current-depth to the Storybook Manager selected item: sampled soft inset falloff (left/top/bottom) and a fade over the last quarter, keeping the concave corners and content join. - Drop the size overrides from the LNB Story CSS and update the LNB, GNB and Dashboard docs, AI guides and the theme verification baseline.
238 lines
13 KiB
Markdown
238 lines
13 KiB
Markdown
# Web(@hm-design/ui) 조합 패턴
|
|
|
|
모든 패턴은 공개 Export만 씁니다([COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md)). 코드는 형태를 보여 주는 예이며, 실제 화면에서는 **요청된 메뉴·조건·버튼만** 넣습니다(예시의 항목을 요구사항으로 복사하지 않음).
|
|
|
|
## 0. 어떤 패턴을 고를까
|
|
|
|
| 요청 | 먼저 고를 것 | 조합 |
|
|
|---|---|---|
|
|
| 목록 검색·필터 | `FilterBar`(표가 없어도 먼저 검토), 표가 필요하면 `DataTable` | `PageHeader` 아래 `Card` 하나 안에 FilterBar + DataTable. 조건 → 조회 → 초기화, draft/applied 분리(§3) |
|
|
| 업무 Dashboard | `DashboardLayout` + `DashboardHeader` + `DashboardMain` + `DashboardContent` | Header 48px, 최소 폭 960px·내부 가로 스크롤, Sidebar 없음. 상태는 Dashboard Status 계약(§6) |
|
|
| Form(등록·수정) | 공개 Form Pattern 없음 | `Card` 안 `Label` + `Input`/`Textarea`/`Select`/`Checkbox`/`RadioGroup`/`Switch`, `useId`·`htmlFor`, 오류는 `aria-invalid` + 메시지, 첫 오류 Focus(§4) |
|
|
| 서비스 상단 메뉴 | `GNB` + `GNBMobileMenu` | 요청된 경로만 Desktop·Mobile에 같게. Sidebar 없음(§1) |
|
|
| 계층 좌측 메뉴 | `LNB` + `LNBMobileHeader`/`LNBMobileMenu` | 3뎁스까지 `LNBGroup` 중첩(§1) |
|
|
| 작업 확인 | `AlertDialog`(또는 `Dialog`) | Title/Description, 취소/진행, Escape·Trigger Focus 복귀. 취소·삭제 모두 Solid(§5) |
|
|
| 측면 상세 | `Sheet` | 메뉴 대체로 쓰지 않음 |
|
|
| 하단 작업 | `Drawer` | desktop 취소→실행, mobile 실행 위·취소 아래 |
|
|
| 초기·부분 로딩 | `LoadingScreen` 또는 `Skeleton` / `DataTable loading` | 기존 콘텐츠 유지 여부는 요구사항으로 결정 |
|
|
| 빈 목록·오류 | `DataTable emptyMessage` / 콘텐츠 HTML / `Alert` | 별도 Empty 컴포넌트 만들지 않음 |
|
|
| 패키지에 없는 블록 | 소비 프로젝트 신규 | `Card`·`Button` 등 재사용 + [DESIGN_RULES.md](./DESIGN_RULES.md) 토큰(§7) |
|
|
|
|
## 1. Navigation 단일 모드 (필수)
|
|
|
|
한 페이지 = 하나의 Navigation: **GNB ONLY** · **LNB ONLY** · **DASHBOARD ONLY** 중 하나.
|
|
|
|
| 모드 | 구조 | 금지 |
|
|
|---|---|---|
|
|
| GNB | `GNB`(+ `GNBMobileMenu`) + Content | Sidebar·LNB·Dashboard와 혼합 |
|
|
| LNB | `LNB`(Desktop) + `LNBMobileHeader`/`LNBMobileMenu`(모바일 <1024px) + Content | GNB·Dashboard와 혼합 |
|
|
| DASHBOARD | `DashboardLayout header={<DashboardHeader/>}` + Content | Sidebar·LNB·GNB(`sidebar` prop·`DashboardSidebar`는 호환성 전용) |
|
|
|
|
- 요청이 모드를 말하지 않으면 화면 목적(서비스 상단 메뉴 = GNB, 계층 업무 메뉴 = LNB, 운영 현황 = DASHBOARD)으로 고르고 **선택 근거를 보고**합니다. 임의로 Sidebar를 추가하지 않습니다.
|
|
- 혼합 요청(GNB + LNB 등)은 정책 충돌로 안내하고 하나를 고르게 합니다. Drawer·Hamburger 등 대체 메뉴를 추측해 만들지 않습니다.
|
|
- Breadcrumb·FilterBar·사용자 Dropdown은 Navigation 모드가 아닙니다.
|
|
- **LNB 크기의 기준은 패키지 토큰입니다.** `@hm-design/ui`의 `--hm-lnb-*`가 정의한 값(Desktop 열 240px, 메뉴 14/28/700, 1뎁스 행 48px, 2·3뎁스 행 42px, Profile 48px, 모바일 Header 56px·링크 44px)을 그대로 씁니다. Storybook 화면에서 눈으로 잰 값이나 과거 예제의 272px·18px·56px를 새로 정하지 않고, `--hm-lnb-width` 등을 소비 CSS에서 다시 정의하지 않습니다. 레이아웃 열은 `var(--hm-lnb-width)`로 읽습니다.
|
|
|
|
```tsx
|
|
// LNB ONLY 골격
|
|
<div className="grid grid-cols-1 lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]">
|
|
<LNB>
|
|
<LNBBrand><span><small>업무 시스템</small><strong>서비스명</strong></span></LNBBrand>
|
|
<LNBNav aria-label="서비스 메뉴">
|
|
<LNBItem href="/list" active>목록</LNBItem>
|
|
<LNBGroup label="관리" current>
|
|
<LNBItem href="/admin/users">사용자</LNBItem>
|
|
</LNBGroup>
|
|
</LNBNav>
|
|
</LNB>
|
|
<div className="app-content-stack">
|
|
<LNBMobileHeader>
|
|
<LNBMobileMenu title="서비스 메뉴">
|
|
<LNBMobileLink href="/list" active>목록</LNBMobileLink>
|
|
<LNBMobileGroup label="관리"><LNBMobileLink href="/admin/users">사용자</LNBMobileLink></LNBMobileGroup>
|
|
</LNBMobileMenu>
|
|
<LNBMobileBrand href="/">서비스명</LNBMobileBrand>
|
|
</LNBMobileHeader>
|
|
<main className="app-page-content">…</main>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
```css
|
|
/* 소비 레이아웃(높이·스크롤)만. HM 면·간격은 그대로. --hm-lnb-width는 패키지 LNB 변수 */
|
|
.app-content-stack { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
.app-page-content { position: relative; flex: 1; min-height: 0; min-width: 0; overflow: auto; }
|
|
```
|
|
|
|
```tsx
|
|
// GNB ONLY 골격 — NavigationMenu는 viewport={false} 필수
|
|
<GNB sticky>
|
|
<GNBMobileMenu title="메뉴"><GNBMobileLink href="/" active>대시보드</GNBMobileLink></GNBMobileMenu>
|
|
<GNBBrand href="/"><span><small>업무 시스템</small><strong>서비스명</strong></span></GNBBrand>
|
|
<GNBNav>
|
|
<NavigationMenu viewport={false} aria-label="주 메뉴">
|
|
<NavigationMenuList>
|
|
<NavigationMenuItem><NavigationMenuLink href="/" active>대시보드</NavigationMenuLink></NavigationMenuItem>
|
|
</NavigationMenuList>
|
|
</NavigationMenu>
|
|
</GNBNav>
|
|
<GNBUtilities>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild><GNBUser name="홍길동" description="관리자"><Avatar><AvatarFallback>홍</AvatarFallback></Avatar></GNBUser></DropdownMenuTrigger>
|
|
<DropdownMenuContent><DropdownMenuItem variant="destructive">로그아웃</DropdownMenuItem></DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</GNBUtilities>
|
|
</GNB>
|
|
```
|
|
|
|
## 2. 페이지 제목
|
|
|
|
```tsx
|
|
<PageHeader>
|
|
<PageHeaderContent>
|
|
<PageHeaderTitle>사용자 목록</PageHeaderTitle>
|
|
<PageHeaderDescription>등록된 사용자를 조회합니다.</PageHeaderDescription>
|
|
</PageHeaderContent>
|
|
<PageHeaderActions>
|
|
<Button appearance="brand">사용자 등록</Button>{/* 페이지 대표 실행 1개만 brand */}
|
|
</PageHeaderActions>
|
|
</PageHeader>
|
|
```
|
|
|
|
## 3. 목록 화면 — FilterBar + DataTable (조회 버튼 적용)
|
|
|
|
`draft` = 입력 중 조건, `applied` = 목록에 적용한 조건. 조회 클릭에만 복사하고, 초기화는 두 상태와 페이지를 함께 비웁니다.
|
|
|
|
```tsx
|
|
import { useMemo, useState } from "react"
|
|
import {
|
|
Button, Card, CardContent, DataTable, FilterBar, FilterBarActions, FilterBarSearch,
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, useDataTable,
|
|
type DataTableColumnDef,
|
|
} from "@hm-design/ui"
|
|
|
|
type User = { id: string; name: string; status: "active" | "inactive" }
|
|
type Conditions = { query: string; status: string }
|
|
const empty: Conditions = { query: "", status: "all" }
|
|
const rows: User[] = [
|
|
{ id: "U-1", name: "김한맥", status: "active" },
|
|
{ id: "U-2", name: "이한맥", status: "inactive" },
|
|
]
|
|
const columns: DataTableColumnDef<User>[] = [ // 모듈 상수(참조 안정)
|
|
{ accessorKey: "name", header: "이름" },
|
|
{ accessorKey: "status", header: "상태", cell: ({ row }) => (row.original.status === "active" ? "활성" : "비활성") },
|
|
]
|
|
|
|
export function UserList() {
|
|
const [draft, setDraft] = useState<Conditions>({ ...empty })
|
|
const [applied, setApplied] = useState<Conditions>({ ...empty })
|
|
const data = useMemo(() => rows.filter(row =>
|
|
row.name.toLocaleLowerCase().includes(applied.query.trim().toLocaleLowerCase()) &&
|
|
(applied.status === "all" || row.status === applied.status)
|
|
), [applied])
|
|
const table = useDataTable({ columns, data, getRowId: row => row.id })
|
|
const apply = () => { setApplied({ ...draft }); table.setPageIndex(0) }
|
|
const reset = () => { setDraft({ ...empty }); setApplied({ ...empty }); table.setPageIndex(0) }
|
|
return (
|
|
<Card>
|
|
<CardContent>
|
|
<FilterBar layout="list-basic">
|
|
<Select value={draft.status} onValueChange={status => setDraft(s => ({ ...s, status }))}>
|
|
<SelectTrigger size="medium" aria-label="사용자 상태"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체 상태</SelectItem>
|
|
<SelectItem value="active">활성</SelectItem>
|
|
<SelectItem value="inactive">비활성</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FilterBarSearch value={draft.query} onValueChange={query => setDraft(s => ({ ...s, query }))} placeholder="이름 검색" />
|
|
<FilterBarActions>
|
|
<Button type="button" variant="primary" appearance="solid" onClick={apply}>조회</Button>
|
|
<Button type="button" variant="cancel" appearance="outline" onClick={reset}>초기화</Button>
|
|
</FilterBarActions>
|
|
</FilterBar>
|
|
<p role="status">조회 결과 {data.length}건</p>
|
|
<DataTable table={table} emptyMessage="조건에 맞는 사용자가 없습니다." />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
- 정렬 Header: `header: ({ column }) => <DataTableColumnHeader column={column} title="이름" />`. 선택 열: `dataTableSelectColumn<User>()`를 columns 맨 앞에.
|
|
- TanStack Table **v9** API입니다(v8의 `useReactTable`·`getCoreRowModel` 예제를 쓰지 않음). 필터: `table.setGlobalFilter`, `table.getColumn(id)?.setFilterValue`, 초기화 `resetGlobalFilter(true)`·`resetColumnFilters(true)`.
|
|
- `ColumnDef.size`는 th/td 폭으로 연결되지 않습니다. 긴 ID는 셀 콘텐츠에 `whitespace-nowrap` 등을 주고 실제 폭을 확인합니다.
|
|
|
|
## 4. Form
|
|
|
|
```tsx
|
|
const nameId = useId()
|
|
<Card>
|
|
<CardHeader><CardTitle>사용자 등록</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<Label htmlFor={nameId}>이름</Label>
|
|
<Input id={nameId} size="large" aria-invalid={error ? true : undefined} aria-describedby={error ? `${nameId}-error` : undefined} />
|
|
{error && <p id={`${nameId}-error`} role="alert">{error}</p>}
|
|
</CardContent>
|
|
<CardFooter>
|
|
<Button variant="cancel" appearance="solid">취소</Button>
|
|
<Button appearance="solid">저장</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
```
|
|
|
|
- 결정 세트는 같은 `appearance`·`size`. 오류 메시지 색이 필요하면 `--hm-sem-status-error`(잠정 토큰) 또는 `Alert variant="destructive"`.
|
|
|
|
## 5. 확인창 (삭제)
|
|
|
|
```tsx
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild><Button variant="destructive" appearance="outline">삭제</Button></AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>사용자를 삭제할까요?</AlertDialogTitle>
|
|
<AlertDialogDescription>삭제한 사용자는 복구할 수 없습니다.</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>{/* 기본 cancel + solid */}
|
|
<AlertDialogAction variant="destructive">삭제</AlertDialogAction>{/* 기본 solid */}
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
```
|
|
|
|
일반 작성·수정 모달은 `Dialog` + `DialogHeader` + `DialogBody`(스크롤) + `DialogFooter`, 버튼은 취소 Solid + 저장 Solid(팝업에 `brand` 금지).
|
|
|
|
## 6. Dashboard
|
|
|
|
```tsx
|
|
<DashboardLayout header={<DashboardHeader>…요청된 제목·상태 필터만…</DashboardHeader>}>
|
|
<DashboardMain>
|
|
<DashboardContent>…</DashboardContent>
|
|
</DashboardMain>
|
|
</DashboardLayout>
|
|
```
|
|
|
|
- 업무 상태: `dashboardStatusDefinitions`·`dashboardFilterDefinitions`·`matchesDashboardFilter`·`countDashboardFilter`로 필터·집계를 하고, 색은 `@hm-design/ui/dashboard-status.css`를 import한 뒤 `--hm-dashboard-status-active|hold|done|issue`를 소비 코드에서 Badge 등에 연결합니다(자동 스타일 없음). 새 상태색을 만들지 않습니다.
|
|
|
|
## 7. 패키지에 없는 블록 만들기
|
|
|
|
예: "통계 카드 4개".
|
|
|
|
1. 카탈로그 확인 → 통계 전용 컴포넌트 없음 → **조합**: `Card` + `CardHeader`/`CardTitle` + `CardContent`.
|
|
2. 숫자 강조 등 Card 밖 스타일이 필요하면 소비 프로젝트 class(`app-stat-value`)를 만들고 토큰만 읽습니다.
|
|
|
|
```css
|
|
.app-stat-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
|
.app-stat-value { color: var(--hm-sem-fg-primary); font-weight: var(--hm-font-weight-bold); }
|
|
.app-stat-label { color: var(--hm-sem-fg-secondary); }
|
|
```
|
|
|
|
3. 간격 값(16px 등)은 레이아웃용입니다. 컴포넌트 내부 padding·radius는 바꾸지 않습니다.
|
|
4. 결과 보고에 "신규: `StatCard`(소비 프로젝트) — Card 조합, 토큰 `--hm-sem-fg-primary`, `--hm-sem-fg-secondary`, `--hm-font-weight-bold`"처럼 남깁니다.
|
|
|
|
## 8. 혼합 사용과 함께 쓸 때
|
|
|
|
- **Web + OrgChart:** 페이지·제목·필터는 이 문서대로, 조직도 영역은 `@hm-design/org-chart/ai/COMPOSITION_PATTERNS.md` "Full Page". 조직도 안쪽에 Web 토큰을 넣지 않습니다.
|
|
- **Web + SW:** SW Popup 안 하단 버튼 등 SW 문서가 허용한 지점에서만 Web 컴포넌트를 씁니다. Web CSS(Tailwind base 포함)는 문서 전체에 적용되므로 SW 영역을 브라우저에서 확인합니다([AI_GUIDE.md](./AI_GUIDE.md) §5).
|