Files
HMDesign/packages/ui/ai/COMPOSITION_PATTERNS.md
T
shkim4 fb74cd756f docs(ai): add packaged AI guides for Web, SW and OrgChart
- Shared entry point docs/ai/AI_GUIDE.md (type selection Web / SW / OrgChart / mixed,
  steps 1-7, package style separation, report format, examples A-D) and
  AGENTS_SNIPPET.md, copied into every package ai/ folder
- New ai/ docs for @hm-design/ui and @hm-design/org-chart (+ org-chart-react pointer):
  AI_USAGE, COMPONENT_CATALOG, DESIGN_RULES, COMPOSITION_PATTERNS
- SW ai/ linked to the shared guide; catalog lists every Props type explicitly
- "ai" added to package files of ui, org-chart, org-chart-react
- scripts/ai-docs.mjs: sync:ai-docs / check:ai-docs (copies, files, catalog <-> exports, links)
- Maintenance guide and verification record under docs/ai
2026-10-19 17:13:36 +09:00

12 KiB

Web(@hm-design/ui) 조합 패턴

모든 패턴은 공개 Export만 씁니다(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 토큰(§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 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>
/* 소비 레이아웃(높이·스크롤)만. 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; }
// 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. 페이지 제목

<PageHeader>
  <PageHeaderContent>
    <PageHeaderTitle>사용자 목록</PageHeaderTitle>
    <PageHeaderDescription>등록된 사용자를 조회합니다.</PageHeaderDescription>
  </PageHeaderContent>
  <PageHeaderActions>
    <Button appearance="brand">사용자 등록</Button>{/* 페이지 대표 실행 1개만 brand */}
  </PageHeaderActions>
</PageHeader>

3. 목록 화면 — FilterBar + DataTable (조회 버튼 적용)

draft = 입력 중 조건, applied = 목록에 적용한 조건. 조회 클릭에만 복사하고, 초기화는 두 상태와 페이지를 함께 비웁니다.

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

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. 확인창 (삭제)

<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

<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)를 만들고 토큰만 읽습니다.
.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); }
  1. 간격 값(16px 등)은 레이아웃용입니다. 컴포넌트 내부 padding·radius는 바꾸지 않습니다.
  2. 결과 보고에 "신규: 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 §5).