Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01ccaca01 | ||
|
|
3cbe879910 | ||
|
|
4c6fff5dea | ||
|
|
39a089ad0e | ||
|
|
1a88d575be | ||
|
|
b83502e7b7 | ||
|
|
d9434df79e | ||
|
|
4c7d3460f1 | ||
|
|
5e5911199d | ||
|
|
c42a7e1ecf | ||
|
|
bcce6590ec | ||
|
|
030ea02713 | ||
|
|
a75fa7b560 | ||
|
|
fae221b192 | ||
|
|
e42b67c53a | ||
|
|
f13498dbe2 | ||
|
|
89f265ae2f | ||
|
|
6bbac88142 | ||
|
|
237866385a | ||
|
|
ca4b696897 | ||
|
|
2e203627c5 | ||
|
|
8007eb5b11 | ||
|
|
074ed8706b | ||
|
|
73ef042c55 | ||
|
|
be0661062f | ||
|
|
ea984ab3a6 | ||
|
|
9394050c72 | ||
|
|
0be30d34ab | ||
|
|
a460426535 | ||
|
|
0f1574f80f | ||
|
|
031f88033d | ||
|
|
dcdbebdea6 | ||
|
|
d0f4ec6ed4 | ||
|
|
84dddcc7f7 | ||
|
|
b33ca2a157 | ||
|
|
2755f4f17c | ||
|
|
3bebe8a660 | ||
|
|
bef164489b | ||
|
|
3868ca09c7 | ||
|
|
cd60ab661a | ||
|
|
0ea5b46f74 | ||
|
|
4088b2c120 | ||
|
|
8827eb2681 | ||
|
|
64f14c3c03 | ||
|
|
f6b92d781a | ||
|
|
2199318f8f | ||
|
|
e47eb040f2 | ||
|
|
9b03650d8f | ||
|
|
fc0a3e7725 | ||
|
|
db0bfe2d34 | ||
|
|
dc9258599c | ||
|
|
228944a06b |
No files matched your search
+61
-1
@@ -1,5 +1,58 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { readFileSync, realpathSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import path from "node:path"
|
||||
import type { Plugin } from "vite"
|
||||
|
||||
// Checkouts that share another checkout's node_modules (a linked git worktree whose node_modules is a junction) also
|
||||
// inherit that checkout's node_modules/@hm-design/* links. Those links point at the other checkout's packages/ui and its
|
||||
// compiled dist, so Storybook would show the other checkout's tokens (a stale palette) instead of this checkout's.
|
||||
// Only in that case, resolve the five @hm-design/ui entry points to this checkout's own source. Nothing is built, the
|
||||
// other checkout's node_modules and dist are only read, and a checkout whose links already point at its own
|
||||
// packages/ui keeps the unchanged dist-based resolution.
|
||||
const checkoutRoot = fileURLToPath(new URL("../", import.meta.url))
|
||||
const localUiRoot = path.join(checkoutRoot, "packages/ui")
|
||||
function uiLinkedElsewhere() {
|
||||
try {
|
||||
return realpathSync(path.join(checkoutRoot, "node_modules/@hm-design/ui")) !== realpathSync(localUiRoot)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const localUiAliases = [
|
||||
[/^@hm-design\/ui$/, "src/index.ts"],
|
||||
[/^@hm-design\/ui\/theme$/, "src/theme/index.ts"],
|
||||
[/^@hm-design\/ui\/theme\.css$/, "src/theme.css"],
|
||||
[/^@hm-design\/ui\/tailwind\.css$/, "src/tailwind.css"],
|
||||
[/^@hm-design\/ui\/dashboard-status\.css$/, "src/patterns/dashboard-status/dashboard-status.css"],
|
||||
].map(([find, file]) => ({ find: find as RegExp, replacement: path.join(localUiRoot, file as string) }))
|
||||
|
||||
// @hm-design/ui's dist/theme.css declares the bundled font with a relative URL. When node_modules/@hm-design/ui is
|
||||
// symlinked to a checkout outside this Vite root, Vite rewrites that URL to /@fs/<outside path> and the dev preview
|
||||
// gets a 403 ("outside of Vite serving allow list"). Point only that font URL at the /fonts static directory the
|
||||
// Manager already uses, instead of widening server.fs.allow. theme.css is inlined through @import, so the rewrite
|
||||
// runs on the already-resolved CSS and is limited to the exact ".../ui/dist/fonts/NotoSansKR-variable.ttf" URL.
|
||||
// Dev server only: the static build emits the font as a bundled asset.
|
||||
const UI_DIST_FONT_URL = /[^"'()\\\s]*\/ui\/dist\/fonts\/NotoSansKR-variable\.ttf/g
|
||||
const FONT_STATIC_URL = "/fonts/NotoSansKR-variable.ttf"
|
||||
|
||||
const hmUiThemeFontPath: Plugin = {
|
||||
name: "hm-ui-theme-font-path",
|
||||
apply: "serve" as const,
|
||||
enforce: "post" as const,
|
||||
transform(code: string, id: string) {
|
||||
const file = id.split("?")[0]
|
||||
if (!file.endsWith(".css") || !code.includes("NotoSansKR-variable.ttf")) return null
|
||||
const next = code.replace(UI_DIST_FONT_URL, FONT_STATIC_URL)
|
||||
if (next === code) {
|
||||
if (!code.includes(FONT_STATIC_URL)) {
|
||||
this.warn(`[hm-ui-theme-font-path] ${file}: found NotoSansKR-variable.ttf but not at ".../ui/dist/fonts/"; the URL was left unchanged and Preview may fail to load Noto Sans KR.`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
return { code: next, map: null }
|
||||
},
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
||||
@@ -21,6 +74,13 @@ const config: StorybookConfig = {
|
||||
viteFinal: (config) => {
|
||||
config.server ??= {}
|
||||
config.server.watch = { ...config.server.watch, ignored: ["**/_local/**", "**/storybook-static/**"] }
|
||||
config.plugins = [...(config.plugins ?? []), hmUiThemeFontPath]
|
||||
if (uiLinkedElsewhere()) {
|
||||
config.resolve ??= {}
|
||||
const existing = config.resolve.alias
|
||||
const current = Array.isArray(existing) ? existing : Object.entries(existing ?? {}).map(([find, replacement]) => ({ find, replacement }))
|
||||
config.resolve.alias = [...localUiAliases, ...current]
|
||||
}
|
||||
return config
|
||||
},
|
||||
framework: "@storybook/react-vite",
|
||||
|
||||
+33
-14
@@ -61,13 +61,15 @@
|
||||
background: var(--hm-fg-on-green-secondary);
|
||||
}
|
||||
#storybook-sidebar-region .sidebar-header {
|
||||
margin: 0 20px 24px 0;
|
||||
margin: 0 20px 0px 0;
|
||||
padding: 0;
|
||||
color: var(--color-fg-on-green-primary);
|
||||
}
|
||||
/* The Settings button is hidden, so the title uses the full header width. */
|
||||
#storybook-sidebar-region .sidebar-header > div { margin-right: 0; }
|
||||
#storybook-sidebar-region .sidebar-header > div a {
|
||||
color: var(--color-fg-on-green-primary);
|
||||
font: 700 20px/30px var(--hm-manager-font);
|
||||
font: 700 16px/30px var(--hm-manager-font);
|
||||
white-space: normal;
|
||||
/* §6 Text Border: brand title keeps the original 1px (no shadow). */
|
||||
-webkit-text-stroke: 1px var(--effect-text-border-color);
|
||||
@@ -78,7 +80,6 @@
|
||||
}
|
||||
#storybook-sidebar-region [role='search'] {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
#storybook-sidebar-region .search-field {
|
||||
min-height: 44px;
|
||||
@@ -104,21 +105,25 @@
|
||||
}
|
||||
/* Storybook's named creation action only; search/filter remain available. */
|
||||
#storybook-sidebar-region button[aria-label='Create a new story'] { display: none; }
|
||||
/* Storybook Settings menu trigger only; sidebar search, tag filter and expand/collapse remain. */
|
||||
#storybook-sidebar-region .sidebar-header button[aria-label='Settings'] { display: none; }
|
||||
/* Storybook sidebar search landmark (input, icon, shortcut hint, tag filter); the tree and header remain. */
|
||||
#storybook-sidebar-region [role='search'] { display: none; }
|
||||
|
||||
#storybook-explorer-tree .sidebar-subheading {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0 20px 0 0;
|
||||
min-height: 56px;
|
||||
min-height: 48px;
|
||||
color: var(--hm-fg-on-green-secondary);
|
||||
}
|
||||
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0']) {
|
||||
margin-top: 40px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
top: -12px;
|
||||
left: 0;
|
||||
right: 20px;
|
||||
height: 2px;
|
||||
@@ -126,9 +131,9 @@
|
||||
mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
|
||||
}
|
||||
#storybook-explorer-tree .sidebar-subheading > button {
|
||||
min-height: 56px;
|
||||
min-height: 48px;
|
||||
color: inherit;
|
||||
font: 700 16px/28px var(--hm-manager-font);
|
||||
font: 700 14px/28px var(--hm-manager-font);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
@@ -136,7 +141,7 @@
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 56px;
|
||||
min-height: 48px;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -163,22 +168,30 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
min-height: 48px;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 14px 32px 14px 18px;
|
||||
padding: 10px 32px 10px 18px;
|
||||
border: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: 700 18px/28px var(--hm-manager-font);
|
||||
font: 700 14px/28px var(--hm-manager-font);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
/* Sub rows (depth 2+): 42px (7px + 28px line + 7px), shorter than the 48px top-level rows. */
|
||||
#storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) {
|
||||
min-height: 42px;
|
||||
}
|
||||
#storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) > :is(a[id], button[id]:not([data-testid])) {
|
||||
min-height: 42px;
|
||||
padding-block: 7px;
|
||||
}
|
||||
#storybook-explorer-tree .sidebar-item[data-hm-depth='2'] > :is(a[id], button[id]:not([data-testid])) {
|
||||
margin-left: 32px;
|
||||
width: calc(100% - 32px);
|
||||
@@ -188,6 +201,10 @@
|
||||
margin-left: 44px;
|
||||
width: calc(100% - 44px);
|
||||
}
|
||||
/* Menu item labels and section heading labels (.sidebar-subheading) are both 14px / 700. */
|
||||
#storybook-explorer-tree .sidebar-item .hm-manager-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
#storybook-explorer-tree .hm-manager-label {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
@@ -198,8 +215,10 @@
|
||||
}
|
||||
/* D-093 D-014 (§6 Text Border): non-selected labels, including hover and the
|
||||
yellow current parent, use the specimen's 1.5px border plus lower shadow.
|
||||
Thickness and offset are REVIEW VALUEs. Selected labels carry no effect. */
|
||||
#storybook-explorer-tree .sidebar-item[data-selected='false'] .hm-manager-label {
|
||||
Thickness and offset are REVIEW VALUEs. Selected labels carry no effect.
|
||||
Section heading labels (Foundation, Components, ...) share this exact rule. */
|
||||
#storybook-explorer-tree .sidebar-item[data-selected='false'] .hm-manager-label,
|
||||
#storybook-explorer-tree .sidebar-subheading .hm-manager-label {
|
||||
-webkit-text-stroke: 1.5px var(--effect-text-border-color);
|
||||
paint-order: stroke fill;
|
||||
text-shadow: 1px 2px 1px var(--effect-text-border-color);
|
||||
|
||||
+98
-2
@@ -28,13 +28,109 @@ toolbarStyle.textContent = `
|
||||
`
|
||||
document.head.appendChild(toolbarStyle)
|
||||
|
||||
// Sidebar display names only. Story titles, Story IDs and storySort stay English; items not listed keep item.name.
|
||||
const koreanLabels = {
|
||||
foundation: '기본 요소',
|
||||
components: '컴포넌트',
|
||||
'components-form': '폼 요소',
|
||||
'components-navigation': '내비게이션',
|
||||
'components-overlay': '오버레이',
|
||||
'components-data-display': '데이터 표시',
|
||||
'components-disclosure': '영역 확장·조절',
|
||||
'components-search-date': '검색·날짜',
|
||||
'components-feedback': '알림·피드백',
|
||||
'components-form-button': '버튼',
|
||||
'components-form-input': '입력창',
|
||||
'components-form-textarea': '여러 줄 입력창',
|
||||
'components-form-select': '선택 목록',
|
||||
'components-form-checkbox': '체크박스',
|
||||
'components-form-radio-group': '라디오 버튼',
|
||||
'components-form-switch': '스위치',
|
||||
'components-form-slider': '슬라이더',
|
||||
'components-form-toggle': '토글 버튼',
|
||||
'components-navigation-tabs': '탭',
|
||||
'components-navigation-breadcrumb': '이동 경로',
|
||||
'components-navigation-pagination': '페이지네이션',
|
||||
'components-navigation-navigation-menu': '내비게이션 메뉴',
|
||||
'components-navigation-menubar': '메뉴바',
|
||||
'components-overlay-dialog': '대화상자',
|
||||
'components-overlay-alert-dialog': '확인·경고 대화상자',
|
||||
'components-overlay-sheet': '시트 패널',
|
||||
'components-overlay-drawer': '드로어',
|
||||
'components-overlay-dropdown-menu': '드롭다운 메뉴',
|
||||
'components-overlay-context-menu': '컨텍스트 메뉴',
|
||||
'components-overlay-popover': '팝오버',
|
||||
'components-overlay-tooltip': '툴팁',
|
||||
'components-overlay-hover-card': '호버 카드',
|
||||
'components-data-display-card': '카드',
|
||||
'components-data-display-table': '테이블',
|
||||
'components-data-display-badge': '배지',
|
||||
'components-data-display-avatar': '아바타',
|
||||
'components-data-display-progress': '진행률',
|
||||
'components-data-display-skeleton': '스켈레톤',
|
||||
'components-data-display-separator': '구분선',
|
||||
'components-data-display-aspect-ratio': '화면 비율',
|
||||
'components-data-display-carousel': '캐러셀',
|
||||
'components-data-display-scroll-area': '스크롤 영역',
|
||||
'components-disclosure-accordion': '아코디언',
|
||||
'components-disclosure-collapsible': '접기·펼치기',
|
||||
'components-disclosure-resizable': '크기 조절',
|
||||
'components-search-date-command': '명령어 검색',
|
||||
'components-search-date-combobox': '콤보박스',
|
||||
'components-search-date-calendar': '달력',
|
||||
'components-search-date-date-picker': '날짜 선택기',
|
||||
'components-feedback-alert': '알림 메시지',
|
||||
'components-feedback-sonner': '토스트 알림',
|
||||
patterns: 'UI 패턴',
|
||||
'patterns-data': '데이터 조회',
|
||||
'patterns-data-data-table': '데이터 테이블',
|
||||
'patterns-data-filter-bar': '필터 바',
|
||||
'patterns-layout': '화면 구성',
|
||||
'patterns-layout-gnb': '전체 메뉴 (GNB)',
|
||||
'patterns-layout-dashboard': '대시보드',
|
||||
'patterns-layout-lnb': '좌측 메뉴 (LNB)',
|
||||
'patterns-layout-dashboard-layout': '대시보드',
|
||||
'patterns-layout-page-header': '페이지 헤더',
|
||||
chart: '차트',
|
||||
'chart-bar-chart--bar': '막대 차트',
|
||||
'chart-line-chart--line': '선 차트',
|
||||
'chart-donut-chart--donut': '도넛 차트',
|
||||
'chart-pie-chart--pie': '원형 차트',
|
||||
'chart-radar-chart--radar': '레이더 차트',
|
||||
'chart-area-chart--area': '영역 차트',
|
||||
'chart-radial-chart--radial': '원형 막대 차트',
|
||||
sw: 'SW UI',
|
||||
'sw-header': '헤더',
|
||||
'sw-header--overview': 'SW 헤더',
|
||||
'sw-header--responsive-more': '화면 너비 대응',
|
||||
'sw-viewer--responsive': '화면 너비 대응',
|
||||
'sw-step-header--responsive': '화면 너비 대응',
|
||||
'sw-footer--responsive': '화면 너비 대응',
|
||||
'sw-viewer': '뷰어',
|
||||
'sw-viewer--viewer-header': '뷰어 헤더',
|
||||
'sw-step-header': '단계별',
|
||||
'sw-step-header--process-header': '단계별 헤더',
|
||||
'sw-popup': '팝업',
|
||||
}
|
||||
|
||||
// A component with one visible Story is shown as that Story (`<component-id>--all`) under the component's name.
|
||||
// A real Story named "All" under an expanded component keeps its own name.
|
||||
const koreanLabel = (item) => {
|
||||
const hoisted = item.type === 'story' && item.name !== 'All' ? koreanLabels[item.id.replace(/--all$/, '')] : undefined
|
||||
return koreanLabels[item.id] ?? hoisted ?? item.name
|
||||
}
|
||||
|
||||
addons.setConfig({
|
||||
layout: {
|
||||
showPanel: false,
|
||||
showToolbar: false,
|
||||
// LNB width 246px. recentVisibleSizes keeps the same width when the sidebar is collapsed and expanded again.
|
||||
navSize: 246,
|
||||
recentVisibleSizes: { navSize: 246 },
|
||||
},
|
||||
theme: create({
|
||||
base: 'light',
|
||||
brandTitle: 'HM Design System',
|
||||
brandTitle: '한맥 디자인 시스템',
|
||||
brandUrl: './',
|
||||
brandTarget: '_self',
|
||||
fontBase: "'Hanmac Manager Noto Sans KR', 'Noto Sans KR', 'Malgun Gothic', sans-serif",
|
||||
@@ -48,7 +144,7 @@ addons.setConfig({
|
||||
barSelectedColor: '#123328',
|
||||
}),
|
||||
sidebar: {
|
||||
renderLabel: (item) => createElement('span', { className: 'hm-manager-label' }, item.name),
|
||||
renderLabel: (item) => createElement('span', { className: 'hm-manager-label' }, koreanLabel(item)),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const preview: Preview = {
|
||||
includeNames: true,
|
||||
order: [
|
||||
"Foundation",
|
||||
["Color", "Company Colors", "Typography", "Layout"],
|
||||
["한맥 컬러", "가족사 컬러", "폰트"],
|
||||
"Components",
|
||||
[
|
||||
"Form",
|
||||
@@ -44,12 +44,14 @@ const preview: Preview = {
|
||||
["Alert", "Sonner"],
|
||||
],
|
||||
"Patterns",
|
||||
["Data", ["Data Table", "Filter Bar"], "Layout", ["GNB", "LNB", "Dashboard Layout", "Loading Screen"]],
|
||||
"OrgChart",
|
||||
["Overview", "Department Colors", "Full Composition", "Expanded OrgChart"],
|
||||
"Center Graphic",
|
||||
["Data", ["Data Table", "Filter Bar"], "Layout", ["GNB", "LNB", "Dashboard", "Loading Screen"]],
|
||||
"Chart",
|
||||
["Bar Chart", "Line Chart", "Donut Chart", "Pie Chart", "Radar Chart", "Area Chart", "Radial Chart"],
|
||||
"SW",
|
||||
["Header", ["SW Header", "*"], "Footer", ["SW Footer", "*"], "Popup", "Viewer", "Theme"],
|
||||
["Header", ["SW Header", "*"], "Viewer", "Step Header", "Popup", "하단 명령어바", ["SW 하단 명령어바", "*"], "Theme"],
|
||||
"조직도",
|
||||
["개요", "부서별 색상", "전체 구성", "확장 조직도"],
|
||||
"노선도",
|
||||
"*",
|
||||
// With includeNames, keeps the Loader group after the Runtime Contract Stories (its place without names).
|
||||
"Theme",
|
||||
|
||||
@@ -7,57 +7,99 @@ This is a classification record. No Story IDs, exports or titles are renamed. HI
|
||||
## Summary
|
||||
|
||||
- PRIMARY: 82
|
||||
- VERIFICATION: 252
|
||||
- UTILITY: 38
|
||||
- VERIFICATION: 256
|
||||
- UTILITY: 49
|
||||
- LEGACY / DUPLICATE: 0
|
||||
- KEEP: 82
|
||||
- HIDE: 290
|
||||
- HIDE: 305
|
||||
- MERGE_CANDIDATE: 33
|
||||
- DELETE_CANDIDATE: 0
|
||||
- Story files: 82
|
||||
- Stories: 372
|
||||
- Hierarchy: Foundation → Components → Patterns → Center Graphic → Theme
|
||||
- Story files: 89
|
||||
- Stories: 387
|
||||
- Hierarchy: Foundation → Components → Patterns → Chart → SW → 조직도 → 노선도 → Verification → Theme
|
||||
|
||||
## Full inventory
|
||||
|
||||
### Center Graphic/Facility
|
||||
### 노선도/시설물
|
||||
|
||||
Source: `src/center/center-facility.stories.tsx`; meta.tags: []
|
||||
|
||||
- Tunnel — Tunnel; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Bridge — Bridge; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Intersection — Intersection; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Junction — Junction; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Interchange — Interchange; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Roadside — Roadside; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Linked — Linked; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Tunnel — 터널; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Bridge — 교량; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Intersection — 교차로; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Junction — 분기점; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Interchange — 나들목; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Roadside — 휴게소 · 졸음쉼터; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Linked — 연계 시설물; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- TunnelPlayground — 직접 테스트 (터널); tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Center Graphic/Information
|
||||
### 노선도/정보
|
||||
|
||||
Source: `src/center/center-information.stories.tsx`; meta.tags: []
|
||||
|
||||
- Information — Information; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Information — 정보; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Center Graphic/Overview
|
||||
### 노선도/Overview
|
||||
|
||||
Source: `src/center/center-overview.stories.tsx`; meta.tags: []
|
||||
|
||||
- Overview — Overview; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Center Graphic/Spatial
|
||||
### 노선도/노선·공간
|
||||
|
||||
Source: `src/center/center-spatial.stories.tsx`; meta.tags: []
|
||||
|
||||
- Spatial — Spatial; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Spatial — 노선·공간; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Center Graphic/Title
|
||||
### 노선도/사업명
|
||||
|
||||
Source: `src/center/center-title.stories.tsx`; meta.tags: []
|
||||
|
||||
- Compositions — Title; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Compositions — 사업명; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Chart/Area Chart
|
||||
|
||||
Source: `src/chart/area-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Area — Area Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Bar Chart
|
||||
|
||||
Source: `src/chart/bar-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Bar — Bar Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Donut Chart
|
||||
|
||||
Source: `src/chart/donut-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Donut — Donut Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Line Chart
|
||||
|
||||
Source: `src/chart/line-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Line — Line Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Pie Chart
|
||||
|
||||
Source: `src/chart/pie-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Pie — Pie Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Radar Chart
|
||||
|
||||
Source: `src/chart/radar-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Radar — Radar Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Chart/Radial Chart
|
||||
|
||||
Source: `src/chart/radial-chart.stories.tsx`; meta.tags: []
|
||||
|
||||
- Radial — Radial Chart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Components/Disclosure/Accordion
|
||||
|
||||
Source: `src/components/accordion.stories.tsx`; meta.tags: []
|
||||
@@ -171,6 +213,7 @@ Source: `src/components/checkbox.stories.tsx`; meta.tags: []
|
||||
- Indeterminate — Indeterminate; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
|
||||
- Error — Error; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- LongLabel — Long Label; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Components/Disclosure/Collapsible
|
||||
@@ -274,9 +317,9 @@ Source: `src/components/input.stories.tsx`; meta.tags: []
|
||||
|
||||
### Components/Form/Label
|
||||
|
||||
Source: `src/components/label.stories.tsx`; meta.tags: []
|
||||
Source: `src/components/label.stories.tsx`; meta.tags: ["internal"]
|
||||
|
||||
- All — Label; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- All — Label; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Components/Navigation/Menubar
|
||||
|
||||
@@ -467,6 +510,8 @@ Source: `src/components/textarea.stories.tsx`; meta.tags: []
|
||||
- InvalidFocus — Invalid Focus; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ErrorMessage — Error Message; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Required — Required; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Multiline — Multiline · Wrap; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ResizeNone — Resize · none; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Readonly — Readonly; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
|
||||
- LongContent — Long Content; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
@@ -474,13 +519,14 @@ Source: `src/components/textarea.stories.tsx`; meta.tags: []
|
||||
|
||||
### Components/Form/Toggle Group
|
||||
|
||||
Source: `src/components/toggle-group.stories.tsx`; meta.tags: []
|
||||
Source: `src/components/toggle-group.stories.tsx`; meta.tags: ["internal"]
|
||||
|
||||
- All — Toggle Group; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- SingleSelection — Single; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- MultipleSelection — Multiple; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- VerticalGroup — Vertical; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ContentNodes — Content Nodes; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- All — Toggle Group; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- SingleSelection — Single; tags: ["internal","verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- MultipleSelection — Multiple; tags: ["internal","verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- VerticalGroup — Vertical; tags: ["internal","verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ManyOptions — Many Options; tags: ["internal","verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ContentNodes — Content Nodes; tags: ["internal","verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Components/Form/Toggle
|
||||
|
||||
@@ -499,31 +545,31 @@ Source: `src/components/tooltip.stories.tsx`; meta.tags: []
|
||||
- All — Tooltip; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Foundation/Color
|
||||
### Foundation/한맥 컬러
|
||||
|
||||
Source: `src/foundation/color.stories.tsx`; meta.tags: []
|
||||
|
||||
- All — Color; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- All — 한맥 컬러; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Foundation/Company Colors
|
||||
### Foundation/가족사 컬러
|
||||
|
||||
Source: `src/foundation/company-colors.stories.tsx`; meta.tags: []
|
||||
|
||||
- All — Company Colors; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- All — 가족사 컬러; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### Foundation/Layout
|
||||
|
||||
Source: `src/foundation/layout.stories.tsx`; meta.tags: []
|
||||
Source: `src/foundation/layout.stories.tsx`; meta.tags: ["verification"]
|
||||
|
||||
- All — Layout; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- All — Layout; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Foundation/Typography
|
||||
### Foundation/폰트
|
||||
|
||||
Source: `src/foundation/typography.stories.tsx`; meta.tags: []
|
||||
|
||||
- All — Typography; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- All — 폰트; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### OrgChart/Components/Cards
|
||||
### 조직도/Components/Cards
|
||||
|
||||
Source: `src/org-chart/org-chart-cards.stories.tsx`; meta.tags: ["internal"]
|
||||
|
||||
@@ -537,25 +583,25 @@ Source: `src/org-chart/org-chart-cards.stories.tsx`; meta.tags: ["internal"]
|
||||
- OrganizationLeader — 조직장; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Employee — 일반 직원; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### OrgChart/Department Colors
|
||||
### 조직도/부서별 색상
|
||||
|
||||
Source: `src/org-chart/org-chart-department-colors.stories.tsx`; meta.tags: []
|
||||
|
||||
- DepartmentColors — Department Colors; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- DepartmentColors — 부서별 색상; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### OrgChart/Expanded OrgChart
|
||||
### 조직도/확장 조직도
|
||||
|
||||
Source: `src/org-chart/org-chart-expanded.stories.tsx`; meta.tags: []
|
||||
|
||||
- ExpandedOrgChart — Expanded OrgChart; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- ExpandedOrgChart — 확장 조직도; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### OrgChart/Full Composition
|
||||
### 조직도/전체 구성
|
||||
|
||||
Source: `src/org-chart/org-chart-full-composition.stories.tsx`; meta.tags: []
|
||||
|
||||
- FullComposition — Full Composition; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- FullComposition — 전체 구성; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### OrgChart/Verification/Overview
|
||||
### 조직도/Verification/Overview
|
||||
|
||||
Source: `src/org-chart/org-chart-overview-internal.stories.tsx`; meta.tags: ["internal"]
|
||||
|
||||
@@ -563,13 +609,13 @@ Source: `src/org-chart/org-chart-overview-internal.stories.tsx`; meta.tags: ["in
|
||||
- PositionSummary — Position Summary; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- AffiliationSummary — Affiliation Summary; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### OrgChart/Overview
|
||||
### 조직도/개요
|
||||
|
||||
Source: `src/org-chart/org-chart-p1.stories.tsx`; meta.tags: []
|
||||
|
||||
- LogoPersonnelSummary — Overview; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- LogoPersonnelSummary — 개요; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### OrgChart/Components/Summaries
|
||||
### 조직도/Components/Summaries
|
||||
|
||||
Source: `src/org-chart/org-chart-summary.stories.tsx`; meta.tags: ["internal"]
|
||||
|
||||
@@ -577,16 +623,16 @@ Source: `src/org-chart/org-chart-summary.stories.tsx`; meta.tags: ["internal"]
|
||||
- AffiliationSummary — Affiliation Summary; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- StatusSummary — Status Summary; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### OrgChart/Content
|
||||
### 조직도/콘텐츠
|
||||
|
||||
Source: `src/org-chart/org-chart.stories.tsx`; meta.tags: ["internal"]
|
||||
|
||||
- BasicOrganization — Basic Organization; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- OrganizationHierarchy — Organization Hierarchy; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- EmployeeCards — Employee Cards; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- OrganizationSummary — Organization Summary; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- BasicOrganization — 기본 조직도; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- OrganizationHierarchy — 조직 계층; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- EmployeeCards — 직원 카드; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- OrganizationSummary — 조직 요약; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- JSONDataUpdate — JSON Data Update; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- LoadingEmptyError — Loading / Empty / Error; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- LoadingEmptyError — 로딩 / 비어 있음 / 오류; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ResponsiveContainer — Responsive Container; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- MultipleInstances — Multiple Instances; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
@@ -608,6 +654,8 @@ Source: `src/patterns/dashboard-layout.stories.tsx`; meta.tags: []
|
||||
Source: `src/patterns/data-table.stories.tsx`; meta.tags: []
|
||||
|
||||
- All — Data Table; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Overview — Overview; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- BasicSearch — Basic Search; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- WithFilterBarStory — With Filter Bar; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Loading — Loading; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Empty — Empty; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
@@ -627,6 +675,7 @@ Source: `src/patterns/gnb.stories.tsx`; meta.tags: []
|
||||
- Default — Default; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
|
||||
- Sticky — Sticky; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Sidebar — GNB Sidebar; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- BrownSidebar — Brown Sidebar; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- NavigationMenuOpen — Navigation Menu Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ShortDepthOpen — Short Depth Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- RightEdgeLongDepth — Right Edge Long Depth; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
@@ -669,26 +718,27 @@ Source: `src/patterns/lnb.stories.tsx`; meta.tags: []
|
||||
|
||||
### Patterns/Layout/Loading Screen
|
||||
|
||||
Source: `src/patterns/loading-screen.stories.tsx`; meta.tags: []
|
||||
Source: `src/patterns/loading-screen.stories.tsx`; meta.tags: ["internal"]
|
||||
|
||||
- All — Loading Screen; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Inline — Inline; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- All — Loading Screen; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Inline — Inline; tags: ["internal","verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Patterns/Layout/Page Header
|
||||
|
||||
Source: `src/patterns/page-header.stories.tsx`; meta.tags: []
|
||||
Source: `src/patterns/page-header.stories.tsx`; meta.tags: ["verification"]
|
||||
|
||||
- All — Page Header; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- All — Page Header; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- FullPage — Full Page; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### SW/Footer
|
||||
### SW/하단 명령어바
|
||||
|
||||
Source: `src/sw/sw-footer.stories.tsx`; meta.tags: []
|
||||
|
||||
- FullFooter — SW Footer; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- FullFooter — SW 하단 명령어바; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- ToolGroups — 도구 그룹별 표시; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- FigmaVariants — Figma 디자인별 Variant (35 노드); tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- CustomContent — 도구 및 메뉴 변경 예제; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Responsive — Responsive More; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Responsive — 화면 너비; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Parts — 개별 부품 (조합용); tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### SW/Header
|
||||
@@ -701,7 +751,7 @@ Source: `src/sw/sw-header-dropdown.stories.tsx`; meta.tags: []
|
||||
|
||||
Source: `src/sw/sw-header-home-menu.stories.tsx`; meta.tags: []
|
||||
|
||||
- HomeMenu — Home Menu; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- HomeMenu — Home Menu; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- HomeMenuCaptureDefault — Home Menu · 비교 캔버스 · Default; tags: ["!autodocs","verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- HomeMenuHeaderComposition — Home Menu · Header 조합 · 로고 클릭; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- HomeMenuDynamicData — Home Menu · Props · 데이터 변경; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
@@ -726,9 +776,9 @@ Source: `src/sw/sw-header.stories.tsx`; meta.tags: []
|
||||
- Overview — SW Header; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- InteractiveStates — Interactive States; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- WindowMoveMotion — Window Move Motion; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- LogoSlot — Logo; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- LogoSlot — 로고; tags: ["internal"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- DocumentDropdown — Document Dropdown; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ResponsiveMore — Responsive More; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- ResponsiveMore — 화면 너비; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- KGuide — K Guide; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### SW/Popup
|
||||
@@ -743,7 +793,7 @@ Source: `src/sw/sw-popup.stories.tsx`; meta.tags: []
|
||||
Source: `src/sw/sw-process-header.stories.tsx`; meta.tags: []
|
||||
|
||||
- ProcessHeader — Step Header; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Responsive — Responsive More; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Responsive — 화면 너비; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- SplitOpen — 화면 분할; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- MultiStep — Multi Step; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- FixedWidths — Fixed Widths; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
@@ -757,7 +807,7 @@ Source: `src/sw/sw-process-header.stories.tsx`; meta.tags: []
|
||||
|
||||
Source: `src/sw/sw-property-panel.stories.tsx`; meta.tags: []
|
||||
|
||||
- PropertyPanelCompare — Property Panel; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- PropertyPanelCompare — 속성창; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
|
||||
### SW/Theme
|
||||
|
||||
@@ -774,10 +824,10 @@ Source: `src/sw/sw-viewer.stories.tsx`; meta.tags: []
|
||||
- States — Item States; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
|
||||
- Dropdowns — Header + Dropdown; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- SettingsPanel — Settings Popup; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ContentVariants — Settings Popup; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- ContentVariants — 설정창; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- VariableSize — Variable Size Popups; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Logo — Logo; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Responsive — Responsive More; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Logo — 로고; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- Responsive — 화면 너비; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
|
||||
- CompareWithSWHeader — SWHeader vs Viewer Header; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Theme/Designer Playground
|
||||
|
||||
@@ -24,7 +24,22 @@ const inventory = files.map(file => {
|
||||
return { file: path.relative(root, file).replaceAll('\\', '/'), title, metaTags, stories }
|
||||
})
|
||||
const all = inventory.flatMap(f => f.stories)
|
||||
const summary = { storyFiles: files.length, stories: all.length, categories: Object.fromEntries(['PRIMARY', 'VERIFICATION', 'UTILITY', 'LEGACY / DUPLICATE'].map(c => [c, all.filter(s => s.category === c).length])), decisions: Object.fromEntries(['KEEP', 'HIDE', 'MERGE_CANDIDATE', 'DELETE_CANDIDATE'].map(c => [c, all.filter(s => s.decision === c || s.candidate === c).length])), hierarchy: ['Foundation', 'Components', 'Patterns', 'Center Graphic', 'Theme'] }
|
||||
// Top-level groups that actually exist, in the storySort order of .storybook/preview.tsx ("*" = unlisted groups).
|
||||
function topLevelHierarchy() {
|
||||
const groups = [...new Set(inventory.map(f => f.title.split('/')[0]))]
|
||||
const source = fs.readFileSync(path.join(root, '.storybook/preview.tsx'), 'utf8').replace(/\/\/.*$/gm, '')
|
||||
const start = source.indexOf('[', source.indexOf('order:'))
|
||||
const order = []
|
||||
for (let i = start, depth = 0; i < source.length; i++) {
|
||||
const ch = source[i]
|
||||
if (ch === '[') depth++
|
||||
else if (ch === ']' && --depth === 0) break
|
||||
else if (ch === '"') { const end = source.indexOf('"', i + 1); if (depth === 1) order.push(source.slice(i + 1, end)); i = end }
|
||||
}
|
||||
const unlisted = groups.filter(g => !order.includes(g)).sort()
|
||||
return order.flatMap(g => g === '*' ? unlisted : groups.includes(g) ? [g] : []).concat(order.includes('*') ? [] : unlisted)
|
||||
}
|
||||
const summary = { storyFiles: files.length, stories: all.length, categories: Object.fromEntries(['PRIMARY', 'VERIFICATION', 'UTILITY', 'LEGACY / DUPLICATE'].map(c => [c, all.filter(s => s.category === c).length])), decisions: Object.fromEntries(['KEEP', 'HIDE', 'MERGE_CANDIDATE', 'DELETE_CANDIDATE'].map(c => [c, all.filter(s => s.decision === c || s.candidate === c).length])), hierarchy: topLevelHierarchy() }
|
||||
const output = path.join(root, '.storybook', 'qa')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
fs.writeFileSync(path.join(output, 'story-inventory.json'), JSON.stringify({ summary, inventory }, null, 2) + '\n')
|
||||
|
||||
+390
-106
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"summary": {
|
||||
"storyFiles": 82,
|
||||
"stories": 372,
|
||||
"storyFiles": 89,
|
||||
"stories": 387,
|
||||
"categories": {
|
||||
"PRIMARY": 82,
|
||||
"VERIFICATION": 252,
|
||||
"UTILITY": 38,
|
||||
"VERIFICATION": 256,
|
||||
"UTILITY": 49,
|
||||
"LEGACY / DUPLICATE": 0
|
||||
},
|
||||
"decisions": {
|
||||
"KEEP": 82,
|
||||
"HIDE": 290,
|
||||
"HIDE": 305,
|
||||
"MERGE_CANDIDATE": 33,
|
||||
"DELETE_CANDIDATE": 0
|
||||
},
|
||||
@@ -18,20 +18,24 @@
|
||||
"Foundation",
|
||||
"Components",
|
||||
"Patterns",
|
||||
"Center Graphic",
|
||||
"Chart",
|
||||
"SW",
|
||||
"조직도",
|
||||
"노선도",
|
||||
"Verification",
|
||||
"Theme"
|
||||
]
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"file": "src/center/center-facility.stories.tsx",
|
||||
"title": "Center Graphic/Facility",
|
||||
"title": "노선도/시설물",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "center-graphic-facility--tunnel",
|
||||
"exportName": "Tunnel",
|
||||
"name": "Tunnel",
|
||||
"name": "터널",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -42,7 +46,7 @@
|
||||
{
|
||||
"id": "center-graphic-facility--bridge",
|
||||
"exportName": "Bridge",
|
||||
"name": "Bridge",
|
||||
"name": "교량",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -53,7 +57,7 @@
|
||||
{
|
||||
"id": "center-graphic-facility--intersection",
|
||||
"exportName": "Intersection",
|
||||
"name": "Intersection",
|
||||
"name": "교차로",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -64,7 +68,7 @@
|
||||
{
|
||||
"id": "center-graphic-facility--junction",
|
||||
"exportName": "Junction",
|
||||
"name": "Junction",
|
||||
"name": "분기점",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -75,7 +79,7 @@
|
||||
{
|
||||
"id": "center-graphic-facility--interchange",
|
||||
"exportName": "Interchange",
|
||||
"name": "Interchange",
|
||||
"name": "나들목",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -86,7 +90,7 @@
|
||||
{
|
||||
"id": "center-graphic-facility--roadside",
|
||||
"exportName": "Roadside",
|
||||
"name": "Roadside",
|
||||
"name": "휴게소 · 졸음쉼터",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -97,7 +101,7 @@
|
||||
{
|
||||
"id": "center-graphic-facility--linked",
|
||||
"exportName": "Linked",
|
||||
"name": "Linked",
|
||||
"name": "연계 시설물",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -124,13 +128,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/center/center-information.stories.tsx",
|
||||
"title": "Center Graphic/Information",
|
||||
"title": "노선도/정보",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "center-graphic-information--information",
|
||||
"exportName": "Information",
|
||||
"name": "Information",
|
||||
"name": "정보",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -142,7 +146,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/center/center-overview.stories.tsx",
|
||||
"title": "Center Graphic/Overview",
|
||||
"title": "노선도/Overview",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
@@ -164,13 +168,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/center/center-spatial.stories.tsx",
|
||||
"title": "Center Graphic/Spatial",
|
||||
"title": "노선도/노선·공간",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "center-graphic-spatial--spatial",
|
||||
"exportName": "Spatial",
|
||||
"name": "Spatial",
|
||||
"name": "노선·공간",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -182,13 +186,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/center/center-title.stories.tsx",
|
||||
"title": "Center Graphic/Title",
|
||||
"title": "노선도/사업명",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "center-graphic-title--compositions",
|
||||
"exportName": "Compositions",
|
||||
"name": "Title",
|
||||
"name": "사업명",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -213,6 +217,132 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/area-chart.stories.tsx",
|
||||
"title": "Chart/Area Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-area-chart--area",
|
||||
"exportName": "Area",
|
||||
"name": "Area Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/bar-chart.stories.tsx",
|
||||
"title": "Chart/Bar Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-bar-chart--bar",
|
||||
"exportName": "Bar",
|
||||
"name": "Bar Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/donut-chart.stories.tsx",
|
||||
"title": "Chart/Donut Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-donut-chart--donut",
|
||||
"exportName": "Donut",
|
||||
"name": "Donut Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/line-chart.stories.tsx",
|
||||
"title": "Chart/Line Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-line-chart--line",
|
||||
"exportName": "Line",
|
||||
"name": "Line Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/pie-chart.stories.tsx",
|
||||
"title": "Chart/Pie Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-pie-chart--pie",
|
||||
"exportName": "Pie",
|
||||
"name": "Pie Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/radar-chart.stories.tsx",
|
||||
"title": "Chart/Radar Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-radar-chart--radar",
|
||||
"exportName": "Radar",
|
||||
"name": "Radar Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/chart/radial-chart.stories.tsx",
|
||||
"title": "Chart/Radial Chart",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "chart-radial-chart--radial",
|
||||
"exportName": "Radial",
|
||||
"name": "Radial Chart",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/components/accordion.stories.tsx",
|
||||
"title": "Components/Disclosure/Accordion",
|
||||
@@ -1061,6 +1191,21 @@
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-checkbox--long-label",
|
||||
"exportName": "LongLabel",
|
||||
"name": "Long Label",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-checkbox--playground",
|
||||
"exportName": "Playground",
|
||||
@@ -1856,18 +2001,22 @@
|
||||
{
|
||||
"file": "src/components/label.stories.tsx",
|
||||
"title": "Components/Form/Label",
|
||||
"metaTags": [],
|
||||
"metaTags": [
|
||||
"internal"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "components-form-label--all",
|
||||
"exportName": "All",
|
||||
"name": "Label",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
],
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3384,6 +3533,36 @@
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-textarea--multiline",
|
||||
"exportName": "Multiline",
|
||||
"name": "Multiline · Wrap",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-textarea--resize-none",
|
||||
"exportName": "ResizeNone",
|
||||
"name": "Resize · none",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-textarea--readonly",
|
||||
"exportName": "Readonly",
|
||||
@@ -3449,18 +3628,22 @@
|
||||
{
|
||||
"file": "src/components/toggle-group.stories.tsx",
|
||||
"title": "Components/Form/Toggle Group",
|
||||
"metaTags": [],
|
||||
"metaTags": [
|
||||
"internal"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "components-form-toggle-group--all",
|
||||
"exportName": "All",
|
||||
"name": "Toggle Group",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
],
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-toggle-group--single-selection",
|
||||
@@ -3470,9 +3653,10 @@
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal",
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
@@ -3485,9 +3669,10 @@
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal",
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
@@ -3500,9 +3685,26 @@
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal",
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-toggle-group--many-options",
|
||||
"exportName": "ManyOptions",
|
||||
"name": "Many Options",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal",
|
||||
"verification"
|
||||
],
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
@@ -3515,9 +3717,10 @@
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal",
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
@@ -3637,13 +3840,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/foundation/color.stories.tsx",
|
||||
"title": "Foundation/Color",
|
||||
"title": "Foundation/한맥 컬러",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "foundation-color--all",
|
||||
"exportName": "All",
|
||||
"name": "Color",
|
||||
"name": "한맥 컬러",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -3655,13 +3858,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/foundation/company-colors.stories.tsx",
|
||||
"title": "Foundation/Company Colors",
|
||||
"title": "Foundation/가족사 컬러",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "foundation-company-colors--all",
|
||||
"exportName": "All",
|
||||
"name": "Company Colors",
|
||||
"name": "가족사 컬러",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -3674,30 +3877,34 @@
|
||||
{
|
||||
"file": "src/foundation/layout.stories.tsx",
|
||||
"title": "Foundation/Layout",
|
||||
"metaTags": [],
|
||||
"metaTags": [
|
||||
"verification"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "foundation-layout--all",
|
||||
"exportName": "All",
|
||||
"name": "Layout",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/foundation/typography.stories.tsx",
|
||||
"title": "Foundation/Typography",
|
||||
"title": "Foundation/폰트",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "foundation-typography--all",
|
||||
"exportName": "All",
|
||||
"name": "Typography",
|
||||
"name": "폰트",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -3709,7 +3916,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart-cards.stories.tsx",
|
||||
"title": "OrgChart/Components/Cards",
|
||||
"title": "조직도/Components/Cards",
|
||||
"metaTags": [
|
||||
"internal"
|
||||
],
|
||||
@@ -3835,13 +4042,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart-department-colors.stories.tsx",
|
||||
"title": "OrgChart/Department Colors",
|
||||
"title": "조직도/부서별 색상",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "orgchart--department-colors",
|
||||
"exportName": "DepartmentColors",
|
||||
"name": "Department Colors",
|
||||
"name": "부서별 색상",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -3853,13 +4060,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart-expanded.stories.tsx",
|
||||
"title": "OrgChart/Expanded OrgChart",
|
||||
"title": "조직도/확장 조직도",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "orgchart--expanded-org-chart",
|
||||
"exportName": "ExpandedOrgChart",
|
||||
"name": "Expanded OrgChart",
|
||||
"name": "확장 조직도",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -3871,13 +4078,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart-full-composition.stories.tsx",
|
||||
"title": "OrgChart/Full Composition",
|
||||
"title": "조직도/전체 구성",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "orgchart--full-composition",
|
||||
"exportName": "FullComposition",
|
||||
"name": "Full Composition",
|
||||
"name": "전체 구성",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -3889,7 +4096,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart-overview-internal.stories.tsx",
|
||||
"title": "OrgChart/Verification/Overview",
|
||||
"title": "조직도/Verification/Overview",
|
||||
"metaTags": [
|
||||
"internal"
|
||||
],
|
||||
@@ -3937,13 +4144,13 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart-p1.stories.tsx",
|
||||
"title": "OrgChart/Overview",
|
||||
"title": "조직도/개요",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "orgchart--logo-personnel-summary",
|
||||
"exportName": "LogoPersonnelSummary",
|
||||
"name": "Overview",
|
||||
"name": "개요",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -3955,7 +4162,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart-summary.stories.tsx",
|
||||
"title": "OrgChart/Components/Summaries",
|
||||
"title": "조직도/Components/Summaries",
|
||||
"metaTags": [
|
||||
"internal"
|
||||
],
|
||||
@@ -4003,7 +4210,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/org-chart/org-chart.stories.tsx",
|
||||
"title": "OrgChart/Content",
|
||||
"title": "조직도/콘텐츠",
|
||||
"metaTags": [
|
||||
"internal"
|
||||
],
|
||||
@@ -4011,7 +4218,7 @@
|
||||
{
|
||||
"id": "orgchart-content--basic-organization",
|
||||
"exportName": "BasicOrganization",
|
||||
"name": "Basic Organization",
|
||||
"name": "기본 조직도",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
@@ -4024,7 +4231,7 @@
|
||||
{
|
||||
"id": "orgchart-content--organization-hierarchy",
|
||||
"exportName": "OrganizationHierarchy",
|
||||
"name": "Organization Hierarchy",
|
||||
"name": "조직 계층",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
@@ -4037,7 +4244,7 @@
|
||||
{
|
||||
"id": "orgchart-content--employee-cards",
|
||||
"exportName": "EmployeeCards",
|
||||
"name": "Employee Cards",
|
||||
"name": "직원 카드",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
@@ -4050,7 +4257,7 @@
|
||||
{
|
||||
"id": "orgchart-content--organization-summary",
|
||||
"exportName": "OrganizationSummary",
|
||||
"name": "Organization Summary",
|
||||
"name": "조직 요약",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
@@ -4076,7 +4283,7 @@
|
||||
{
|
||||
"id": "orgchart-content--loading-empty-error",
|
||||
"exportName": "LoadingEmptyError",
|
||||
"name": "Loading / Empty / Error",
|
||||
"name": "로딩 / 비어 있음 / 오류",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
@@ -4253,6 +4460,36 @@
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
},
|
||||
{
|
||||
"id": "patterns-data-data-table--overview",
|
||||
"exportName": "Overview",
|
||||
"name": "Overview",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "patterns-data-data-table--basic-search",
|
||||
"exportName": "BasicSearch",
|
||||
"name": "Basic Search",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "patterns-data-data-table--with-filter-bar-story",
|
||||
"exportName": "WithFilterBarStory",
|
||||
@@ -4394,6 +4631,21 @@
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "patterns-layout-gnb--brown-sidebar",
|
||||
"exportName": "BrownSidebar",
|
||||
"name": "Brown Sidebar",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "patterns-layout-gnb--navigation-menu-open",
|
||||
"exportName": "NavigationMenuOpen",
|
||||
@@ -4912,18 +5164,22 @@
|
||||
{
|
||||
"file": "src/patterns/loading-screen.stories.tsx",
|
||||
"title": "Patterns/Layout/Loading Screen",
|
||||
"metaTags": [],
|
||||
"metaTags": [
|
||||
"internal"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "patterns-layout-loading-screen--all",
|
||||
"exportName": "All",
|
||||
"name": "Loading Screen",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
],
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "patterns-layout-loading-screen--inline",
|
||||
@@ -4932,6 +5188,44 @@
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal",
|
||||
"verification"
|
||||
],
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/patterns/page-header.stories.tsx",
|
||||
"title": "Patterns/Layout/Page Header",
|
||||
"metaTags": [
|
||||
"verification"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "patterns-layout-page-header--all",
|
||||
"exportName": "All",
|
||||
"name": "Page Header",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "patterns-layout-page-header--full-page",
|
||||
"exportName": "FullPage",
|
||||
"name": "Full Page",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
@@ -4942,33 +5236,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/patterns/page-header.stories.tsx",
|
||||
"title": "Patterns/Layout/Page Header",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "patterns-layout-page-header--all",
|
||||
"exportName": "All",
|
||||
"name": "Page Header",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/sw/sw-footer.stories.tsx",
|
||||
"title": "SW/Footer",
|
||||
"title": "SW/하단 명령어바",
|
||||
"metaTags": [],
|
||||
"stories": [
|
||||
{
|
||||
"id": "sw-footer--full-footer",
|
||||
"exportName": "FullFooter",
|
||||
"name": "SW Footer",
|
||||
"name": "SW 하단 명령어바",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -5024,7 +5300,7 @@
|
||||
{
|
||||
"id": "sw-footer--responsive",
|
||||
"exportName": "Responsive",
|
||||
"name": "Responsive More",
|
||||
"name": "화면 너비",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -5080,12 +5356,16 @@
|
||||
"id": "sw-header--home-menu",
|
||||
"exportName": "HomeMenu",
|
||||
"name": "Home Menu",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"storyTags": [
|
||||
"internal"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
],
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "sw-header--home-menu-capture-default",
|
||||
@@ -5316,13 +5596,17 @@
|
||||
{
|
||||
"id": "sw-header--logo-slot",
|
||||
"exportName": "LogoSlot",
|
||||
"name": "Logo",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
"decision": "KEEP",
|
||||
"name": "로고",
|
||||
"storyTags": [
|
||||
"internal"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"internal"
|
||||
],
|
||||
"category": "UTILITY",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Representative story or distinct reference page."
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "sw-header--document-dropdown",
|
||||
@@ -5342,7 +5626,7 @@
|
||||
{
|
||||
"id": "sw-header--responsive-more",
|
||||
"exportName": "ResponsiveMore",
|
||||
"name": "Responsive More",
|
||||
"name": "화면 너비",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -5423,7 +5707,7 @@
|
||||
{
|
||||
"id": "sw-step-header--responsive",
|
||||
"exportName": "Responsive",
|
||||
"name": "Responsive More",
|
||||
"name": "화면 너비",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -5561,7 +5845,7 @@
|
||||
{
|
||||
"id": "sw-header--property-panel-compare",
|
||||
"exportName": "PropertyPanelCompare",
|
||||
"name": "Property Panel",
|
||||
"name": "속성창",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -5672,7 +5956,7 @@
|
||||
{
|
||||
"id": "sw-viewer--content-variants",
|
||||
"exportName": "ContentVariants",
|
||||
"name": "Settings Popup",
|
||||
"name": "설정창",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -5698,7 +5982,7 @@
|
||||
{
|
||||
"id": "sw-viewer--logo",
|
||||
"exportName": "Logo",
|
||||
"name": "Logo",
|
||||
"name": "로고",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
@@ -5709,7 +5993,7 @@
|
||||
{
|
||||
"id": "sw-viewer--responsive",
|
||||
"exportName": "Responsive",
|
||||
"name": "Responsive More",
|
||||
"name": "화면 너비",
|
||||
"storyTags": [],
|
||||
"effectiveTags": [],
|
||||
"category": "PRIMARY",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 기준 우선순위: 2026-10-10 추가 수정 요청의 확정 사항 → 충돌하지 않는 D-01/D-02 → 현재 HM Component/Pattern/Theme → 현재 Storybook/구현 → 과거 문서. [현재 추가 수정 기준](docs/guidelines/publisher-additional-review-2026-10-10.md). 미확정 값은 임의 확정하지 않으며 과거 검증 기록은 보존합니다.
|
||||
|
||||
shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. React + TypeScript + Vite, 개발 Storybook 포트 6024(포터블 테스트 ZIP은 기본 6007, 충돌 시 자동 변경). 실행법과 파일 위치는 `README.md`를 따른다.
|
||||
shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. React + TypeScript + Vite, 개발 Storybook 포트 6024(과거 포터블 테스트 ZIP의 6007은 당시 기록이며 현재 Git Clone에는 ZIP이 포함되지 않는다). 실행법과 파일 위치는 `README.md`를 따른다.
|
||||
|
||||
## 지침 문서
|
||||
|
||||
@@ -63,7 +63,7 @@ shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. Reac
|
||||
- Button(`variant`=역할, `appearance`=treatment, `size`), Input(`size`, native 상태 selector), Select(Trigger `size`, Radix 상태 속성)는 ui_viz Confirmed 규격으로 구현했다. Card도 `<Card />`만으로 HM 스타일이 적용된다(ui_viz Content Card, `packages/ui/src/theme/card.css`). Dialog도 `<DialogContent />`만으로 HM 스타일이 적용된다(`packages/ui/src/theme/dialog.css`).
|
||||
- 구조(2026-10-02 패키지 분리, npm workspaces): UI 컴포넌트·Theme 구현은 `packages/ui`(`@hm-design/ui`, 컴포넌트 `src/components/ui/`, Theme `src/theme/*.css`, 진입점 `src/theme.css`·`src/tailwind.css`), 컴포넌트 문서는 `docs/components/`, Storybook·Demo는 루트(`.storybook/`, `src/`; Demo 스타일은 `src/hm-theme.css`), `packages/center-graphic`(`@hm-design/center-graphic`)은 CENTER 구현(제목·정보·노선/공간·시설물), 기준 문서는 `docs/center-graphic/`다.
|
||||
- 작업 순서: 컴포넌트 문서 정리, Card, Dialog, 패키지 분리, CENTER 구현, 1단계 UI 컴포넌트(Textarea, Tabs, Label, Checkbox, Radio Group, Switch, Badge, Alert, Separator)와 2단계 A묶음 Floating(Dropdown Menu, Popover, Tooltip, Hover Card)과 B묶음 Modal(Sheet, Drawer(vaul), Alert Dialog)과 C묶음 Expand(Accordion, Collapsible)과 D묶음 Navigation(Breadcrumb, Pagination, Navigation Menu, Menubar)까지 완료로 2단계 UI가 끝났다. 3단계 A묶음 Data/Feedback(Table, Progress, Skeleton, Avatar, Scroll Area)과 B묶음 Selection Controls(Slider, Toggle, Toggle Group)과 C묶음 Search/Date(Calendar, Command, Date Picker, Combobox)까지 완료(새 dependency: `react-day-picker`, `cmdk`)과 D묶음 Layout/Media(Carousel, Aspect Ratio, Resizable)까지 완료로 3단계 UI가 끝났다(새 dependency: `embla-carousel-react`, `react-resizable-panels`). floating surface Semantic 정합성 작업도 완료했다. 4단계 A묶음 독립 UI(Context Menu, Toast/Sonner)까지 완료(새 dependency: `sonner`). 4단계 B묶음 업무 Pattern(Data Table, Filter Bar)까지 완료(새 dependency: `@tanstack/react-table` v9)로 4단계 핵심이 끝났다. Layout Pattern은 GNB·LNB·Page Header·Dashboard Layout까지 완료(`docs/patterns/gnb.md`, `lnb.md`, `page-header.md`, `dashboard-layout.md`, ui-viz 시각 기준)했다. 각 Navigation Mode는 독립 레이아웃을 소유한다. GNB ONLY는 기존 LNB 메뉴 부품을 재사용하는 내부 Sidebar를 허용하며 외곽 배경만 Green Light 2로 적용한다. DASHBOARD ONLY 운영 화면은 전용 Header(48px)·업무 상태 필터 + Content로 구성하고 Sidebar를 금지한다. 운영 최소 폭 960px·내부 가로 스크롤을 유지한다. 기존 DashboardSidebar Export·sidebar slot·1232px 구성은 호환성·검증용이며 신규 운영 Reference가 아니다. LNB 3뎁스(`LNBGroup` 중첩, 2026-10-06)와 모바일 Sheet 중첩 그룹까지 완료(`docs/patterns/lnb.md` §3a). 현재 LNB 모바일은 독립 LNB Mobile Header와 Sheet를 사용한다(`lnb.md` §3b). 과거 GNB+LNB 조합·Brand ownership·gradient 접점 기준은 Navigation Single Mode Policy로 대체되었다. 한 페이지에는 GNB / LNB / DASHBOARD 중 하나만 사용하며 기존 혼합 Story는 verification / VERIFICATION ONLY 조건의 기술 검증에만 한정한다. 경로: UI primitive `packages/ui/src/components/ui`, Pattern `packages/ui/src/patterns`, Pattern 문서 `docs/patterns`. Field는 코드 없이 `docs/components/field.md` 소비 가이드만 둔다.
|
||||
- 패키지는 5개다: `@hm-design/ui`(Web), `@hm-design/sw`(SW 일반·뷰어 모드, `packages/sw`, Story `src/sw/`, 문서 `docs/sw/`), `@hm-design/org-chart`·`@hm-design/org-chart-react`(OrgChart, Story `src/org-chart/`, 문서 `docs/org-chart/`), `@hm-design/center-graphic`(CENTER). 역할·진입점 표는 `README.md`의 패키지 구성, 외부 설치는 `docs/guidelines/external-install-submodule.md`를 따른다. `org-chart-react`가 Core에 의존하고 `/hm-dialog`만 `@hm-design/ui`를 optional peer로 쓰는 것 외에는 서로 의존하지 않는다. 모두 private이 아니며 compiled dist export(JS·타입·CSS)와 library build가 구현되어 있다. Registry publish 이력은 ZIP 검증 범위 밖이다. Runtime Theme URL Loader와 Theme JSON 독립 산출은 구현되었고 실제 CDN infrastructure/publish pipeline은 미구현이다. CENTER 기준은 `docs/center-graphic/README.md`, `packages/center-graphic/src/`, `src/center/`의 현재 HM 구현과 대표 Story다. 외부 ui_viz owner 자료는 제공된 경우에만 참고하며 필수 실행 경로가 아니다. UI와 CENTER 규격을 섞지 않는다.
|
||||
- 패키지는 6개다: `@hm-design/chart`(Bar·Line·Donut·Pie·Radar·Area·Radial 차트, shadcn Chart + Recharts, Story `src/chart/`, 소비 예제 `examples/chart/`, 문서 `packages/chart/README.md`; `@hm-design/ui`의 Theme 토큰(`theme.css`)을 쓰는 peer 의존이며 `@hm-design/ui` JS는 import하지 않는다), `@hm-design/ui`(Web), `@hm-design/sw`(SW 일반·뷰어 모드, `packages/sw`, Story `src/sw/`, 문서 `docs/sw/`), `@hm-design/org-chart`·`@hm-design/org-chart-react`(OrgChart, Story `src/org-chart/`, 문서 `docs/org-chart/`), `@hm-design/center-graphic`(CENTER). 역할·진입점 표는 `README.md`의 패키지 구성, 외부 설치는 `docs/guidelines/external-install-submodule.md`를 따른다. `org-chart-react`가 Core에 의존하고 `/hm-dialog`만 `@hm-design/ui`를 optional peer로 쓰는 것, `chart`가 `@hm-design/ui` Theme CSS 토큰을 peer로 쓰는 것 외에는 서로 의존하지 않는다. 모두 private이 아니며 compiled dist export(JS·타입·CSS)와 library build가 구현되어 있다. Registry publish 이력은 ZIP 검증 범위 밖이다. Runtime Theme URL Loader와 Theme JSON 독립 산출은 구현되었고 실제 CDN infrastructure/publish pipeline은 미구현이다. CENTER 기준은 `docs/center-graphic/README.md`, `packages/center-graphic/src/`, `src/center/`의 현재 HM 구현과 대표 Story다. 외부 ui_viz owner 자료는 제공된 경우에만 참고하며 필수 실행 경로가 아니다. UI와 CENTER 규격을 섞지 않는다.
|
||||
|
||||
> 위 작업 순서는 구현 이력이다. 현재 API·제약은 코드와 Component/Pattern 문서를 대조한다.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# HM Design System
|
||||
|
||||
React + TypeScript + Vite와 shadcn/Radix 기반 HM 디자인 시스템입니다. Web UI·SW UI·조직도·CENTER Graphic을 독립 workspace 패키지와 통합 Storybook으로 확인합니다.
|
||||
React + TypeScript + Vite와 shadcn/Radix 기반 HM 디자인 시스템입니다. Web UI·SW UI·조직도·CENTER Graphic·차트를 독립 workspace 패키지와 통합 Storybook으로 확인합니다.
|
||||
|
||||
## 패키지 구성
|
||||
|
||||
npm workspace(`packages/*`)의 패키지 5개입니다. 서로 의존하지 않으며(아래 표의 예외 제외) 각자 `dist/`의 ESM JS·타입·CSS를 `exports`로 공개합니다.
|
||||
npm workspace(`packages/*`)의 패키지 6개입니다. 서로 의존하지 않으며(아래 표의 예외 제외) 각자 `dist/`의 ESM JS·타입·CSS를 `exports`로 공개합니다.
|
||||
|
||||
| 패키지 | 역할 | 공개 진입점 | Storybook | AI 지침 |
|
||||
|---|---|---|---|---|
|
||||
@@ -13,6 +13,7 @@ npm workspace(`packages/*`)의 패키지 5개입니다. 서로 의존하지 않
|
||||
| `@hm-design/org-chart` | **OrgChart** Core(Vanilla, Shadow DOM, 데이터·배치·상호작용) | `.`, `/style.css`, `/engine.css`, `/browser` | OrgChart | `packages/org-chart/ai/` |
|
||||
| `@hm-design/org-chart-react` | OrgChart React Wrapper. Core `@hm-design/org-chart`에 의존하고, 선택 Entry `/hm-dialog`만 `@hm-design/ui`(optional peer)를 사용 | `.`, `/hm-dialog` | OrgChart | `packages/org-chart-react/ai/` |
|
||||
| `@hm-design/center-graphic` | CENTER 그래픽(제목·정보·노선/공간·시설물). 내부 검토 범위, License 미확정 | `.`, `/reference`, `/style.css` | Center Graphic | 없음(README) |
|
||||
| `@hm-design/chart` | **Chart** Bar·Line·Donut·Pie·Radar·Area·Radial 차트(공식 shadcn/ui Chart + Recharts). `@hm-design/ui` Theme 토큰(`theme.css`)을 사용하는 peer 의존(JS import 없음), 예시 데이터는 `/examples`에 분리 | `.`, `/examples`, `/style.css`, `/tailwind.css` | Chart | 패키지 `ai/` 없음 — [README](packages/chart/README.md)가 사용 지침(공통 [`AI_GUIDE`](docs/ai/AI_GUIDE.md)의 "기타" 유형) |
|
||||
|
||||
다른 프로젝트에서의 설치는 [외부 설치 가이드(Git Submodule + npm Workspace)](docs/guidelines/external-install-submodule.md)를 따릅니다.
|
||||
|
||||
@@ -128,7 +129,7 @@ SW는 Tailwind 없이 앱 진입점에서 `import "@hm-design/sw/styles.css"`를
|
||||
|
||||
화면 생성 Reference: [Navigation 선택 근거](docs/guidelines/navigation-single-mode.md#선택-근거), [GNB Desktop·Mobile 구성](docs/patterns/gnb.md#8-desktop--mobile)과 [내부 Sidebar](docs/patterns/gnb.md#15-gnb-only-sidebar-composition-2026-10-08), [FilterBar 조회·초기화 소비 예제](docs/patterns/filter-bar.md#61-조회-버튼으로-적용하는-소비-예제). 일반 업무 목록의 모드를 임의 확정하거나 FilterBar에 조회 API가 있다고 가정하지 않습니다.
|
||||
|
||||
루트 `package.json`의 `workspaces`(`packages/*`)로 연결됩니다. 확인: `npm run typecheck`, `npm run build`. 위 [패키지 구성](#패키지-구성)의 5개 패키지는 모두 private이 아니며 ESM JS·타입 선언·CSS를 `dist`에서 export합니다. `npm run build:packages`로 재빌드할 수 있습니다. HM 패키지는 npm Registry로 배포하지 않으며, 다른 프로젝트는 [외부 프로젝트 설치](docs/guidelines/external-install-submodule.md)의 Git Submodule + npm Workspace 방식으로 연결합니다.
|
||||
루트 `package.json`의 `workspaces`(`packages/*`)로 연결됩니다. 확인: `npm run typecheck`, `npm run build`. 위 [패키지 구성](#패키지-구성)의 6개 패키지는 모두 private이 아니며 ESM JS·타입 선언·CSS를 `dist`에서 export합니다. `npm run build:packages`로 재빌드할 수 있습니다. HM 패키지는 npm Registry로 배포하지 않으며, 다른 프로젝트는 [외부 프로젝트 설치](docs/guidelines/external-install-submodule.md)의 Git Submodule + npm Workspace 방식으로 연결합니다.
|
||||
|
||||
## 파일 위치
|
||||
|
||||
@@ -146,6 +147,7 @@ SW는 Tailwind 없이 앱 진입점에서 `import "@hm-design/sw/styles.css"`를
|
||||
- `docs/ai/`: 패키지 `ai/` 공통 지침 원본(`npm run sync:ai-docs`, `npm run check:ai-docs`)
|
||||
- `packages/center-graphic/` (`@hm-design/center-graphic`): CENTER 그래픽(제목·정보·노선/공간·시설물), `@hm-design/ui`에 의존하지 않음
|
||||
- `src/center/`: CENTER Storybook (`Center Graphic` 그룹)
|
||||
- `packages/chart/` (`@hm-design/chart`): Bar·Line·Donut·Pie·Radar·Area·Radial 차트와 예시 데이터. `src/chart/`: Chart Storybook(`Chart` 그룹), `examples/chart/`: 소비 예제(`npm run dev:chart-example`)
|
||||
- `docs/center-graphic/`: CENTER 구현 기준 문서
|
||||
- `src/App.tsx`: 기본 UI 확인 화면 (`@hm-design/ui` 사용)
|
||||
- `src/hm-theme.css`: App/Storybook 예시 화면 스타일. 장식용 시제품 색상은 고정값으로 남아 있음
|
||||
|
||||
+2
-1
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 `@hm-design/ui`·`@hm-design/sw`·`@hm-design/center-graphic`·`@hm-design/chart`의 peer이고, `@hm-design/org-chart-react`는 React `^19`만 peer로 요구합니다. `@hm-design/org-chart`(Core)는 peer가 없습니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `packages/org-chart-react/ai/` | `AI_USAGE`(Core `ai/` 안내) | 포함 |
|
||||
| `scripts/ai-docs.mjs` | 동기화(`--sync`)·검사(`--check`) | — |
|
||||
|
||||
`@hm-design/center-graphic`에는 AI 지침이 없습니다(공통 가이드에 "README만"으로 안내). 추가하려면 같은 4종 구조로 만들고 `scripts/ai-docs.mjs`의 `packages`에 등록합니다.
|
||||
`@hm-design/center-graphic`과 `@hm-design/chart`에는 `ai/` 폴더가 없습니다(공통 가이드에 "README만"으로 안내). `@hm-design/chart`는 `packages/chart/README.md`가 설치·CSS 연결·차트 선택·Props·검증 지침을 겸하므로 차트 공개 Export·Props·기본값이 바뀌면 같은 Commit에서 README를 고칩니다(`check:ai-docs`는 chart README를 검사하지 않음). 추가하려면 같은 4종 구조로 만들고 `scripts/ai-docs.mjs`의 `packages`에 등록합니다.
|
||||
|
||||
저장소 루트 `AGENTS.md`·`CLAUDE.md`는 **이 저장소를 개발하는 AI**용 규칙이고, `packages/*/ai/`는 **패키지를 설치해 쓰는 소비 프로젝트의 AI**용 규칙입니다. 내용이 겹쳐도 서로를 대신하지 않습니다. 패키지 `ai/` 문서는 패키지 밖(`../../docs/…`)을 링크하지 않습니다(설치 후 존재하지 않음).
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
| variant | 면 | 글자 | 테두리 |
|
||||
|---|---|---|---|
|
||||
| `default` | `--hm-sem-action-primary` (Primary Green `#123328`) | `--hm-sem-on-action-primary` (White) | 면과 동일 |
|
||||
| `default` | `--hm-sem-action-primary` (Primary Green `#164434`) | `--hm-sem-on-action-primary` (White) | 면과 동일 |
|
||||
| `secondary` | `--hm-sem-bg-subtle` | `--hm-sem-fg-secondary` | 면과 동일 |
|
||||
| `destructive` | `--hm-sem-action-destructive` | White | 면과 동일 |
|
||||
| `outline` | `--hm-sem-bg-surface` | `--hm-sem-fg-primary` | `--hm-sem-stroke-default` |
|
||||
|
||||
@@ -69,7 +69,7 @@ Component Mapping 위치: `packages/ui/src/theme/breadcrumb.css` (`--hm-breadcru
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Breadcrumb"): Basic(3 depth), Ellipsis(Dropdown Menu 조합), Long path, Custom link(asChild)와 custom separator를 한 페이지에 둔다.
|
||||
- 대표 Story `All`("Breadcrumb"): Variants(Basic(3 depth), Ellipsis(Dropdown Menu 조합), Custom link(asChild)와 custom separator)를 PC 균등 3열로, Usage(Long path, 최대 900px)를 한 페이지에 둔다(2026-11-09 Overview 제거).
|
||||
- verification: `Basic`, `Ellipsis`, `Long Path`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -150,7 +150,7 @@ CVA 구조, `data-slot`, `asChild`(Radix `Slot`), `className` 확장, focus-visi
|
||||
## 11. Storybook
|
||||
|
||||
- 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스.
|
||||
- `All`의 구역: Overview → Sizes → Variants → States. 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다.
|
||||
- `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. PC에서는 Sizes·Variants가 같은 3열(Brand / Standard Solid / Standard Outline)을 쓰고 States의 Solid·Outline 표는 좌우 2열이다.
|
||||
- verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다.
|
||||
- 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다.
|
||||
- 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조.
|
||||
|
||||
@@ -100,8 +100,8 @@ Radix 구조·ARIA(`role=checkbox`, `aria-checked`)·키보드, `data-slot`, `cn
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Checkbox"): `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `error`, `disabled`, `long`. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다.
|
||||
- verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `Playground`.
|
||||
- 대표 Story `All`("Checkbox"): States `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `disabled`(PC 3열 × 2행)와 Validation `error`. Overview·Usage 구역은 2026-11-09에 없앴고 `long`(Long Label) 예시는 숨김 verification Story `LongLabel`로 옮겼다. 옵션 Label은 Story에서만 `--hm-label-text`를 `--foreground`로 덮어 기본 텍스트 색으로 보이며(공용 Label은 그대로, 비활성 Checkbox 옆 Label은 `peer-disabled`로 보조색 유지), 예시 제목은 보조색이다. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다.
|
||||
- verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `LongLabel`("Long Label"), `Playground`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Radix Label 동작·접근성, `data-slot`, `cn(..., className)`, `select-none`,
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Label"): 구역 `default`(htmlFor), `required`(소비 조합), `disabled`, `long`.
|
||||
- 대표 Story `All`("Label"): States(`default`(htmlFor), `disabled`)와 Usage(`select`(부서 선택), `required`(소비 조합), `long`)(2026-11-09 Overview 제거).
|
||||
- 별도 verification Story 없음(구역이 단순).
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -76,7 +76,7 @@ Radix 구조·ARIA(`role=menubar`, `menu`, `menuitem*`)·키보드 모델(Down/E
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Menubar"): 파일(item+shortcut, disabled, separator, sub menu 내보내기, destructive 종료), 편집, 보기(Label, Checkbox, Radio Group)를 가진 실제 bar.
|
||||
- 대표 Story `All`("Menubar"): 구역 제목은 Basic(2026-11-09 Overview에서 변경, 열린 메뉴용 고정 min-height 384px 제거). 파일(item+shortcut, disabled, separator, sub menu 내보내기, destructive 종료), 편집, 보기(Label, Checkbox, Radio Group)를 가진 실제 bar.
|
||||
- 숨김 verification: `TriggerFocus`, `OpenFocus`. `OpenFocus`는 검수 목적으로 Story의 `play`에서 실제 열린 menu의 Trigger에 focus를 준다. 일반 Story/Production은 Radix 기본대로 메뉴 항목에 focus를 이동한다.
|
||||
- Focus 검수는 일반 Story의 computed style 기준으로 한다. Designer의 `.hm-designer button:focus-visible` 장식 outline을 컴포넌트 ring의 근거로 사용하지 않는다.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Radix 구조·동작(hover open delay, Arrow 키와 Tab 이동, Escape 닫힘, f
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Navigation Menu"): `Viewport(기본)`과 `viewport={false}` 두 구성을 한 페이지에 둔다. 제품(설명 있는 3 링크), 자료(2열 4 링크), 회사(현재 위치 링크), 문의(일반 링크), 준비 중(disabled Trigger).
|
||||
- 대표 Story `All`("Navigation Menu"): Variants 한 구역에 `Viewport(기본)`과 `viewport={false}` 두 구성을 PC 균등 2열로 둔다(2026-11-09 Overview 제거, 닫힌 메뉴용 min-height 256px는 PC에서만 해제하고 1000px 미만 1열에서는 유지). 제품(설명 있는 3 링크), 자료(2열 4 링크), 회사(현재 위치 링크), 문의(일반 링크), 준비 중(disabled Trigger).
|
||||
- verification: `Default`, `No Viewport`.
|
||||
- Story의 Content 폭은 `style` 인라인으로 지정한다(새 Story 파일의 Tailwind 임의값 클래스가 개발 서버에서 생성되지 않는 일이 있었다).
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| Page link | 34 × 34px(min-width 34), radius 4px(`--hm-radius-control-md`), 14 / 20 / 500, `--hm-sem-fg-primary` |
|
||||
| Previous / Next | 높이 34px, padding `0 12px`, 아이콘 16px, 라벨 `hidden sm:block`("Previous"/"Next", 모바일은 아이콘만) |
|
||||
| 간격 | 목록 gap 4px |
|
||||
| Current Page | 배경 `--hm-sem-action-primary`(Primary Green `#123328`), 글자 `--hm-sem-on-action-primary`(흰색), weight 700 |
|
||||
| Current Page | 배경 `--hm-sem-action-primary`(Primary Green `#164434`), 글자 `--hm-sem-on-action-primary`(흰색), weight 700 |
|
||||
| Ellipsis | 34 × 34 영역, 아이콘 16px, `--hm-sem-fg-secondary` |
|
||||
|
||||
## 4. 상태 규격
|
||||
@@ -67,7 +67,7 @@ Component Mapping 위치: `packages/ui/src/theme/pagination.css` (`--hm-paginati
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Pagination"): First page, Middle page, Last page, Long range with ellipsis(1 … 9 10 11 … 20)를 한 페이지에 둔다. 각 Specimen이 local state를 가지고 클릭 시 현재 페이지가 실제로 바뀐다(`#page-N` href + `preventDefault`).
|
||||
- 대표 Story `All`("Pagination"): Variants 한 구역에 First page, Middle page, Last page, Long range with ellipsis(1 … 9 10 11 … 20)를 PC 2열 × 2행으로 둔다(2026-11-09 Overview / States / Usage 통합, 버튼 묶음은 칸 안 왼쪽 정렬). 각 Specimen이 local state를 가지고 클릭 시 현재 페이지가 실제로 바뀐다(`#page-N` href + `preventDefault`).
|
||||
- verification: `First Page`, `Last Page`, `Long Range`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -57,7 +57,7 @@ shadcn `size-4 border-input text-primary shadow-xs dark:` class 제거 → theme
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
대표 Story `All`: `vertical`, `horizontal`, `hover`, `focus`, `error`, `disabled-item`, `disabled-group`. Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`.
|
||||
대표 Story `All`: Variants(`vertical`, `horizontal`; 2026-11-09 Overview 제거로 Vertical이 Variants 첫 항목), States(`hover`, `focus`, `disabled-item`, `disabled-group`), Validation(`error`). Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ Radix Root/Trigger/Portal/Content/Viewport/Item/ItemText 구조, 키보드 조
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Invalid / Focus(`invalid`, `invalid-disabled`). 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다.
|
||||
- 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Validation(`invalid`, `invalid-disabled`; 2026-11-09 `Invalid / Focus`에서 제목 변경, Focus 예시가 없다). PC에서는 세 구역이 같은 3열 칸을 쓰고 Sizes·Validation은 첫 두 칸만 채운다. 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다.
|
||||
- verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용).
|
||||
- `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함).
|
||||
- `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다.
|
||||
|
||||
@@ -74,7 +74,7 @@ Radix 구조·동작(pointer drag, track 클릭 이동, 키보드, thumb 정렬/
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Slider"): Single(값 label), Range(두 thumb, `minStepsBetweenThumbs` 5), Step(25), Disabled, Vertical을 한 페이지에 둔다. 값 label은 Story 조합(local state)이다.
|
||||
- 대표 Story `All`("Slider"): Variants(Single, Range(두 thumb, `minStepsBetweenThumbs` 5), Step(25), Vertical)와 States(Disabled) 두 구역으로 둔다(2026-11-09 Overview 제거, PC 4열). 값 label은 Story 조합(local state)이다.
|
||||
- verification: `Single`, `Range`, `Disabled`, `Focus`(실제 thumb focus). 기본 Sidebar에서 숨긴다.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -56,7 +56,7 @@ shadcn의 `h-[1.15rem] w-8`, `border`, `shadow-xs`, `dark:` class 제거 → the
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
대표 Story `All`: `off`, `on`, `hover`, `focus`, `disabled`(2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`.
|
||||
대표 Story `All`: States 한 구역에 `off`, `on`, `hover`, `focus`, `disabled`(2026-11-09 Overview 제거, PC 5열; 2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@hm-design/ui"
|
||||
| 상태 | 면 | 테두리 | 전경 |
|
||||
|---|---|---|---|
|
||||
| Default (`data-state="inactive"`) | `rgba(189,223,212,.45)` | top/right `rgba(38,102,81,.35)`, bottom `#DEDBD2` | `#266651` |
|
||||
| Selected (`data-state="active"`) | `#FFFFFF` (`--hm-sem-bg-surface`) | top/right/left `#DEDBD2`, bottom 1px 표면색(기준선을 덮어 콘텐츠와 연결), `z-index: 2` | `#123328` |
|
||||
| Hover (비선택만, `:hover`) | 기본 면 + Black 20% overlay (`linear-gradient`) | 변화 없음 | `#123328` |
|
||||
| Selected (`data-state="active"`) | `#FFFFFF` (`--hm-sem-bg-surface`) | top/right/left `#DEDBD2`, bottom 1px 표면색(기준선을 덮어 콘텐츠와 연결), `z-index: 2` | `#164434` |
|
||||
| Hover (비선택만, `:hover`) | 기본 면 + Black 20% overlay (`linear-gradient`) | 변화 없음 | `#164434` |
|
||||
| Hover (선택) | 적용 안 함 | — | — |
|
||||
| Focus (`:focus-visible`) | shadcn ring 유지 | — | — |
|
||||
| Disabled | shadcn 기본 `opacity-50`, 클릭 불가 | — | — |
|
||||
@@ -126,12 +126,12 @@ Radix Tabs 구조·ARIA(`role=tablist/tab/tabpanel`, `aria-selected`)·roving ta
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Tabs"): **Overview**는 fill / panelSurface를 생략한 기본 Primary다. 같은 Primary가 반복되던 **Default API** 구역(`fill`만, `fill` + `panelSurface="content"` 명시)은 2026-10-22 숨김 verification `DefaultApi`로 옮겼다. **Variants**는 명시적 Secondary, **States**는 기본 Primary + 8개 탭 + 260px wrapper Overflow다. 기존 대표 Story ID와 메뉴를 유지한다. Hover는 실제 포인터로 확인한다.
|
||||
- 대표 Story `All`("Tabs"): **Variants**는 Primary(fill / panelSurface를 생략한 기본)·Secondary(`panelSurface="surface"`)·Overflow(20개 탭, 이전 States의 Overflow 예시)를 PC 균등 3열로 두고, **Usage**는 Card 내부 · Secondary / 페이지 직접 배치 · Primary 2열이다(2026-11-09 Overview·States 구역을 Variants로 통합, 이 페이지의 위아래 패딩만 줄여 1280×900 한 화면). 같은 Primary가 반복되던 **Default API** 구역(`fill`만, `fill` + `panelSurface="content"` 명시)은 2026-10-22 숨김 verification `DefaultApi`로 옮겼다. 기존 대표 Story ID와 메뉴를 유지한다. Hover는 실제 포인터로 확인한다.
|
||||
- verification(`tags: ["verification"]`): `DefaultApi`, `Secondary`, `SelectedFirst`, `SelectedLast`, `Focus`, `Disabled`, `Overflow`, `SecondaryOverflow`, `LongLabel`. Primary 기준이며 Secondary는 같은 CSS 경로를 쓴다. Focus는 `play`로 실제 focus를 준다.
|
||||
- `LongLabel`은 짧은 라벨, 긴 라벨 1개, 긴 라벨 여러 개, 260px 좁은 container를 함께 검증한다. 라벨은 `nowrap`·무말줄임이며 실제 필요 폭이 넘을 때 이동 버튼으로 전환한다.
|
||||
- non-fill Story(`Selected`, `SelectedMiddle`, `Fill`, `FillContent`, `FillOverflow`와 대표의 non-fill 구역)는 2026-10-06에 제거했다. API(`fill=false`, Legacy profile CSS)는 유지한다(§10).
|
||||
- 2026-10-08 숨김 `Compatibility`(`verification` 태그 + **VERIFICATION ONLY** 표시)는 Legacy `fill={false}` / `fill={false} panelSurface="surface"` 동일성과 `panelSurface="surface"`만 명시한 경우의 Secondary 전환을 확인한다. 일반 Story 메뉴를 추가하지 않는다.
|
||||
- Overview / Variants / States, `DefaultApi` 및 Fill verification 예제의 콘텐츠 panel은 `px-0 py-6`로 통일해 D-01을 실제로 반영한다. Primary는 상위 content 면, Secondary는 상위 surface 면에 연결하며 내부 패널의 별도 border·배경·radius를 제거한다. Compatibility의 Legacy non-fill 두 예제만 이전 `border bg-card rounded-b p-4`를 보존한다.
|
||||
- Variants / Usage, `DefaultApi` 및 Fill verification 예제의 콘텐츠 panel은 `px-0 py-6`로 통일해 D-01을 실제로 반영한다. Primary는 상위 content 면, Secondary는 상위 surface 면에 연결하며 내부 패널의 별도 border·배경·radius를 제거한다. Compatibility의 Legacy non-fill 두 예제만 이전 `border bg-card rounded-b p-4`를 보존한다.
|
||||
- **Usage**(2026-10-09 추가)는 공개 Card / Input / Textarea / Table을 조합해 Card 내부 Secondary와 페이지 content 면 위 Primary를 보여준다. 두 예제 모두 `TabsContent`는 `px-0 py-6`이며 별도 배경·border·radius를 추가하지 않는다. 대표 Story ID·기존 구역·verification Story는 유지한다.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -86,8 +86,8 @@ native `<textarea>` 동작 전체, `data-slot`, `cn(..., className)`, `transitio
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Textarea"): 구역 `default`, `focus`, `error`, `error-message`, `readonly`, `disabled`, `multiline`, `resize`. Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다.
|
||||
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용).
|
||||
- 대표 Story `All`("Textarea"): States `default`, `focus`, `readonly`, `disabled`와 Validation `error`, `error-message`. `multiline`·`resize` 예시는 대표 페이지에서 빼고 숨김 verification Story `Multiline`·`ResizeNone`으로 옮겼다(2026-11-09). Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다.
|
||||
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Multiline`("Multiline · Wrap"), `ResizeNone`("Resize · none"), `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). `Multiline`·`ResizeNone`은 2026-11-09에 대표 Story의 Usage 구역에서 옮긴 것이다.
|
||||
- Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
@@ -119,8 +119,8 @@ src/components/story-board.tsx
|
||||
- `npm run typecheck`(root + ui + center-graphic), `npm run build`: 통과(2026-10-02).
|
||||
- Storybook computed style(`components-form-textarea--all`): Default min-height 112px / padding 8px 12px / border 1px rgb(222,219,210) / radius 4px / bg white / 16·24·400 / resize vertical / Placeholder rgb(153,153,153), Error border rgb(211,47,47) + padding 7.5px 11.5px, Readonly resize none · opacity 1, `resize="none"` 적용, Disabled opacity 0.6.
|
||||
- 실제 클릭 focus: `:focus-visible` 매칭, border rgb(18,51,40), padding 7.5px 11.5px 확인.
|
||||
- Story ID: `All` + verification 8개 존재 확인(index.json). `@hm-design/ui`에서 `import { Textarea }`로 Story가 빌드됨.
|
||||
- 2026-10-06 ui_viz Storybook 화면 비교(1280/768/390, Chromium): 컨트롤 computed style 일치(ui_viz Story의 114px은 `rows=4` 때문), Error+Focus outline 유지, 긴 내용은 내부 세로 scroll·가로 넘침 없음, Readonly 편집 불가·선택 가능, Disabled focus 불가. ui_viz는 Disabled에서 Field 전체(라벨 포함)를 흐리지만 HM은 컨트롤만 흐리고 라벨은 `Label`의 disabled 연동(소비 조합)이 맡는다 — 의도적 차이. 대표 Story는 ui_viz 5구역을 모두 포함한 상위 집합(Hover = Default, Error Message, Resize none 추가)이라 변경하지 않음.
|
||||
- Story ID: `All` + verification 8개 존재 확인(index.json). 2026-11-09 기준 `All` + verification 12개(index.json 13개, `components-form-textarea--multiline`·`--resize-none` 추가, 기존 ID 유지). `@hm-design/ui`에서 `import { Textarea }`로 Story가 빌드됨.
|
||||
- 2026-10-06 ui_viz Storybook 화면 비교(1280/768/390, Chromium): 컨트롤 computed style 일치(ui_viz Story의 114px은 `rows=4` 때문), Error+Focus outline 유지, 긴 내용은 내부 세로 scroll·가로 넘침 없음, Readonly 편집 불가·선택 가능, Disabled focus 불가. ui_viz는 Disabled에서 Field 전체(라벨 포함)를 흐리지만 HM은 컨트롤만 흐리고 라벨은 `Label`의 disabled 연동(소비 조합)이 맡는다 — 의도적 차이. 대표 Story는 ui_viz 5구역을 모두 포함한 상위 집합(Hover = Default, Error Message, Resize none 추가)이라 변경하지 않음(당시 기록). 2026-11-09부터 대표 Story는 States·Validation만 보여 주고 Multiline·Resize none은 숨김 Story로 옮겼다(§9).
|
||||
- 미확인: 브라우저별 렌더(Chromium 외).
|
||||
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Radix 구조·ARIA·roving focus(방향키, Home/End, disabled 건너뜀, `loop`
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Toggle Group"): Single(local state), Multiple(icon-only, local state), Disabled group / item, Vertical(icon-only), `spacing={2}`, Many items(`spacing={1}` + `flex-wrap`)을 한 페이지에 둔다. 대표 화면은 예시 제목·선택값 표시(`value = …`)를 생략하고 구역 제목으로 구분한다. 값 표시는 verification에 남긴다.
|
||||
- 대표 Story `All`("Toggle Group"): Variants(Single(local state), Multiple(icon-only, local state), Vertical(icon-only), `spacing={2}`), Sizes, States(Disabled group / item)를 둔다(2026-11-09 Overview 제거). `spacing={1}` + `flex-wrap`의 Many items 예시는 대표 화면에서 빼고 숨김 verification Story `ManyOptions`("Many Options")로 옮겼다. 대표 화면은 예시 제목·선택값 표시(`value = …`)를 생략하고 구역 제목으로 구분한다. 값 표시는 verification에 남긴다.
|
||||
- verification: `Single`, `Multiple`, `Vertical`.
|
||||
- meta에 `component`를 지정하지 않는다(props가 `single | multiple` union이라 args 없는 Story가 타입을 통과하지 못함).
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Radix 구조·`aria-pressed`·`data-state`·Enter/Space·controlled/uncontrolled
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Toggle"): Overview(Off/On, default·outline), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes, States(Disabled off/on). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
|
||||
- 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
|
||||
- verification: `States`, `Icon Only`, `Disabled`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
| Light 3 | #249473 | #C1A770 | #EC8F53 |
|
||||
| Light 2 | #266651 | #897445 | #F87725 |
|
||||
| Light 1 | #205241 | #604F32 | #EB5F00 |
|
||||
| Default | #123328 | #3E3523 | #CC5200 |
|
||||
| Dark 1 | #0A221D | #352D1D | #BC4C00 |
|
||||
| Dark 2 | #071E18 | #2F281C | #883700 |
|
||||
| Dark 3 | #020A07 | #1D1810 | #4F2000 |
|
||||
| Default | #164434 | #4E422D | #CC5200 |
|
||||
| Dark 1 | #11372D | #403726 | #BC4C00 |
|
||||
| Dark 2 | #0B2B23 | #2F281C | #883700 |
|
||||
| Dark 3 | #030E0A | #0D0B07 | #4F2000 |
|
||||
|
||||
모든 칸의 Key는 `color.palette.{green|brown|orange}-{light-4|light-3|light-2|light-1|default|dark-1|dark-2|dark-3}`로 선언되어 있다. CSS는 동일 suffix의 `--hm-sem-palette-*` → `--hm-*` Primitive를 사용한다. Orange Dark 1·2 확정 당시에는 83개(색상 81개 + radius 2개)였으며 이후 Generic Status Error 1개와 Brown/Orange Background Stop 10개가 추가됐다. 현재 공개 입력은 94개(색상 92개 + radius 2개)다. Dark 3와 Light 4~Default 값은 유지한다.
|
||||
|
||||
@@ -33,8 +33,8 @@ Orange Dark 1·2의 원본 Needs Review 상태와 [이전 검수 보고서](../r
|
||||
|
||||
## 호환성과 Storybook
|
||||
|
||||
페이지 소개는 `한맥 브랜드 컬러`로 표시한다. 그룹 설명의 단계 순서 나열 문구는 제공하지 않으며, 개별 컬러칩의 단계명과 HEX는 유지한다.
|
||||
페이지 제목은 `Hanmac Color`(메뉴 `Foundation > Hanmac Color`)이며 별도 소개 문구는 표시하지 않는다. 기존 Story ID `foundation-color--all`은 `meta.id`로 유지한다. 과거 `한맥 브랜드 컬러` 소개 문구는 당시 이력이다. 그룹 설명의 단계 순서 나열 문구는 제공하지 않으며, 개별 컬러칩의 단계명과 HEX는 유지한다.
|
||||
|
||||
[Color 대표 Story](../../src/foundation/color.stories.tsx)는 한맥그린·한맥브라운·한맥오렌지 3개 그룹에 각 8개, 총 24개 컬러칩만 표시한다. Green 8/8·Brown 8/8·Orange 8/8 확정이며 단계명·현재 HEX와 Default 기본색 강조를 유지한다. 2026-10-11 최종 표시 요청에 따라 Brown Deep, Orange Light·Mid·Dark·Deep의 별도 표시, Accent·Semantic·Gradients 섹션과 개발용 Token·CSS Variable·Runtime Key 설명은 제거한다. Legacy Button 색상과 Accent·Semantic·Gradient의 실제 Key·값·소비처는 보존한다. 기존 Component mapping을 새 값에 연결하지 않으며 public Key·Property·type·Default·API·schemaVersion 1과 서비스 partial JSON 호환성을 유지한다. [Runtime Theme](runtime-theme.md)의 geometry/상태 권한과 Designer의 기존 17개 상위 역할 입력 UI는 확대하지 않는다. 한맥오렌지의 미확정 브랜드 단계는 없다. [표시 정리 검증 기록](../reports/foundation-color-display-cleanup-2026-10-11.md).
|
||||
[Hanmac Color 대표 Story](../../src/foundation/color.stories.tsx)는 한맥그린·한맥브라운·한맥오렌지 3개 그룹에 각 8개, 총 24개 컬러칩만 표시한다. Green 8/8·Brown 8/8·Orange 8/8 확정이며 단계명·현재 HEX와 Default 기본색 강조를 유지한다. 2026-10-11 최종 표시 요청에 따라 Brown Deep, Orange Light·Mid·Dark·Deep의 별도 표시, Accent·Semantic·Gradients 섹션과 개발용 Token·CSS Variable·Runtime Key 설명은 제거한다. Legacy Button 색상과 Accent·Semantic·Gradient의 실제 Key·값·소비처는 보존한다. 기존 Component mapping을 새 값에 연결하지 않으며 public Key·Property·type·Default·API·schemaVersion 1과 서비스 partial JSON 호환성을 유지한다. [Runtime Theme](runtime-theme.md)의 geometry/상태 권한과 Designer의 기존 17개 상위 역할 입력 UI는 확대하지 않는다. 한맥오렌지의 미확정 브랜드 단계는 없다. [표시 정리 검증 기록](../reports/foundation-color-display-cleanup-2026-10-11.md).
|
||||
|
||||
[Company Colors Story](../../src/foundation/company-colors.stories.tsx)는 Foundation > Color와 별도 메뉴(Color 바로 아래)이며 7개 계열사·14개 컬러를 회사별로 표시한다. 데이터는 OrgChart의 기존 `companyColorPresets`를 그대로 읽고(메인 → 서브 순서, 메인은 Default 강조 재사용) HEX를 중복 정의하지 않는다. Theme 토큰·`themes/*.json`·공개 계약은 변경하지 않으며 Foundation > Color의 3그룹 24칩은 그대로다.
|
||||
[Company Colors Story](../../src/foundation/company-colors.stories.tsx)는 Foundation > Hanmac Color와 별도 메뉴(Hanmac Color 바로 아래)이며 7개 계열사·14개 컬러를 회사별로 표시한다(별도 소개 문구 없음). 데이터는 OrgChart의 기존 `companyColorPresets`를 그대로 읽고(메인 → 서브 순서, 메인은 Default 강조 재사용) HEX를 중복 정의하지 않는다. Theme 토큰·`themes/*.json`·공개 계약은 변경하지 않으며 Foundation > Hanmac Color의 3그룹 24칩은 그대로다.
|
||||
@@ -1,6 +1,6 @@
|
||||
# 외부 프로젝트 설치 — Git Submodule + npm Workspace
|
||||
|
||||
> 2026-10-20 작성, 2026-10-23 갱신(저장소 주소 `shkim4`, HM 패키지 npm Registry 미사용 명시). 현재 HMDesign을 다른 React 프로젝트에서 쓰는 공식 방식입니다. npm Registry publish 없이 Git 저장소를 Submodule로 연결하고, HMDesign 안에서 패키지를 빌드한 뒤 소비 프로젝트의 npm Workspace로 연결합니다.
|
||||
> 2026-10-20 작성, 2026-10-23 갱신(저장소 주소 `shkim4`, HM 패키지 npm Registry 미사용 명시), 2026-11-06 갱신(`@hm-design/chart` 연결·React 중복 해석 방지 추가). 현재 HMDesign을 다른 React 프로젝트에서 쓰는 공식 방식입니다. npm Registry publish 없이 Git 저장소를 Submodule로 연결하고, HMDesign 안에서 패키지를 빌드한 뒤 소비 프로젝트의 npm Workspace로 연결합니다.
|
||||
> 실제 적용 근거: 외부 DWG/DXF Viewer(React 19, `@hm-design/ui` + `@hm-design/sw`)가 이 절차로 연결되었습니다. 패키지 tarball 소비 검증은 `packages/sw/tests/integrated-consumer.mjs`가 별도로 수행합니다.
|
||||
|
||||
## 1. 전제
|
||||
@@ -39,7 +39,7 @@ cd ..
|
||||
```
|
||||
|
||||
- `npm ci`는 HMDesign 자체 빌드 도구(Vite·TypeScript·Tailwind 등)를 `HMDesign/node_modules`에 설치합니다. 패키지 빌드 스크립트가 이 위치의 도구를 사용하므로 생략할 수 없습니다.
|
||||
- `build:packages`는 `org-chart → ui → center-graphic → org-chart-react → sw` 순서로 5개 패키지의 `dist/`를 만듭니다. 필요한 패키지만 빌드할 때는 `npm run build:ui`, `npm run build:sw`를 쓸 수 있고, `org-chart-react`는 `org-chart`를 먼저 빌드해야 합니다.
|
||||
- `build:packages`는 `org-chart → ui → center-graphic → chart → org-chart-react → sw` 순서로 6개 패키지의 `dist/`를 만듭니다. 필요한 패키지만 빌드할 때는 `npm run build:ui`, `npm run build:chart`, `npm run build:sw`를 쓸 수 있고, `org-chart-react`는 `org-chart`를 먼저 빌드해야 합니다. `@hm-design/chart`는 `@hm-design/ui`의 `theme.css`(토큰·글꼴)를 소비 앱이 연결해야 하므로 `build:ui`도 함께 실행합니다.
|
||||
|
||||
## 4. 소비 프로젝트 Workspace 연결
|
||||
|
||||
@@ -63,6 +63,8 @@ npm install
|
||||
|
||||
- `npm install` 후 `node_modules/@hm-design/<패키지>`는 `HMDesign/packages/<패키지>`를 가리키는 링크입니다. Import는 패키지 `exports`의 공개 진입점만 사용합니다(`dist`·`src` 내부 경로 금지).
|
||||
- OrgChart를 쓰면 `HMDesign/packages/org-chart`(와 React에서는 `HMDesign/packages/org-chart-react`)를 추가합니다.
|
||||
- 차트를 쓰면 `HMDesign/packages/chart`와 **`HMDesign/packages/ui`를 함께** Workspace·dependency에 추가합니다(`@hm-design/chart`의 peer이며 CSS 토큰용입니다). `recharts` 등 차트 dependency는 `npm install`이 설치합니다.
|
||||
- **React 중복 해석 방지(차트에서 확인).** Submodule 안의 `HMDesign/node_modules`에도 `react`가 설치되어 있어, 소비 프로젝트의 Vite가 패키지 `dist`의 `react` import를 그쪽으로 해석하면 React가 둘이 되어 렌더링이 실패합니다(`Cannot read properties of null (reading useId)`). 소비 프로젝트 `vite.config`에 `resolve: { dedupe: ["react", "react-dom"] }`를 지정합니다. 임시 소비 프로젝트(Vite 7)에서 `dedupe` 없이는 차트가 비었고, 지정 후 렌더링됨을 확인했습니다.
|
||||
- Vite 소비 프로젝트는 HMDesign 저장소 안의 예제·Export용 HTML을 의존성 스캔에서 제외하도록 `optimizeDeps.entries: ['index.html']`를 지정합니다(외부 Viewer에서 확인된 설정).
|
||||
|
||||
## 5. CSS Import
|
||||
@@ -71,6 +73,7 @@ npm install
|
||||
|---|---|
|
||||
| Web `@hm-design/ui` | CSS entry에서 `@import "tailwindcss";` → `@import "@hm-design/ui/tailwind.css";` → `@import "@hm-design/ui/theme.css";`. Tailwind 검색 범위는 [Runtime portability](./runtime-portability.md#21-tailwind-v4-소비자-검색-범위)를 따릅니다. |
|
||||
| SW `@hm-design/sw` | `import "@hm-design/sw/styles.css"`를 한 번. JS import만으로 CSS가 들어오지 않습니다. 글꼴 파일은 포함하지 않습니다(기본 Noto Sans KR). |
|
||||
| Chart `@hm-design/chart` | CSS entry에서 `@import "tailwindcss" source("./");` → `@import "@hm-design/ui/tailwind.css";` → `@import "@hm-design/chart/tailwind.css";` → `@import "@hm-design/ui/theme.css";`. 차트 스타일은 JS import가 자동으로 불러옵니다. 상세는 [`packages/chart/README.md`](../../packages/chart/README.md). |
|
||||
| OrgChart | Core CSS 두 개(`@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`)를 URL로 해석해 `stylesheetUrls`에 전달합니다(Shadow DOM). 문서에 import하는 것만으로는 적용되지 않습니다. |
|
||||
|
||||
Web과 SW를 함께 쓰면 두 CSS의 순서는 서로의 계산 스타일을 바꾸지 않습니다(`integrated-consumer` 검증 항목).
|
||||
@@ -79,6 +82,7 @@ Web과 SW를 함께 쓰면 두 CSS의 순서는 서로의 계산 스타일을
|
||||
|
||||
- 소비 프로젝트의 AI 코딩 도구는 `node_modules/@hm-design/<패키지>/ai/`(= `HMDesign/packages/<패키지>/ai/`)를 읽습니다. 진입점은 각 폴더의 `AI_GUIDE.md`이며 사용 유형(Web / SW 일반·뷰어 / OrgChart / 혼합)을 먼저 정합니다.
|
||||
- 루트 `AGENTS.md`·`CLAUDE.md`는 자동으로 연결되지 않습니다. `ai/AGENTS_SNIPPET.md`의 문구를 소비 프로젝트 루트 지침에 붙입니다(방법은 `AI_GUIDE.md`의 "소비 프로젝트 설정").
|
||||
- `@hm-design/chart`에는 `ai/` 폴더가 없습니다. 차트 사용 지침은 `node_modules/@hm-design/chart/README.md`(= `HMDesign/packages/chart/README.md`)이며, 소비 프로젝트 루트 지침에서 이 README를 명시적으로 가리킵니다(`AI_GUIDE.md`의 "소비 프로젝트 설정").
|
||||
- HMDesign 저장소 루트의 `AGENTS.md`·`CLAUDE.md`는 HMDesign을 개발하는 AI용이며 소비 프로젝트 지침이 아닙니다.
|
||||
|
||||
## 7. HMDesign 버전 갱신
|
||||
|
||||
@@ -6,24 +6,24 @@
|
||||
|---|---|---|---|---|---|
|
||||
| `color.bg.content` | `--hm-sem-bg-content` | `#f5f0e5` | 콘텐츠 면 | Yes | gnb, lnb |
|
||||
| `color.bg.surface` | `--hm-sem-bg-surface` | `#ffffff` | 기본/카드/입력 면 | Yes | checkbox, radio, switch, slider, badge, table, input, select, button, gnb |
|
||||
| `color.fg.primary` | `--hm-sem-fg-primary` | `#0a221d` | 주요 텍스트 | Yes | calendar, pagination, badge, table, input, select, gnb |
|
||||
| `color.fg.primary` | `--hm-sem-fg-primary` | `#11372d` | 주요 텍스트 | Yes | calendar, pagination, badge, table, input, select, gnb |
|
||||
| `color.fg.secondary` | `--hm-sem-fg-secondary` | `#6e6759` | 보조 텍스트 | Yes | calendar, pagination, badge, table, gnb |
|
||||
| `color.fg.placeholder` | `--hm-sem-fg-placeholder` | `#999999` | Placeholder | Yes | input, select |
|
||||
| `color.action.primary` | `--hm-sem-action-primary` | `#123328` | Strong / Primary ink | Yes | checkbox/radio hover, slider Hover/Drag halo, pagination current, badge default, button outline, navigation current ink |
|
||||
| `color.action.primary` | `--hm-sem-action-primary` | `#164434` | Strong / Primary ink | Yes | checkbox/radio hover, slider Hover/Drag halo, pagination current, badge default, button outline, navigation current ink |
|
||||
| `color.action.cancel` | `--hm-sem-action-cancel` | `#6e6759` | 공개 취소 역할 | Yes | Button `variant="cancel"` + `appearance="outline"` |
|
||||
| `color.action.destructive` | `--hm-sem-action-destructive` | `#d32f2f` | 오류/위험 역할 | Yes | checkbox, radio, badge, input, button |
|
||||
| `color.status.error` | `--hm-sem-status-error` | `#b3261e` | Generic Status Error · 일반 상태 오류 · Provisional | Yes | Foundation Color / 선택적 Theme 입력; 기존 Component 자동 연결 없음 |
|
||||
| `color.stroke.default` | `--hm-sem-stroke-default` | `#9a9284` | 기본 border | Yes | checkbox, radio, switch, badge |
|
||||
| `color.stroke.input.default` | `--hm-sem-stroke-input-default` | `#dedbd2` | 입력/패널 divider border | Yes | table, input, select, GNB 콘텐츠 LNB divider |
|
||||
| `color.stroke.input.focus` | `--hm-sem-stroke-input-focus` | `#266651` | 공통 Focus border/ring | Yes | input, select, button, checkbox, calendar, slider Keyboard Focus halo 등; GNB/LNB는 contextual 예외 |
|
||||
| `color.bg.brand.stop.0` | `--hm-sem-brand-bg-stop-0` | `#0a221d` | 기존 gradient stop: color.bg.brand.stop.0 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brand.stop.12` | `--hm-sem-brand-bg-stop-12` | `#071e18` | 기존 gradient stop: color.bg.brand.stop.12 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brand.stop.42` | `--hm-sem-brand-bg-stop-42` | `#123328` | 기존 gradient stop: color.bg.brand.stop.42 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brand.stop.0` | `--hm-sem-brand-bg-stop-0` | `#11372d` | 기존 gradient stop: color.bg.brand.stop.0 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brand.stop.12` | `--hm-sem-brand-bg-stop-12` | `#0b2b23` | 기존 gradient stop: color.bg.brand.stop.12 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brand.stop.42` | `--hm-sem-brand-bg-stop-42` | `#164434` | 기존 gradient stop: color.bg.brand.stop.42 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brand.stop.78` | `--hm-sem-brand-bg-stop-78` | `#266651` | 기존 gradient stop: color.bg.brand.stop.78 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brand.stop.100` | `--hm-sem-brand-bg-stop-100` | `#205241` | 기존 gradient stop: color.bg.brand.stop.100 | Yes | Theme palette / adapter |
|
||||
| `color.bg.brown.stop.0` | `--hm-sem-brown-bg-stop-0` | `#352d1d` | Brown Background · Dark 1 | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
| `color.bg.brown.stop.0` | `--hm-sem-brown-bg-stop-0` | `#403726` | Brown Background · Dark 1 | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
| `color.bg.brown.stop.12` | `--hm-sem-brown-bg-stop-12` | `#2f281c` | Brown Background · Dark 2 | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
| `color.bg.brown.stop.42` | `--hm-sem-brown-bg-stop-42` | `#3e3523` | Brown Background · Default | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
| `color.bg.brown.stop.42` | `--hm-sem-brown-bg-stop-42` | `#4e422d` | Brown Background · Default | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
| `color.bg.brown.stop.78` | `--hm-sem-brown-bg-stop-78` | `#897445` | Brown Background · Light 2 | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
| `color.bg.brown.stop.100` | `--hm-sem-brown-bg-stop-100` | `#604f32` | Brown Background · Light 1 | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
| `color.bg.orange.stop.0` | `--hm-sem-orange-bg-stop-0` | `#bc4c00` | Orange Background · Dark 1 | Yes · 5개 세트 | 공통 Background / Foundation Color |
|
||||
@@ -35,7 +35,7 @@
|
||||
| `surface.action.primary.stop.30` | `--hm-sem-action-surface-stop-30` | `#165c47` | 기존 gradient stop: surface.action.primary.stop.30 | Yes | button, gnb |
|
||||
| `surface.action.primary.stop.65` | `--hm-sem-action-surface-stop-65` | `#114838` | 기존 gradient stop: surface.action.primary.stop.65 | Yes | button, gnb |
|
||||
| `surface.action.primary.stop.100` | `--hm-sem-action-surface-stop-100` | `#08241c` | 기존 gradient stop: surface.action.primary.stop.100 | Yes | button, gnb |
|
||||
| `surface.action.primary.border.stop.0` | `--hm-sem-action-border-stop-0` | `#0a221d` | 기존 gradient stop: surface.action.primary.border.stop.0 | Yes | button |
|
||||
| `surface.action.primary.border.stop.0` | `--hm-sem-action-border-stop-0` | `#11372d` | 기존 gradient stop: surface.action.primary.border.stop.0 | Yes | button |
|
||||
| `surface.action.primary.border.stop.50` | `--hm-sem-action-border-stop-50` | `#34daaa` | 기존 gradient stop: surface.action.primary.border.stop.50 | Yes | button |
|
||||
| `surface.action.primary.border.stop.100` | `--hm-sem-action-border-stop-100` | `#08241c` | 기존 gradient stop: surface.action.primary.border.stop.100 | Yes | button |
|
||||
| `radius.input.default` | `--hm-radius-input` | `4px` | Input/Select radius | Yes | input, select |
|
||||
@@ -50,13 +50,13 @@
|
||||
| `color.palette.green-light-3` | `--hm-sem-palette-green-light-3` | `#249473` | 선택적 Theme 팔레트: green-light-3 | Yes | data-table Sort Direction Accent, tabs 장식, lnb gradient |
|
||||
| `color.palette.green-light-2` | `--hm-sem-palette-green-light-2` | `#266651` | 선택적 Theme 팔레트: green-light-2 | Yes | checkbox, radio, switch, slider, progress, calendar, table, input, select, gnb, lnb |
|
||||
| `color.palette.green-light-1` | `--hm-sem-palette-green-light-1` | `#205241` | 선택적 Theme 팔레트: green-light-1 | Yes | table, button, lnb |
|
||||
| `color.palette.green-default` | `--hm-sem-palette-green-default` | `#123328` | 선택적 Theme 팔레트: green-default | Yes | checkbox, radio, slider, calendar, pagination, badge, select, button, gnb, lnb |
|
||||
| `color.palette.green-dark-1` | `--hm-sem-palette-green-dark-1` | `#0a221d` | 선택적 Theme 팔레트: green-dark-1 | Yes | calendar, pagination, badge, table, input, select, button, gnb |
|
||||
| `color.palette.green-dark-2` | `--hm-sem-palette-green-dark-2` | `#071e18` | 선택적 Theme 팔레트: green-dark-2 | Yes | Theme palette / adapter |
|
||||
| `color.palette.green-default` | `--hm-sem-palette-green-default` | `#164434` | 선택적 Theme 팔레트: green-default | Yes | checkbox, radio, slider, calendar, pagination, badge, select, button, gnb, lnb |
|
||||
| `color.palette.green-dark-1` | `--hm-sem-palette-green-dark-1` | `#11372d` | 선택적 Theme 팔레트: green-dark-1 | Yes | calendar, pagination, badge, table, input, select, button, gnb |
|
||||
| `color.palette.green-dark-2` | `--hm-sem-palette-green-dark-2` | `#0b2b23` | 선택적 Theme 팔레트: green-dark-2 | Yes | Theme palette / adapter |
|
||||
| `color.palette.brown-light-2` | `--hm-sem-palette-brown-light-2` | `#897445` | 선택적 Theme 팔레트: brown-light-2 | Yes | button |
|
||||
| `color.palette.brown-light-1` | `--hm-sem-palette-brown-light-1` | `#604f32` | 선택적 Theme 팔레트: brown-light-1 | Yes | button |
|
||||
| `color.palette.brown-default` | `--hm-sem-palette-brown-default` | `#3e3523` | 선택적 Theme 팔레트: brown-default | Yes | button |
|
||||
| `color.palette.brown-dark-1` | `--hm-sem-palette-brown-dark-1` | `#352d1d` | 선택적 Theme 팔레트: brown-dark-1 | Yes | button |
|
||||
| `color.palette.brown-default` | `--hm-sem-palette-brown-default` | `#4e422d` | 선택적 Theme 팔레트: brown-default | Yes | button |
|
||||
| `color.palette.brown-dark-1` | `--hm-sem-palette-brown-dark-1` | `#403726` | 선택적 Theme 팔레트: brown-dark-1 | Yes | button |
|
||||
| `color.palette.brown-light-4` | `--hm-sem-palette-brown-light-4` | `#ecd4a2` | 선택적 Theme 팔레트: brown-light-4 | Yes | Theme palette / adapter |
|
||||
| `color.palette.orange-light-4` | `--hm-sem-palette-orange-light-4` | `#feba88` | 선택적 Theme 팔레트: orange-light-4 | Yes | button |
|
||||
| `color.palette.orange-default` | `--hm-sem-palette-orange-default` | `#cc5200` | 선택적 Theme 팔레트: orange-default | Yes | button |
|
||||
@@ -89,10 +89,10 @@
|
||||
| `color.palette.gray-dark` | `--hm-sem-palette-gray-dark` | `#5a5a5a` | 선택적 Theme 팔레트: gray-dark | Yes | button |
|
||||
| `color.palette.indicator` | `--hm-sem-palette-indicator` | `#2d2d2d` | 선택적 Theme 팔레트: indicator | Yes | select |
|
||||
|
||||
| `color.palette.green-dark-3` | `--hm-sem-palette-green-dark-3` | `#020a07` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.green-dark-3` | `--hm-sem-palette-green-dark-3` | `#030e0a` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.brown-light-3` | `--hm-sem-palette-brown-light-3` | `#c1a770` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.brown-dark-2` | `--hm-sem-palette-brown-dark-2` | `#2f281c` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.brown-dark-3` | `--hm-sem-palette-brown-dark-3` | `#1d1810` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.brown-dark-3` | `--hm-sem-palette-brown-dark-3` | `#0d0b07` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.orange-light-3` | `--hm-sem-palette-orange-light-3` | `#ec8f53` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.orange-light-2` | `--hm-sem-palette-orange-light-2` | `#f87725` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
| `color.palette.orange-light-1` | `--hm-sem-palette-orange-light-1` | `#eb5f00` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
|
||||
|
||||
@@ -281,7 +281,7 @@ HMDesignSystem의 핵심 원칙은 다음과 같다.
|
||||
- Progress는 현재 Active와 색 정책이 같아 같은 Semantic을 쓴다. 의미가 달라지면 별도 Semantic으로 분리한다.
|
||||
- 대상 아님: Toggle / Toggle Group(on = `--accent` 면, 별도 정책), GNB·LNB·Navigation Menu current(기존 Strong Brand 언어), Error/Destructive, Button.
|
||||
- **Persistent Selected Surface**: Toggle / Toggle Group의 Selected 및 Selected + Hover = `--accent` 100% / `--accent-foreground`(Git baseline `d101530` 매핑, 2026-10-06 원복). 아이콘은 전경을 따르고 border는 기존 default 투명 / outline input stroke를 유지한다. Active override에 영향받지 않고 Accent override를 따른다. Focus override는 별도 ring만 바꾼다.
|
||||
- Color 역할 정리: Hover / Soft surface = `--accent` 45% (Select·Command·Combobox·Menu Item highlighted, Calendar hover·range middle, Pagination hover, Table row hover·selected) / Persistent Selected Surface = `--accent` 100%(Toggle·Toggle Group) / Menubar Top Trigger Highlighted·Open 및 Navigation Menu Trigger·Open = `--accent` 100% + On Accent / Active Control fill = `--hm-sem-state-active`(위 적용 대상) / Focus = Focus chain / Strong Brand·Navigation·Badge default·Pagination current = Primary Green(`--hm-sem-action-primary`, `#123328`) / Error = Destructive.
|
||||
- Color 역할 정리: Hover / Soft surface = `--accent` 45% (Select·Command·Combobox·Menu Item highlighted, Calendar hover·range middle, Pagination hover, Table row hover·selected) / Persistent Selected Surface = `--accent` 100%(Toggle·Toggle Group) / Menubar Top Trigger Highlighted·Open 및 Navigation Menu Trigger·Open = `--accent` 100% + On Accent / Active Control fill = `--hm-sem-state-active`(위 적용 대상) / Focus = Focus chain / Strong Brand·Navigation·Badge default·Pagination current = Primary Green(`--hm-sem-action-primary`, `#164434`) / Error = Destructive.
|
||||
- Runtime 공개 계약(`packages/ui/src/theme/contract.ts`)에 Active/on Active를 포함했다. `src/theme/preview.ts`는 패키지 Runtime API의 호환 어댑터다.
|
||||
- **Floating Surface Semantic**(`default.css`): `--hm-sem-floating-background|border-width|border-color|radius|shadow`. Dropdown Menu·Context Menu·Popover·Hover Card·Menubar content·Navigation Menu panel의 `--hm-<component>-*`가 이를 참조한다. Toast(Sonner)는 notification surface라 이를 쓰지 않고 `--hm-toast-*`를 따로 둔다(Component Token은 유지하며 컴포넌트별 override 가능). Date Picker·Combobox는 Popover를 조합하므로 별도 mapping이 없다. Tooltip(어두운 면)·Dialog/Alert Dialog/Sheet/Drawer(modal)·Select·Card·Command는 대상이 아니다.
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ HMDesignSystem/
|
||||
|
||||
### 5.1 패키지 구성과 Storybook 통합 (구현됨 — 2026-10-02 패키지 분리)
|
||||
|
||||
**하나의 Git 저장소 + 두 개의 독립 배포 패키지 + 하나의 통합 Storybook**을 기본으로 한다. 2026-10-02에 npm workspaces(`packages/*`)로 분리했다. `packages/ui`(`@hm-design/ui`)에 Button·Input·Select·Card·Dialog와 Theme(`src/theme/*.css`, `theme.css`, `tailwind.css`)이 있고, `packages/center-graphic`(`@hm-design/center-graphic`)은 ui-viz CENTER Web Reference(제목·정보·노선/공간·시설물)를 이식해 구현했다(기준 문서 `docs/center-graphic/README.md`). 둘은 서로 의존하지 않는다. 루트는 Storybook(`.storybook/`, `src/**/*.stories.tsx`; UI는 `Components`, CENTER는 `Center Graphic` 최상위 그룹)과 Demo(`src/App.tsx`, `src/hm-theme.css`), 컴포넌트 문서(`docs/components/`)를 유지하며 Story와 Demo는 `@hm-design/ui`를 workspace 패키지로 import한다. 두 패키지는 `private: false`이며 ESM JS·타입 선언·CSS를 compiled `dist`에서 export한다. `npm run build:packages`가 library 산출물을 생성하며 HM 패키지는 npm Registry로 배포하지 않고 [Git Submodule + npm Workspace](external-install-submodule.md)로 연결한다. 소비 앱은 `@import "tailwindcss"; @import "@hm-design/ui/tailwind.css"; @import "@hm-design/ui/theme.css"`를 쓴다. UI dist에는 `fonts/`의 글꼴과 OFL이 포함되며 compiled `theme.css`는 상대 `./fonts/` 경로를 사용한다. Source `packages/ui/src/theme.css`는 `/fonts/NotoSansKR-variable.ttf`를 사용하므로 App/Storybook 개발 서버는 `public/fonts/`를 서빙한다.
|
||||
**하나의 Git 저장소 + 여러 독립 배포 패키지 + 하나의 통합 Storybook**을 기본으로 한다(현재 패키지 목록은 `README.md`의 패키지 구성을 따른다). 2026-10-02에 npm workspaces(`packages/*`)로 분리했다. `packages/ui`(`@hm-design/ui`)에 Button·Input·Select·Card·Dialog와 Theme(`src/theme/*.css`, `theme.css`, `tailwind.css`)이 있고, `packages/center-graphic`(`@hm-design/center-graphic`)은 ui-viz CENTER Web Reference(제목·정보·노선/공간·시설물)를 이식해 구현했다(기준 문서 `docs/center-graphic/README.md`). 둘은 서로 의존하지 않는다. 루트는 Storybook(`.storybook/`, `src/**/*.stories.tsx`; UI는 `Components`, CENTER는 `노선도` 최상위 그룹(분리 당시 이름 `Center Graphic`))과 Demo(`src/App.tsx`, `src/hm-theme.css`), 컴포넌트 문서(`docs/components/`)를 유지하며 Story와 Demo는 `@hm-design/ui`를 workspace 패키지로 import한다. 두 패키지는 `private: false`이며 ESM JS·타입 선언·CSS를 compiled `dist`에서 export한다. `npm run build:packages`가 library 산출물을 생성하며 HM 패키지는 npm Registry로 배포하지 않고 [Git Submodule + npm Workspace](external-install-submodule.md)로 연결한다. 소비 앱은 `@import "tailwindcss"; @import "@hm-design/ui/tailwind.css"; @import "@hm-design/ui/theme.css"`를 쓴다. UI dist에는 `fonts/`의 글꼴과 OFL이 포함되며 compiled `theme.css`는 상대 `./fonts/` 경로를 사용한다. Source `packages/ui/src/theme.css`는 `/fonts/NotoSansKR-variable.ttf`를 사용하므로 App/Storybook 개발 서버는 `public/fonts/`를 서빙한다.
|
||||
|
||||
아래는 목표 구조다.
|
||||
|
||||
@@ -109,7 +109,7 @@ HMDesignSystem/
|
||||
- 두 패키지는 따로 설치하고 따로 버전을 올릴 수 있어야 한다. 그래픽만 필요한 프로젝트가 UI 패키지 전체를 설치하지 않도록 한다.
|
||||
- 의존성 방향: UI → Design Token / Theme, center-graphic → 자체 그래픽 리소스. `center-graphic → ui` 필수 의존은 만들지 않고, 공유가 필요하면 최소 수준의 Token만 공유한다.
|
||||
- `tokens` 또는 `theme` 패키지의 별도 분리는 규모가 커지거나 여러 제품이 테마를 독립적으로 공유해야 할 때 검토한다. 그 전에는 두 패키지로 운영한다.
|
||||
- Storybook은 하나로 통합해 UI와 Center Graphic을 함께 검수한다. 메뉴 구조는 패키지 폴더 구조와 같을 필요가 없고 검토 편의를 기준으로 한다. 컴포넌트는 §6.3의 규칙대로 대표 Story가 바로 열리는 1뎁스 항목으로 두고, Playground·Compatibility·verification Story는 기본 메뉴에서 숨긴다. Center Graphic도 같은 원칙을 따른다.
|
||||
- Storybook은 하나로 통합해 UI·SW·조직도·노선도(CENTER)를 함께 검수한다. 메뉴 구조는 패키지 폴더 구조와 같을 필요가 없고 검토 편의를 기준으로 한다. 컴포넌트는 §6.3의 규칙대로 대표 Story가 바로 열리는 1뎁스 항목으로 두고, Playground·Compatibility·verification Story는 기본 메뉴에서 숨긴다. 노선도(CENTER, 이전 이름 Center Graphic)도 같은 원칙을 따른다.
|
||||
- 다른 문서의 `@company/ui`는 `@hm-design/ui`의 자리표시자로 읽고, 이름 치환은 패키징 작업 때 한꺼번에 한다. Storybook 최상위 그룹은 CENTER 구현 시점에 `Center Graphic`만 추가했고 기존 `Components`는 이름을 바꾸지 않았다(`UI Components`로의 개명은 필요할 때 정한다).
|
||||
|
||||
#### CENTER 규격의 적용 범위
|
||||
@@ -314,8 +314,8 @@ packages/ui 구조에서 나머지 UI 컴포넌트 추가
|
||||
```
|
||||
|
||||
- **Card와 Dialog 완료를 패키지 분리 작업의 시작 기준점으로 한다.** 그 기준을 충족해 2026-10-02에 분리했다(§5.1).
|
||||
- 현재 Storybook은 `Components`, `Patterns`, `Center Graphic`으로 통합 운영한다. `Theme`의 verification Story는 기본 sidebar에서 숨긴다.
|
||||
- 패키지 구성은 §5.1을 따른다(하나의 Git 저장소, `@hm-design/ui`와 `@hm-design/center-graphic`, 독립 설치·버전 관리).
|
||||
- 현재 Storybook 최상위 그룹은 `Foundation`, `Components`, `Patterns`, `Chart`, `SW`, `조직도`, `노선도`, `Verification`, `Theme`이다(정렬 기준 `.storybook/preview.tsx`). `verification`·`internal` 태그 Story(`Verification` 그룹과 `Theme`의 verification Story 포함)는 기본 sidebar에서 숨긴다.
|
||||
- 패키지 구성은 §5.1을 따른다(하나의 Git 저장소, `@hm-design/ui`·`@hm-design/sw`·`@hm-design/org-chart`·`@hm-design/org-chart-react`·`@hm-design/center-graphic`·`@hm-design/chart`, 독립 설치·버전 관리. 역할·진입점은 `README.md`의 패키지 구성).
|
||||
- `center-graphic` 실제 작업을 시작할 때 CENTER 문서와 assets의 읽기 범위·우선순위를 별도로 확정한다. 그 전까지 §5.1의 CENTER 적용 범위를 따른다.
|
||||
|
||||
상세 적용 절차는 [개발 적용 가이드](개발-적용-가이드.md), 발행과 복구 절차는 [운영/배포 가이드](운영-배포-가이드.md)를 따른다.
|
||||
@@ -84,7 +84,7 @@ Auto/Expanded Chart Only의 승인 기준은 다음과 같다.
|
||||
| 한맥기술 | `#CB2E28` |
|
||||
| 삼안 | `#F08300` |
|
||||
| 장헌산업 | `#4580A6` |
|
||||
| 장헌 | `#1D418D` |
|
||||
| (주)장헌 | `#1D418D` |
|
||||
| 피티씨 | `#84338E` |
|
||||
| 한라산업개발 | `#0068B7` |
|
||||
| 바론컨설턴트 | `#0C367F` |
|
||||
|
||||
@@ -92,7 +92,7 @@ Employee 관계를 유지한다. `companyId` 생성 옵션은 기존 JSON의 회
|
||||
| hanmac | 한맥기술 | #CB2E28 |
|
||||
| saman | 삼안 | #F08300 |
|
||||
| jangheon-industry | 장헌산업 | #4580A6 |
|
||||
| jangheon | 장헌 | #1D418D |
|
||||
| jangheon | (주)장헌 | #1D418D |
|
||||
| ptc | 피티씨 | #84338E |
|
||||
| halla | 한라산업개발 | #0068B7 |
|
||||
| baron | 바론컨설턴트 | #0C367F |
|
||||
|
||||
@@ -645,7 +645,7 @@ React에서는 대응 prop으로 변경한다. dataSource 변경은 reload, auto
|
||||
- `hanmac` 한맥기술: `#CB2E28`
|
||||
- `saman` 삼안: `#F08300`
|
||||
- `jangheon-industry` 장헌산업: `#4580A6`
|
||||
- `jangheon` 장헌: `#1D418D`
|
||||
- `jangheon` (주)장헌: `#1D418D`
|
||||
- `ptc` 피티씨: `#84338E`
|
||||
- `halla` 한라산업개발: `#0068B7`
|
||||
- `baron` 바론컨설턴트: `#0C367F`
|
||||
|
||||
@@ -162,9 +162,9 @@ ui-viz에서 확인한 구조(실제 파일: `compositions/dashboard-variant-spe
|
||||
|
||||
| 영역 | ui-viz 구조 | 현재 HM 대응 | 이번 구현 |
|
||||
|---|---|---|---|
|
||||
| 조립 | Variant A(LNB 272 + 콘텐츠), Variant B(GNB 70 + 콘텐츠), Variant C(Compact 48). **GNB+LNB 동시 사용 예 없음** | `DashboardLayout` | slot 방식. 둘 다 / 하나만 |
|
||||
| 조립 | Variant A(LNB 240 + 콘텐츠. 2026-11-09 이전 272), Variant B(GNB 70 + 콘텐츠), Variant C(Compact 48). **GNB+LNB 동시 사용 예 없음** | `DashboardLayout` | slot 방식. 둘 다 / 하나만 |
|
||||
| GNB | 70px green, 콘텐츠와 12px 접점 | `GNB` | slot `header` |
|
||||
| LNB | 272px green, 콘텐츠와 18px 접점 | `LNB` | slot `sidebar` |
|
||||
| LNB | 240px green(2026-11-09 이전 272), 콘텐츠와 18px 접점 | `LNB` | slot `sidebar` |
|
||||
| Content surface | `#F5F0E5`, padding 34, 접점 쪽 radius(LNB 18 / GNB 12), 그림자 `-8px 0 18px`(LNB) · `0 -8px 18px`(GNB) rgba(7,30,24,.18), 별도 border 없음 | 신규 | `DashboardMain` |
|
||||
| 스크롤 | "메뉴/헤더는 고정, 콘텐츠 영역에서만 스크롤"(Confirmed). `overflow-y:auto`, `overscroll-behavior: contain` | 신규 | `DashboardMain`이 스크롤 소유 |
|
||||
| Page heading | breadcrumb 12px(정적 `p`, 컴포넌트 아님) → 제목 28 → 설명, space-between·align end·gap 24, **하단 20px** | Breadcrumb + 신규 Pattern | `PageHeader` |
|
||||
@@ -245,7 +245,7 @@ Compact / Sidebar mobile hide: NONE
|
||||
|
||||
- 충분한 폭에서는 기존 배치를 사용한다. 작은 viewport에서는 내부 최소 폭을 유지하고 Dashboard 전용 root에서만 가로 스크롤한다. Sidebar slot은 항상 렌더링된다.
|
||||
- 960px 근거: KPI 4 × 200px + 간격 3 × 24px + padding 2 × 34px + 세로 scrollbar 여유 20px. Compact 상태 바 실측 약 549px, 브랜드 약 125px, 날개 35px도 이 폭에서 겹치지 않는다.
|
||||
- Sidebar 구성은 콘텐츠 최소 폭에 LNB 폭을 더한다. Desktop은 기존 `--hm-lnb-width`를 읽고, ≤1023px에서는 Dashboard 내부에만 `--hm-lnb-width: var(--hm-dashboard-sidebar-width, 272px)`와 LNB `display:flex`를 적용한다. 공통 `lnb.css`의 모바일 숨김은 유지된다.
|
||||
- Sidebar 구성의 최소 폭은 콘텐츠 최소 폭에 `--hm-dashboard-sidebar-width`(기본 272px)를 더한 값이다(`[data-sidebar]`일 때 `min-width: calc(var(--hm-dashboard-min-width) + var(--hm-dashboard-sidebar-width))`). `DashboardSidebar` 폭도 같은 토큰을 직접 쓴다. Dashboard CSS는 `--hm-lnb-width` 등 GNB/LNB CSS 변수와 반응형 분기를 읽지 않는다(`dashboard-layout.css` 첫 줄).
|
||||
- 기본 콘텐츠 최소 폭은 `--hm-dashboard-min-width`로 조정할 수 있다. 기본값 960px은 현재 Desktop 구성 측정 기준이며 별도 콘텐츠를 넣으면 그 구성을 기준으로 조정한다.
|
||||
- Compact 메뉴는 숨기거나 wrap하지 않는다. Dashboard 안 Filter Bar도 한 줄과 기존 control 폭을 유지하며 공통 Filter Bar 모바일 정책은 변경하지 않는다. 본문 텍스트는 자연스럽게 줄바꿈한다.
|
||||
- GNB의 전역 메뉴·모바일 Sheet와 일반 LNB의 공통 responsive 정책, Theme/Color Token, Story ID/hierarchy는 변경하지 않는다.
|
||||
@@ -260,7 +260,7 @@ Compact / Sidebar mobile hide: NONE
|
||||
|
||||
| 항목 | ui-viz | 현재 HM | 분류 |
|
||||
|---|---|---|---|
|
||||
| GNB 70 / LNB 272 | 같음 | `--hm-gnb-height` / `--hm-lnb-width` | MATCHED |
|
||||
| GNB 70 / LNB 240 | 같음(LNB 폭은 2026-11-09 공식 규격 채택, ui-viz 272와 다름) | `--hm-gnb-height` / `--hm-lnb-width` | MATCHED |
|
||||
| 콘텐츠 면 색·padding 34 | `#F5F0E5`, 34 | 같음 | MATCHED |
|
||||
| 접점 radius (LNB 18 / GNB 12), shadow | 같음 | 같음. 소유를 Dashboard로 이동 | MATCHED |
|
||||
| 헤더·메뉴 고정, 콘텐츠만 스크롤 | 같음 | `DashboardMain` 스크롤 | MATCHED |
|
||||
|
||||
@@ -70,7 +70,7 @@ Header·cell·row·selected 면은 `packages/ui/src/theme/table.css`(`--hm-table
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- `Patterns/Data/Data Table` → `Data Table`(대표: Overview 선택·정렬·열 표시·행 액션·페이지 크기·페이지, `기본 목록 검색`, `추가 조건 목록 검색`), `With Filter Bar`(숨김 Filter Bar 연동 검증). 두 목록 검색 구역은 흰 Card 하나에 CardTitle(`사용자 목록`) → 검색·열 표시 도구 → 표 순서다. 페이지 제목 PageHeader를 쓰는 화면에서는 PageHeader를 이 Card 밖에 둔다. verification: `WithFilterBar`, `Loading`, `Empty`.
|
||||
- `Patterns/Data/Data Table` → `Data Table`(대표: `Advanced Search` 구역 하나. 추가 조건 목록 검색 + 선택·정렬·열 표시·행 액션·페이지 크기·페이지), 숨김 verification Story `Overview`(기본 표, `patterns-data-data-table--overview`), `Basic Search`(기본 목록 검색, `patterns-data-data-table--basic-search`), `With Filter Bar`(추가 조건 연동 검증). 목록 검색 구역은 흰 Card 하나에 CardTitle(`사용자 목록`) → 검색·열 표시 도구 → 표 순서다. 페이지 제목 PageHeader를 쓰는 화면에서는 PageHeader를 이 Card 밖에 둔다. verification: `WithFilterBar`, `Loading`, `Empty`.
|
||||
- 샘플 데이터는 `src/patterns/sample-users.ts`(47행, 네트워크 없음).
|
||||
|
||||
## 10. Accessibility
|
||||
|
||||
@@ -112,7 +112,7 @@ ui-viz에서 GNB(Variant B, top-header)와 LNB(Variant A, sidebar)는 서로 대
|
||||
|
||||
**AI 구현 필수**: `GNBNav`는 모바일에서 숨겨질 뿐 모바일 메뉴를 자동 생성하지 않는다. 최초 구현부터 Desktop `GNBNav`와 Mobile `GNBMobileMenu`를 함께 넣고 같은 메뉴 데이터·현재 경로를 사용한다. `GNBMobileMenu`가 햄버거와 Sheet를 모두 제공하므로 별도 메뉴 버튼이나 Sheet를 추가하지 않는다. 기본 GNB는 `GNBMobileLink / GNBMobileGroup`을 사용한다. 링크 선택은 router의 `preventDefault()` 여부와 관계없이 Sheet를 닫으며, 닫기 버튼·Escape·focus trap·메뉴 버튼으로의 포커스 복귀는 기존 Sheet/Radix 동작을 유지한다.
|
||||
|
||||
Reference는 [대표 Story 소스](../../src/patterns/gnb.stories.tsx)의 `All`(`Patterns/Layout/GNB → GNB`, `patterns-layout-gnb--all`) 안 Overview·Usage · GNB Sidebar를 좁은 폭에서 확인한다. `Mobile / Mobile Menu Open`은 숨김 verification이며 동작 검사에만 사용한다. Sidebar를 포함하면 [§15의 공개 API 소비 예제](#15-gnb-only-sidebar-composition-2026-10-08)를 따른다. LNB 모바일 링크 재사용 시 자동 닫기 Context가 다르므로 controlled Sheet를 route handler에서 닫고 Desktop 복귀 시에도 닫는다. 이는 GNB ONLY 내부 Composition이며 별도 LNB ONLY 모드가 아니다.
|
||||
Reference는 [대표 Story 소스](../../src/patterns/gnb.stories.tsx)의 `All`(`Patterns/Layout/GNB → GNB`, `patterns-layout-gnb--all`) 안 Variants(Default, Long Labels)·States(Navigation Menu Open)를 좁은 폭에서 확인하고, With Logo·Utilities는 숨김 `With Logo`·`Utilities` Story에서 확인하며, GNB Sidebar는 숨김 `GNB Sidebar`(`patterns-layout-gnb--sidebar`)·`Brown Sidebar`(`patterns-layout-gnb--brown-sidebar`), Sticky는 숨김 `Sticky` Story에서 확인한다(2026-11-09: 대표 페이지의 Usage 3개를 개별 verification Story로 분리). `Mobile / Mobile Menu Open`은 숨김 verification이며 동작 검사에만 사용한다. Sidebar를 포함하면 [§15의 공개 API 소비 예제](#15-gnb-only-sidebar-composition-2026-10-08)를 따른다. LNB 모바일 링크 재사용 시 자동 닫기 Context가 다르므로 controlled Sheet를 route handler에서 닫고 Desktop 복귀 시에도 닫는다. 이는 GNB ONLY 내부 Composition이며 별도 LNB ONLY 모드가 아니다.
|
||||
|
||||
- Desktop(1024px 이상): 위 표. 높이 70, 좌우 28, column gap 24.
|
||||
- Mobile(1023px 이하): 높이 56, padding 16. 주 메뉴·eyebrow·사용자 글자를 숨기고 햄버거(흰 ghost Button, `aria-label`)를 맨 앞에, 사용자는 같은 Avatar 하나만 보인다(중복 없음). 햄버거 → HM Sheet(left): 제목, 링크(44px), 그룹 제목. Radix가 focus trap·Escape·focus 복귀를 맡는다. breakpoint를 768에서 1024로 올린 이유: 메뉴 4개(항목 최소 112) 가운데 정렬은 그 이하에서 brand/profile이 너무 좁아진다. ui-viz에는 반응형 기준이 없다.
|
||||
@@ -176,14 +176,14 @@ src/patterns/gnb.stories.tsx
|
||||
|
||||
## 15. GNB ONLY Sidebar Composition (2026-10-08)
|
||||
|
||||
대표 `Patterns/Layout/GNB → GNB`의 **Usage · GNB Sidebar**에서 확인한다. 직접 동작 검증은 숨김 `GNB Sidebar` Story(`patterns-layout-gnb--sidebar`)를 사용한다. 기존 `LNB → VERIFICATION ONLY · GNB + LNB`는 운영 기준으로 승격하지 않는다.
|
||||
숨김 `GNB Sidebar`(`patterns-layout-gnb--sidebar`)와 `Brown Sidebar`(`patterns-layout-gnb--brown-sidebar`) Story에서 확인한다(대표 `GNB` 페이지에서는 분리됨). 기존 `LNB → VERIFICATION ONLY · GNB + LNB`는 운영 기준으로 승격하지 않는다.
|
||||
|
||||
구조는 **GNB Header → Sidebar + Main Content**다. Sidebar는 공개 `LNB / LNBNav / LNBGroup / LNBItem`을 그대로 사용한다. 기존 DOM/data-slot을 유지하고 브랜드·사용자 영역은 GNB만 담당한다. `LNBBrand / LNBProfile`은 기본 조합에서 생략한다. 새로운 GNBSidebar 공개 컴포넌트·메뉴 로직은 없다.
|
||||
|
||||
공용 `@hm-design/ui/theme.css`의 opt-in HTML attribute 계약:
|
||||
|
||||
- 프레임: `data-hm-gnb-layout="sidebar"`. 소비자가 높이를 지정하며 Grid가 GNB의 자연 높이와 남은 본문 높이를 배분한다.
|
||||
- 본문: 프레임의 직계 `data-hm-gnb-body`. Desktop은 기존 `--hm-lnb-width` 열 + 가용 콘텐츠 열, Mobile은 콘텐츠 한 열이다.
|
||||
- 본문: 프레임의 직계 `data-hm-gnb-body`. Desktop은 기존 `--hm-lnb-width` 열 + 가용 콘텐츠 열, Mobile은 콘텐츠 한 열이다. 열 폭과 항목 크기는 LNB 공식 규격(2026-11-09: 240px, 14px, 1뎁스 48px·2/3뎁스 42px)을 그대로 따르며 GNB 전용 override는 두지 않는다.
|
||||
- Sidebar: 본문의 직계 `<LNB data-hm-gnb-sidebar>`. 기존 `data-slot="lnb"`는 유지한다. 이 Reference 범위에서 본문과 같은 배경, 세로 divider 한 개를 사용한다. Green은 Primary, `data-hm-color="brown"`은 기존 Brown 팔레트로 선택·Hover를 표현한다. 펼침 화살표는 유지하며 아이콘은 선택적으로 전달한다. 신규 AI GNB_ONLY에는 Sidebar를 추가하지 않는다.
|
||||
- Main: 본문의 직계 `<main data-hm-gnb-main>`. 공용 CSS가 면·접점·스크롤을 제공하며 콘텐츠 padding은 소비자가 정한다. GNB의 상단 접점과 LNB의 측면 접점은 기존 값을 재사용한다.
|
||||
|
||||
@@ -284,4 +284,4 @@ export function Example() {
|
||||
|
||||
사용자 메뉴와 콘텐츠 padding은 기존 GNB 사용 예제에 따라 소비자가 추가한다. Theme Source, LNB 원본, Runtime 계약, 공개 API는 변경하지 않는다. 공통 요약은 [AGENTS.md](../../AGENTS.md), 탐색 경로는 [Reference Index](../guidelines/component-pattern-reference.md)를 따른다.
|
||||
|
||||
검증(2026-10-08, 기존 Storybook 6024): 1440px Sidebar 272px / Green Light 2, 2·3Depth 선택·그룹 Enter/Space·Hover·선택 경로·메뉴 스크롤 확인. 768px·375px에서 GNB 56px / Sidebar 숨김 / Main 전체 폭 / 흰 Sheet 1개 / 링크 44px·14px / 선택 시 닫기·Escape·포커스 복귀 확인. 열린 Sheet에서 Desktop 복귀 시 닫힘과 선택 경로 보존 확인. 기존 GNB Overview·LNB 대표 메뉴·Dashboard Header computed style 비교 동일, 검사한 화면의 Console error 없음. 앱 TypeScript 검사 통과. 전체 프로젝트 회귀 테스트와 실기기 터치 검증은 수행하지 않았다.
|
||||
검증(2026-10-08, 기존 Storybook 6024): 1440px Sidebar 272px(당시 값. 2026-11-09부터 LNB 규격 240px) / Green Light 2, 2·3Depth 선택·그룹 Enter/Space·Hover·선택 경로·메뉴 스크롤 확인. 768px·375px에서 GNB 56px / Sidebar 숨김 / Main 전체 폭 / 흰 Sheet 1개 / 링크 44px·14px / 선택 시 닫기·Escape·포커스 복귀 확인. 열린 Sheet에서 Desktop 복귀 시 닫힘과 선택 경로 보존 확인. 기존 GNB Overview·LNB 대표 메뉴·Dashboard Header computed style 비교 동일, 검사한 화면의 Console error 없음. 앱 TypeScript 검사 통과. 전체 프로젝트 회귀 테스트와 실기기 터치 검증은 수행하지 않았다.
|
||||
+19
-12
@@ -19,7 +19,7 @@ Implementation: current @hm-design/ui primitives / patterns (HM Collapsible + pl
|
||||
|---|---|
|
||||
| 구현 상태 | 구현 완료 (2026-10-03), 3뎁스 추가 (2026-10-06) |
|
||||
| 기반 | HM Collapsible + 일반 요소 (새 dependency 없음) |
|
||||
| 대표 Story | `Patterns/Layout/LNB` → `LNB` (verification: Third Level, Third Level Long Labels, Default, Selected, Expanded, Selected Child, Collapsed With Selection, With Logo, Long Labels, Scroll, Third Level Scroll, VERIFICATION ONLY · GNB + LNB, LNB Standalone Desktop, LNB Standalone Mobile, LNB Standalone Mobile Menu Open) |
|
||||
| 대표 Story | `Patterns/Layout/LNB` → `LNB` (States: Selected·Expanded·3뎁스 Selected, Variants: With Logo·Long Labels. Scroll·Standalone 반응형은 2026-11-09 대표 페이지에서 분리되어 아래 verification Story로 확인; verification: Third Level, Third Level Long Labels, Default, Selected, Expanded, Selected Child, Collapsed With Selection, With Logo, Long Labels, Scroll, Third Level Scroll, VERIFICATION ONLY · GNB + LNB, LNB Standalone Desktop, LNB Standalone Mobile, LNB Standalone Mobile Menu Open) |
|
||||
| 보류 | §11 |
|
||||
|
||||
## 2. 목적 / 역할
|
||||
@@ -81,10 +81,10 @@ Collapsible의 trigger/content는 `asChild`로 자체 `button`/`div`를 써서 C
|
||||
|
||||
| 항목 | 명세 | 구현 | 분류 |
|
||||
|---|---|---|---|
|
||||
| 글자 | 1·2뎁스 18/28/700, 3뎁스 18/24/500, 선택 700 | 같음 | MATCHED |
|
||||
| 3뎁스 행 | 최소 48, 상하 12, 링크 padding-left 88(2뎁스 68 + 20) | 같음 (1·2뎁스 56 유지) | MATCHED |
|
||||
| 글자 | 1·2뎁스 18/28/700, 3뎁스 18/24/500, 선택 700 | 크기만 14로 변경: 1·2뎁스 14/28/700, 3뎁스 14/24/500, 선택 700 유지 | INTENTIONAL_CHANGE (2026-11-09) |
|
||||
| 3뎁스 행 | 최소 48, 상하 12, 링크 padding-left 88(2뎁스 68 + 20) | 최소 42, 상하 9, padding-left 88 유지 (1뎁스 48, 2뎁스 42) | INTENTIONAL_CHANGE (2026-11-09) |
|
||||
| 3뎁스 글자 장식 | 외곽선·그림자 없음 | 같음 | MATCHED |
|
||||
| 긴 이름 | 줄바꿈, 행 높이 증가 | 48 / 72 / 96 (1·2·3줄), ellipsis 없음, 가로 overflow 0 | MATCHED |
|
||||
| 긴 이름 | 줄바꿈, 행 높이 증가 | 3뎁스 기준 42 / 66 / 90 (1·2·3줄, 24px 줄 + 상하 9), ellipsis 없음, 가로 overflow 0. 2026-11-09 이전 값은 48 / 72 / 96 | INTENTIONAL_CHANGE (2026-11-09) |
|
||||
| 선택 | Beige + `--hm-sem-action-primary`, 오른쪽 곡선 | 기존 current 규칙 그대로(폭 +1, 위·아래 오목 곡선) | MATCHED |
|
||||
| 상위 경로 | 1·2뎁스 노랑, 접혀도 유지 | `current` → `--hm-yellow-accent` | MATCHED |
|
||||
| 연결선 | 3뎁스만, 1px, 세로선 x 68, 가로 12, 글자까지 8, 행 세로 중앙, 마지막 행 중앙에서 끝 | 행마다 background 2겹(세로 `1px × 100%`, 마지막 행 `1px × 50%`, 가로 `12 × 1` at `calc(50% - 0.5px)`). DOM·tab stop·aria 없음 | MATCHED / IMPLEMENTATION_CHANGE(명세의 "배경 레이어" 방식) |
|
||||
@@ -153,11 +153,11 @@ LNB Mode가 Desktop Brand/Profile과 Mobile Header/Sheet Profile을 담당한다
|
||||
|
||||
| 항목 | ui-viz | 최종 HM | 분류 |
|
||||
|---|---|---|---|
|
||||
| 폭 | track 272, visual 290(오른쪽 18 bleed) | 열 272(`--hm-lnb-width`), 뒤로 18(`--hm-lnb-join`) 같은 색 연장 | MATCHED (연장은 `::after`) |
|
||||
| 폭 | track 272, visual 290(오른쪽 18 bleed) | 열 240(`--hm-lnb-width`, 2026-11-09 공식 규격 채택), 뒤로 18(`--hm-lnb-join`) 같은 색 연장 | INTENTIONAL_CHANGE (폭만 ui-viz 272와 다름, 연장은 `::after`) |
|
||||
| 배경 | 90° 5-stop `#123328/#205241/#266651/#249473/#266651` 0/30/50/90/100%, 트랙 폭, 트랙 밖 `#266651` | 같음 (`--hm-green-*`) | MATCHED |
|
||||
| 상단 | 위 28, 브랜드 padding 0 10 22, 메뉴 위 20 | 위 28, 브랜드 inset 30(=20+10), 아래 22, 메뉴 위 20 | MATCHED |
|
||||
| 브랜드 | 로고 조건부, 위 로고·아래 서비스명, eyebrow 11/700, 이름 17 | 같음 | MATCHED |
|
||||
| 항목 | 18/28/700, 최소 56, padding 14 12 14 18, White, 아이콘 20 opacity .5, 글자 테두리 1.5px + 그림자 `1px 2px 1px` | 같음 | MATCHED |
|
||||
| 항목 | 18/28/700, 최소 56, padding 14 12 14 18, White, 아이콘 20 opacity .5, 글자 테두리 1.5px + 그림자 `1px 2px 1px` | 14/28/700, 1뎁스 최소 48(padding 상하 10), 2뎁스 최소 42(상하 7). 색·아이콘·글자 테두리는 같음 | INTENTIONAL_CHANGE (2026-11-09) |
|
||||
| hover | Black 10%, 콘텐츠 경계까지, 시작 쪽 radius 4 | 같음 | MATCHED |
|
||||
| current | Beige + `#123328`, 왼쪽 radius 8, 오른쪽 20+1 연장, 위·아래 18 오목 곡선, 안쪽 깊이 그림자(마지막 25% fade), 글자 효과 없음 | 같음. 깊이는 span 대신 background gradient 3겹으로 근사 | MATCHED / IMPLEMENTATION_CHANGE |
|
||||
| nested(2뎁스) | 상위 = disclosure 버튼, 화살표 접힘 → 펼침 90°, 펼친 그룹 Black 8% + 1px `#266651` 선 + 왼쪽 radius 8, 하위 padding-left 68, 하단 8, 하위 선택 시 상위 노랑 `#FFC600` | 같음. HM Collapsible(Radix) 사용 | MATCHED / IMPLEMENTATION_CHANGE |
|
||||
@@ -175,13 +175,15 @@ LNB Mode가 Desktop Brand/Profile과 Mobile Header/Sheet Profile을 담당한다
|
||||
|
||||
| Token | 값 |
|
||||
|---|---|
|
||||
| `--hm-lnb-width` | 272px (1023px 이하에서 0px) |
|
||||
| `--hm-lnb-width` | 240px (1023px 이하에서 0px) |
|
||||
| `--hm-lnb-join` | 18px. 콘텐츠 둥근 모서리·bleed |
|
||||
| `--hm-lnb-background` / `-background-end` | 5-stop gradient / `#266651` |
|
||||
| item | min-height 56, 18/28/700, gap 12, 아이콘 20, 글자 테두리 1.5px |
|
||||
| item | 1뎁스 min-height 48(`--hm-lnb-item-min-height`) + padding-block 10(`--hm-lnb-item-padding-block`), 14/28/700(`--hm-lnb-item-font-size`), gap 12, 아이콘 20, 글자 테두리 1.5px |
|
||||
| `--hm-lnb-subitem-*` | 2뎁스(그룹 안 직속 링크·중첩 그룹 trigger) min-height 42(`-min-height`), padding-block 7(`-padding-block`), padding-start 68 |
|
||||
| `--hm-lnb-profile-min-height` | 48px. Profile 높이는 3뎁스 행과 독립이다 |
|
||||
| current | `--hm-sem-bg-content` + `--hm-sem-action-primary`, radius 8 0 0 8 |
|
||||
| group | open face Black 8%, 선 1px `--hm-sem-palette-green-light-2`, 하위 padding-start 68, current 글자 `--hm-sem-palette-yellow-accent` |
|
||||
| `--hm-lnb-third-*` | min-height 48, padding-block 12, text padding-start 88(메뉴 행 기준), line-height 24, weight 500 / current 700, connector x `= --hm-lnb-subitem-padding-start`(68)·length 12·width 1, 모든 상태에서 `--hm-sem-palette-green-tint` 40%. 실제 link는 connector 끝부터 시작 |
|
||||
| `--hm-lnb-third-*` | min-height 42, padding-block 9, text padding-start 88(메뉴 행 기준), line-height 24, weight 500 / current 700, connector x `= --hm-lnb-subitem-padding-start`(68)·length 12·width 1, 모든 상태에서 `--hm-sem-palette-green-tint` 40%. 실제 link는 connector 끝부터 시작 |
|
||||
|
||||
새 Primitive: `--hm-yellow-accent: #ffc600`(`primitives.css`). ui-viz `color.action.accent-on-green`이며 HM 팔레트에 없어 추가했다. 그 외 새 Semantic은 없다.
|
||||
|
||||
@@ -212,11 +214,11 @@ viewport 높이 shell에서 모바일 Header와 main을 세로 flex로 배분할
|
||||
|
||||
> HISTORICAL_RECORD: 아래 날짜별 geometry·상태 측정은 당시 기록이다. 현재 링크 interaction 범위·connector 색은 §3a/§5, Profile interaction은 §7을 따른다.
|
||||
|
||||
- 1280: current 항목 x 20→273, 높이 56, 18/28/700, Beige `#F5F0E5`/`#123328`, radius `8 0 0 8`, 그룹·하위 x 20→272. 하위 선택 시 상위 `rgb(255,198,0)`, 접힌 채여도 유지. 긴 이름은 줄바꿈(112px), 스크롤 영역은 메뉴만 스크롤하고 profile은 아래 22px 고정. 그룹 trigger Enter로 펼침.
|
||||
- 1280(당시 값. 2026-11-09부터 공식 규격은 열 240, 1뎁스 48·2/3뎁스 42, 14px): current 항목 x 20→273, 높이 56, 18/28/700, Beige `#F5F0E5`/`#123328`, radius `8 0 0 8`, 그룹·하위 x 20→272. 하위 선택 시 상위 `rgb(255,198,0)`, 접힌 채여도 유지. 긴 이름은 줄바꿈(112px), 스크롤 영역은 메뉴만 스크롤하고 profile은 아래 22px 고정. 그룹 trigger Enter로 펼침.
|
||||
- 1000px: 열 `display:none`, 가로 overflow 없음. 390px은 GNB 모바일 메뉴로 대체.
|
||||
- `npm run typecheck`, `npm run build` 통과. 기존 primitive Story 변화 없음.
|
||||
|
||||
- 3뎁스(2026-10-06, Storybook): 위 §3a 수치를 computed style로 확인(1280 열 272, 3뎁스 x 20 → 272/선택 273, 그룹 2개 모두 x 20·폭 252, 안쪽 그룹 배경 투명·`::before` 없음). 키보드: Enter로 바깥 그룹 접힘(노랑 유지, 3뎁스·선 사라짐), Space로 다시 펼침(안쪽 그룹 열림 유지), Tab → 안쪽 그룹 Enter로 접힘. trigger는 `button` + `aria-expanded`/`aria-controls`, 선택 링크 `aria-current="page"`, focus-visible 흰 outline. 기존 Selected Child 수치 변화 없음(2뎁스 68/56/700, 그룹 하단 8, 선 1px).
|
||||
- 3뎁스(2026-10-06, Storybook): 위 §3a 수치를 computed style로 확인(당시 1280 열 272, 3뎁스 x 20 → 272/선택 273, 그룹 2개 모두 x 20·폭 252, 안쪽 그룹 배경 투명·`::before` 없음). 키보드: Enter로 바깥 그룹 접힘(노랑 유지, 3뎁스·선 사라짐), Space로 다시 펼침(안쪽 그룹 열림 유지), Tab → 안쪽 그룹 Enter로 접힘. trigger는 `button` + `aria-expanded`/`aria-controls`, 선택 링크 `aria-current="page"`, focus-visible 흰 outline. 기존 Selected Child 수치 변화 없음(2뎁스 68/56/700, 그룹 하단 8, 선 1px).
|
||||
|
||||
- 스크롤 소유(2026-10-06, Storybook): 열 = flex column, brand·profile `flex: none`, `LNBNav`만 `flex: 1 1 auto; min-height: 0; overflow-y: auto`(열 overflow 없음, 높이는 Story shell / Dashboard Layout이 결정). 메뉴 위 20을 nav margin 2 + padding 18에서 nav padding 20(`--hm-lnb-nav-padding-top`)으로 옮겨 스크롤 영역이 brand 바로 아래에서 시작(첫 항목 y 그대로). 측정: Scroll(460) nav 93.7→370 = brand bottom → profile top, client 276 / scroll 830, 최하단 마지막 항목 bottom 351.7(= nav bottom − 18); Third Level Scroll(460) client 276 / scroll 990, 3뎁스 선택 행·곡선·연결선 함께 스크롤; Dashboard Third Level 1280·1024×600 nav 163.7→510, client 346 / scroll 526, 최하단 491.7. 1023px 열 `display:none`, 가로 overflow 없음.
|
||||
|
||||
@@ -230,6 +232,10 @@ viewport 높이 shell에서 모바일 Header와 main을 세로 flex로 배분할
|
||||
|
||||
Collapsible, Navigation Menu 등 기존 primitive 코드·theme CSS는 수정하지 않았다. `primitives.css`에 `--hm-yellow-accent` 한 줄만 추가했다.
|
||||
|
||||
**공식 기본 규격 (2026-11-09):** PC 열 240px, 메뉴 14px, 행 높이 1뎁스 48px·2/3뎁스 42px를 `packages/ui/src/theme/lnb.css`의 공식 기본값으로 채택했다. 이전 값(272px, 18px, 56px / 3뎁스 48px)은 위 날짜별 검증 기록에만 남는다. 모바일 규격(`--hm-lnb-mobile-*`)과 `DashboardSidebar`(자체 `--hm-dashboard-sidebar-width` 272px)는 변경하지 않았다. GNB ONLY 내부 Sidebar는 LNB 부품과 `--hm-lnb-width`를 재사용하므로 같은 규격(240px / 14px / 48px / 42px)을 따른다. 소비 프로젝트는 크기를 Storybook 화면이 아니라 `--hm-lnb-*` 토큰에서 읽어야 하며, 새 CSS 변수는 `--hm-lnb-item-padding-block`, `--hm-lnb-subitem-min-height`, `--hm-lnb-subitem-padding-block`, `--hm-lnb-profile-min-height` 4개다.
|
||||
|
||||
**Storybook 전용 표시(`src/patterns/lnb.stories.css`):** `Patterns/Layout/LNB` 대표 Story만 `.hm-lnb-story` 범위 안에서 최소 너비 212px, 브랜드 아래 padding 8px, Profile 글자(14/12/700), 구분선을 덮어쓴다. 크기 규격은 더 이상 덮어쓰지 않는다.
|
||||
|
||||
## 10. 남은 차이
|
||||
|
||||
| 항목 | 분류 |
|
||||
@@ -241,7 +247,7 @@ Collapsible, Navigation Menu 등 기존 primitive 코드·theme CSS는 수정하
|
||||
| 실기기, 1024~1280 사이 시각 | 미확인 |
|
||||
| 4뎁스 이상 | 명세 없음. 3뎁스 규칙만 정의(더 깊이 중첩하면 3뎁스 모양이 반복된다) |
|
||||
| 선택 경로 전체 연결선을 선택색으로 칠할지 | 현재 모든 connector는 Green Tint 40%를 유지한다(§3a/§5). 선택은 Beige 면 + Primary 글자 + 700 weight로 표현하며, 경로 전체 선의 선택색은 별도 결정 전까지 적용하지 않는다 |
|
||||
| LNB 모바일 Sheet 그룹 접기·상위 current 표시 | 현재 링크 목록 방식. Desktop 18px / Mobile 14px 유지 |
|
||||
| LNB 모바일 Sheet 그룹 접기·상위 current 표시 | 현재 링크 목록 방식. Desktop 14px(2026-11-09, 이전 18px) / Mobile 14px 유지 |
|
||||
| LNB Mobile Header 공개 API | 구현 완료. GNB/Dashboard 의존성 없음 |
|
||||
|
||||
## 11. 관련 파일
|
||||
@@ -251,4 +257,5 @@ packages/ui/src/patterns/lnb/{lnb.tsx,lnb-mobile.tsx,index.ts}
|
||||
packages/ui/src/theme/lnb.css
|
||||
packages/ui/src/theme/primitives.css (--hm-yellow-accent)
|
||||
src/patterns/lnb.stories.tsx
|
||||
src/patterns/lnb.stories.css (Storybook 전용 오버라이드)
|
||||
```
|
||||
@@ -51,7 +51,7 @@ Progress는 Indeterminate 표현을 여전히 갖지 않는다(`progress.md` §1
|
||||
| 텍스트 | 14/500/-0.02em, white 60%, 끝에 정적 "..." | 동일, 폰트는 `--hm-font-family-base` | MATCHED |
|
||||
| Inline | padding 36/24, radius 6, inset shadow 24px .45, gap 12, 가운데 정렬 | 동일 (실측 높이 149, ui_viz smoke 148은 폰트 line box 차이) | MATCHED |
|
||||
| Fullscreen | fixed inset 0, assembly는 block·gap 0, 스피너는 묶음 leading edge(텍스트가 44px보다 넓으면 두 중심축이 어긋남), z-index 9999 | fixed inset 0, inline과 같은 column·gap 12로 묶음 전체를 viewport 중앙(스피너·텍스트 중심축 일치) | INTENTIONAL_CHANGE (2026-10-06 사용자 지시: 스피너가 왼쪽으로 치우쳐 보임) |
|
||||
| 면 색 | `#0a3322` (Dashboard-local) | `--hm-sem-action-primary` (Default `#123328`) | INTENTIONAL_CHANGE (HM Primitive 재사용, 새 색 만들지 않음) |
|
||||
| 면 색 | `#0a3322` (Dashboard-local) | `--hm-sem-action-primary` (Default `#164434`) | INTENTIONAL_CHANGE (HM Primitive 재사용, 새 색 만들지 않음) |
|
||||
| 폰트 | Inter → Noto Sans KR | HM 기본 폰트 | INTENTIONAL_CHANGE |
|
||||
| 막대 끝 | radius 1.5 → 렌더에서 pill | `9999px` | MATCHED |
|
||||
| 숨김/표시 fade 0.3s | 있음 | 없음 | FOLLOW_UP (소비자 lifecycle) |
|
||||
|
||||
@@ -79,7 +79,7 @@ Breadcrumb는 HM Breadcrumb 그대로(14px, 링크). ui-viz는 12px 정적 텍
|
||||
|
||||
페이지 content 면에 바로 놓는 Tabs는 PageHeader 다음에 기본 Primary(`panelSurface="content"`)를 사용한다. PageHeader의 제목→설명 **8px**, 아래 첫 콘텐츠까지 **20px**을 유지한다. 탭을 감싸는 새 White Card·border·padding은 필요하지 않으며 내부 `TabsContent`는 `px-0 py-6`로 조합할 수 있다. [Tabs의 표면·여백 소유권](../components/tabs.md#16-publisher--developer-조합-안내-2026-10-09)을 따른다.
|
||||
|
||||
2026-10-09 실제 반영: 대표 Story의 Overview에서 기존 PageHeader 바로 뒤에 `내 업무 / 완료 업무` Tabs와 공개 Table을 조합한다. 선택 탭의 면은 [Primary 상단 White alpha 0.8](../components/tabs.md#163-d-02-상단-흰색-80-실제-반영) → content로 이어지고, 탭 전체 opacity는 1이다. PageHeader Component·Typography·8px/20px Token과 기존 단일 Brand Action은 유지한다. 별도 Pattern API를 추가하지 않는다.
|
||||
2026-10-09 실제 반영(2026-11-09: 대표 Story는 Variants의 Basic·With Breadcrumb만 두고, 이 Tabs/Table 조합은 숨김 verification Story `Full Page`(`patterns-layout-page-header--full-page`)로 분리): 기존 PageHeader 바로 뒤에 `내 업무 / 완료 업무` Tabs와 공개 Table을 조합한다. 선택 탭의 면은 [Primary 상단 White alpha 0.8](../components/tabs.md#163-d-02-상단-흰색-80-실제-반영) → content로 이어지고, 탭 전체 opacity는 1이다. PageHeader Component·Typography·8px/20px Token과 기존 단일 Brand Action은 유지한다. 별도 Pattern API를 추가하지 않는다.
|
||||
|
||||
이때 TabsContent의 상하 24px을 PageHeader 하단 20px에 더해 탭 바 앞 간격으로 사용하지 않는다. Card 안의 제목은 CardTitle 규격(20/28/600), 페이지 제목은 PageHeaderTitle 규격(28/36/700)을 사용한다. CardDescription과 PageHeaderDescription은 서로 다른 계약이다. 비교 이미지의 48px 간격이나 Card 전용 Token을 페이지 전체에 적용하지 않는다.
|
||||
|
||||
|
||||
@@ -556,7 +556,8 @@ Composed Controls의 기존 레이어·색상·상세보기 예제는 보존하
|
||||
| `--hm-sw-header-selected-color` | `#FFC600` | 선택 |
|
||||
| `--hm-sw-header-focus-color` | `#F0F7F5` | Focus |
|
||||
| `--hm-sw-header-arrow-color` | `#47AF91` | 파일명 화살표 |
|
||||
| `--hm-sw-header-hover` | `rgba(255,255,255,.08)` | 더보기 메뉴 항목 Hover 배경 |
|
||||
| `--hm-sw-header-hover` | `rgba(255,255,255,.08)` | 더보기 메뉴 항목 Hover 배경(`--hm-sw-header-more-item-hover-background` 기본값) |
|
||||
| `--hm-sw-header-more-menu-background` / `-more-menu-shadow` / `-more-menu-border-color` / `-more-item-hover-background` | `#0E3D2F` / `0 4px 12px rgba(0,0,0,.5)` / `var(--hm-sw-header-object-border-color)` / `var(--hm-sw-header-hover)` | 더보기(`...`) 메뉴 면·그림자·테두리·항목 Hover(2026-11-10 개별 토큰, 값 변경 없음). 뷰어 Header Dropdown·로고 메뉴도 사용 |
|
||||
| `--hm-sw-header-hover-border-gradient` / `-hover-border-width` | Figma 홈 hover gradient / 1px | Hover 테두리(§11.1) |
|
||||
| `--hm-sw-header-home-on-line-color` / `-home-on-line-width` | `#5F4B0F` / 2px | Home On(로고 메뉴 열림, Figma 662:67267): Header 전폭 상·하단 선. 로고는 Hover 테두리 유지 |
|
||||
| `--hm-sw-header-document-menu-width` / `-document-menu-background` / `-document-menu-border-color` / `-document-menu-border-width` | 310px / `#103127` / `#041611` / 1px | 문서 Dropdown(§5.1) |
|
||||
@@ -575,7 +576,7 @@ Composed Controls의 기존 레이어·색상·상세보기 예제는 보존하
|
||||
| `--hm-sw-header-layer-footer-border-color` / `-layer-detail-background` / `-layer-detail-color` / `-layer-detail-text-opacity` | `rgba(197,177,158,.15)` / `rgba(197,177,158,.15)` / `#FFF` / .35 | 레이어 상세 영역·버튼 |
|
||||
| `--hm-sw-header-document-background` / `-document-border-color` | §10 B / `#0E4C3A` | 파일명 면, 홈 뒤, 창 영역, 인쇄 버튼(단색 선: 홈 뒤·창 영역) |
|
||||
| `--hm-sw-header-document-border-gradient` | §7 | 파일명 면 테두리(SW Header·Viewer, Open 면 포함) |
|
||||
| `--hm-sw-header-object-background` / `-object-border-color` / `-object-radius` | §10 A / `#135643` / 6px | 객체 수정 면, 더보기 메뉴 테두리(단색) |
|
||||
| `--hm-sw-header-object-background` / `-object-border-color` / `-object-radius` | §10 A / `#135643` / 6px | 객체 수정 면, 더보기 메뉴 테두리(단색, `--hm-sw-header-more-menu-border-color` 기본값) |
|
||||
| `--hm-sw-header-object-border-gradient` | §7 | 객체 수정 면 테두리(SW Header·Viewer, Viewer 끝 둥근 면 포함) |
|
||||
| `--hm-sw-header-property-background` / `-property-border-color` / `-property-radius` / `-property-shadow` | §10 C / `#08251C` / 3px / §10 C | 5개 필드, 더보기 버튼 |
|
||||
| `--hm-sw-header-pill-background` / `-pill-border-color` | `rgba(0,0,0,.8)` / `#092920` | 인쇄·가이드 pill |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@import "@hm-design/ui/theme.css";
|
||||
@@ -0,0 +1,9 @@
|
||||
@import "tailwindcss" source("./");
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@import "@hm-design/chart/tailwind.css";
|
||||
|
||||
body { margin: 0; background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); font-family: var(--hm-font-family-base); }
|
||||
.chart-example { min-height: 100dvh; }
|
||||
.chart-example-main { display: grid; min-width: 0; gap: 24px; max-width: 1180px; margin-inline: auto; padding: 24px 16px; }
|
||||
.chart-example-main h1 { margin: 0; font-size: 24px; font-weight: var(--hm-font-weight-bold); }
|
||||
.chart-example-main p { margin: 0; color: var(--hm-sem-fg-secondary); }
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>HMDesign 차트 예제</title><link rel="icon" href="data:," /></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { AreaStackedExample, ChartExamples, PieLabelExample, RadarPlainExample, RadialTextExample } from "@hm-design/chart/examples"
|
||||
import "./index.css"
|
||||
import "./hm-theme.css"
|
||||
|
||||
// Consumer of the public @hm-design/chart entries only: bar, line and donut sample cards plus one Pie, Radar, Area and Radial example.
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<div className="chart-example">
|
||||
<main className="chart-example-main">
|
||||
<header>
|
||||
<h1>HMDesign 차트 예제</h1>
|
||||
<p>막대·선·도넛 차트와 파이·방사형(Radar)·영역·래디얼 차트 예시의 표시와 툴팁·범례·반응형 동작을 확인하는 화면입니다. 모든 값은 예시 데이터입니다.</p>
|
||||
</header>
|
||||
<ChartExamples />
|
||||
{/* One example each of the Pie, Radar, Area and Radial charts. */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<PieLabelExample className="min-w-0" />
|
||||
<RadarPlainExample className="min-w-0" />
|
||||
<AreaStackedExample className="min-w-0" />
|
||||
<RadialTextExample className="min-w-0" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": { "composite": false, "noEmit": true, "types": ["vite/client"], "tsBuildInfoFile": "../../_local/chart-example.tsbuildinfo" },
|
||||
"include": ["main.tsx", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
// Consumer example of @hm-design/chart. The fonts come from the repository public folder like the publisher example.
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL("./", import.meta.url)),
|
||||
publicDir: fileURLToPath(new URL("../../public", import.meta.url)),
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: { host: "127.0.0.1" },
|
||||
})
|
||||
Generated
+402
-4
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@hm-design/center-graphic": "*",
|
||||
"@hm-design/chart": "*",
|
||||
"@hm-design/org-chart": "*",
|
||||
"@hm-design/org-chart-react": "*",
|
||||
"@hm-design/sw": "*",
|
||||
@@ -1211,6 +1212,10 @@
|
||||
"resolved": "packages/center-graphic",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hm-design/chart": {
|
||||
"resolved": "packages/chart",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hm-design/org-chart": {
|
||||
"resolved": "packages/org-chart",
|
||||
"link": true
|
||||
@@ -3564,6 +3569,46 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
|
||||
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.2.9",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
|
||||
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||
@@ -3918,6 +3963,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/builder-vite": {
|
||||
"version": "10.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.1.tgz",
|
||||
@@ -4608,6 +4665,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -4686,6 +4806,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -5894,6 +6020,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
|
||||
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
|
||||
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
|
||||
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
|
||||
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
|
||||
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
|
||||
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
|
||||
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
|
||||
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
@@ -5938,6 +6185,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -6286,6 +6539,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.53.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
|
||||
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -6386,6 +6651,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -6976,6 +7247,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -7010,6 +7291,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -8869,9 +9159,31 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
|
||||
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -8968,6 +9280,36 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -8995,6 +9337,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
@@ -9012,6 +9369,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -9859,7 +10222,6 @@
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
@@ -10236,6 +10598,28 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.4.1",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
|
||||
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
|
||||
"license": "MIT AND ISC AND BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
@@ -10700,6 +11084,20 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/chart": {
|
||||
"name": "@hm-design/chart",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"cn": "^0.4.0",
|
||||
"recharts": "3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hm-design/ui": "0.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/org-chart": {
|
||||
"name": "@hm-design/org-chart",
|
||||
"version": "0.1.0",
|
||||
@@ -10714,8 +11112,8 @@
|
||||
"@hm-design/org-chart": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"@hm-design/ui": "0.1.0"
|
||||
"@hm-design/ui": "0.1.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@hm-design/ui": {
|
||||
|
||||
+5
-1
@@ -26,6 +26,9 @@
|
||||
"typecheck": "tsc -b && npm run typecheck --workspaces --if-present",
|
||||
"build:ui": "npm run build -w @hm-design/ui",
|
||||
"build:center-graphic": "npm run build -w @hm-design/center-graphic",
|
||||
"build:chart": "npm run build -w @hm-design/chart",
|
||||
"build:chart-example": "vite build --config examples/chart/vite.config.ts",
|
||||
"dev:chart-example": "vite --config examples/chart/vite.config.ts",
|
||||
"build:org-chart": "npm run build -w @hm-design/org-chart",
|
||||
"typecheck:org-chart": "npm run typecheck -w @hm-design/org-chart",
|
||||
"test:org-chart": "npm run test -w @hm-design/org-chart",
|
||||
@@ -39,12 +42,13 @@
|
||||
"build:org-chart-react-example": "vite build --config examples/org-chart-react/vite.config.ts",
|
||||
"dev:org-chart-react-example": "vite --config examples/org-chart-react/vite.config.ts",
|
||||
"test:org-chart-react-browser": "node examples/org-chart-react/verify.mjs",
|
||||
"build:packages": "npm run build:org-chart && npm run build:ui && npm run build:center-graphic && npm run build:org-chart-react && npm run build:sw"
|
||||
"build:packages": "npm run build:org-chart && npm run build:ui && npm run build:center-graphic && npm run build:chart && npm run build:org-chart-react && npm run build:sw"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hm-design/org-chart": "*",
|
||||
"@hm-design/org-chart-react": "*",
|
||||
"@hm-design/center-graphic": "*",
|
||||
"@hm-design/chart": "*",
|
||||
"@hm-design/sw": "*",
|
||||
"@hm-design/ui": "*",
|
||||
"lucide-react": "^1.49.0",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# @hm-design/chart
|
||||
|
||||
공식 shadcn/ui Chart(`recharts@3.8.0`) 기반의 HM 차트 패키지입니다. Bar(세로·가로·부호)·Line·Donut·Pie·Radar·Area·Radial 차트와 shadcn Chart 기본 부품을 제공하며, 색상·카드·글꼴은 `@hm-design/ui`의 HM Theme 토큰을 그대로 사용합니다. 색상값(HEX)은 이 패키지에 정의하지 않습니다.
|
||||
|
||||
> 이 패키지에는 별도 `ai/` 폴더가 없고 이 README가 AI 코딩 도구용 사용 지침을 겸합니다(공통 `AI_GUIDE.md`의 "기타" 유형). 아래 순서대로 따릅니다: **① 설치·연결 → ② 차트 고르기 → ③ 데이터 구성 → ④ 렌더링 → ⑤ 검증**.
|
||||
|
||||
## 1. 설치·연결
|
||||
|
||||
- 이 저장소를 Git Submodule로 포함한 프로젝트는 [외부 설치 가이드](../../docs/guidelines/external-install-submodule.md)의 절차를 따릅니다. **HMDesign 안에서 `npm ci` 후 `npm run build:ui`와 `npm run build:chart`(또는 `npm run build:packages`)를 먼저 실행**해야 `dist/`가 생깁니다. `dist/`는 Git에 없습니다.
|
||||
- 소비 프로젝트의 npm Workspace에는 **`HMDesign/packages/ui`와 `HMDesign/packages/chart`를 함께** 추가합니다. `@hm-design/chart`는 `@hm-design/ui`(`0.1.0`), `react`·`react-dom`(`^19`), `tailwindcss`(`^4`)를 peer로 요구하며, 차트가 직접 쓰는 것은 `@hm-design/ui`의 **Theme CSS 토큰**(`theme.css`)뿐입니다(`@hm-design/ui`의 JS는 import하지 않음).
|
||||
- 이 저장소 안에서는 루트 workspace(`packages/*`)로 이미 연결되어 있습니다.
|
||||
|
||||
### CSS 연결 (Tailwind v4 앱)
|
||||
|
||||
앱의 CSS 진입점(예: `src/index.css`)에서 `@import` 순서대로 연결합니다.
|
||||
|
||||
```css
|
||||
@import "tailwindcss" source("./");
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@import "@hm-design/chart/tailwind.css";
|
||||
@import "@hm-design/ui/theme.css";
|
||||
```
|
||||
|
||||
- `@hm-design/chart/tailwind.css`는 차트 마크업의 Tailwind 유틸리티를 소비 앱 빌드가 만들도록 하는 `@source` 한 줄입니다. 연결하지 않으면 그 유틸리티 class가 소비 앱 CSS에 생성되지 않습니다.
|
||||
- `@hm-design/ui/theme.css`는 차트 색 별칭(`--hm-chart-*`)이 가리키는 `--hm-sem-*` 토큰과 Noto Sans KR 글꼴을 제공합니다.
|
||||
- 차트 스타일(`@hm-design/chart/style.css`)은 JS 진입점(`@hm-design/chart`, `@hm-design/chart/examples`)이 자동으로 불러오므로 따로 import하지 않습니다.
|
||||
- `<html>`·`<body>`에 배경·글꼴을 정하는 것은 소비 앱의 몫입니다(차트는 전역 규칙을 넣지 않습니다).
|
||||
|
||||
## 2. 진입점과 차트 고르기
|
||||
|
||||
| Entry | 내용 |
|
||||
| --- | --- |
|
||||
| `@hm-design/chart` | 차트 컴포넌트(아래 표), shadcn Chart 부품(`ChartContainer`, `ChartTooltip(Content)`, `ChartLegend(Content)`, `ChartStyle`, `ChartConfig`), `chartColors`, 타입 |
|
||||
| `@hm-design/chart/examples` | **예시 데이터**와 예제 카드(`ChartExamples` 및 `*Example` 컴포넌트, `sample*` 데이터, `SampleDataBadge`). 실제 업무 데이터가 아니며 운영 화면에 그대로 쓰지 않습니다. |
|
||||
| `@hm-design/chart/tailwind.css` | Tailwind 유틸리티 스캔용 `@source` |
|
||||
| `@hm-design/chart/style.css` | 차트 토큰·스타일(JS 진입점이 자동으로 불러옴) |
|
||||
|
||||
| 보여 줄 것 | 컴포넌트 | 데이터 모양 |
|
||||
| --- | --- | --- |
|
||||
| 카테고리별 값 비교(그룹·누적) | `HMBarChart` | `data` 행 + `categoryKey` + `series[]` |
|
||||
| 항목명이 긴 순위·값 비교 | `HMHorizontalBarChart` | `data` 행 + `categoryKey` + `valueKey` + `valueLabel` |
|
||||
| 증감(양수·음수) | `HMSignedBarChart` | `data` 행 + `categoryKey` + `valueKey` + `valueLabel` |
|
||||
| 추이 | `HMLineChart` | `data` 행 + `categoryKey` + `series[]` |
|
||||
| 시간 흐름의 양·구성비 | `HMAreaChart` | `data` 행 + `categoryKey` + `series[]` |
|
||||
| 전체 대비 비중 | `HMDonutChart`, `HMPieChart` | `data: { id, label, value, color? }[]` |
|
||||
| 두 구성의 동심원 비교 | `HMStackedPieChart` | `rings: [내부, 외부]` |
|
||||
| 두께가 보이는 입체형 비중 | `HMSolidDonutChart`, `HMSolidPieChart` | 위 Donut 데이터 (`HMSolidDonutChart`는 `totalLabel` 선택) |
|
||||
| 여러 지표의 균형 | `HMRadarChart` | `data` 행 + `categoryKey` + `series[]` |
|
||||
| 목표 대비 진행률·항목별 막대 | `HMRadialBarChart`, `HMRadialMeterChart`, `HMRadialStackedChart` | 위 Donut 데이터 / `item` / `series[]`+`row` |
|
||||
|
||||
## 3. 데이터 구성 규칙
|
||||
|
||||
```ts
|
||||
type ChartSeries = { key: string; label: string; color?: string; lightColor?: string }
|
||||
type HMDonutDatum = { id: string; label: string; value: number; color?: string }
|
||||
```
|
||||
|
||||
- 실제 데이터는 소비 앱이 props로 전달합니다. 이 패키지는 API·DB에 연결하지 않습니다.
|
||||
- `series.key`와 `HMDonutDatum.id`는 CSS 변수 이름(`--color-<key>`)으로 쓰이므로 **영문·숫자·`-`·`_`만** 사용합니다(한글·공백 금지). 화면에 보이는 이름은 `label`에 씁니다.
|
||||
- 색상을 지정하지 않으면 `--hm-chart-1`~`5`(HM 그린·브라운·웜 뉴트럴 팔레트 별칭)를 순서대로 사용합니다. 다른 색은 `color`에 CSS 색상/토큰을 전달합니다.
|
||||
- 범례 라벨은 짧게 유지합니다. Bar·Line·Radar·Area의 범례는 한 줄로 두며, 직접 `flex-wrap`으로 줄바꿈시키면 Recharts가 범례 높이를 과대 측정해 차트 아래에 빈 공간이 생길 수 있습니다. Donut·Pie·Stacked Pie는 컴포넌트 내부에서 줄바꿈 범례를 쓰도록 구성되어 있고, Radial 계열은 범례를 차트 박스 밖 아래에 따로 놓습니다.
|
||||
- 모든 차트의 `label`은 필수 접근성 이름(`role="group"`의 `aria-label`)입니다.
|
||||
- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다.
|
||||
|
||||
## 4. 렌더링
|
||||
|
||||
```tsx
|
||||
import { HMBarChart, HMLineChart, HMDonutChart } from "@hm-design/chart"
|
||||
|
||||
const received = [{ month: "1월", received: 186, completed: 142 }]
|
||||
|
||||
<HMBarChart
|
||||
label="월별 접수·완료 건수"
|
||||
data={received}
|
||||
categoryKey="month"
|
||||
series={[{ key: "received", label: "접수" }, { key: "completed", label: "완료" }]}
|
||||
/>
|
||||
<HMLineChart label="처리 시간" data={rows} categoryKey="month" series={[{ key: "average", label: "평균" }]} />
|
||||
<HMDonutChart label="상태 비중" totalLabel="전체(건)" data={[{ id: "done", label: "완료", value: 118 }]} />
|
||||
```
|
||||
|
||||
가로형 라벨 막대(`HMHorizontalBarChart`)는 항목명을 막대 안쪽 왼쪽에, 값을 막대 끝 바깥에 표시합니다. 그라데이션은 왼쪽 30% → 오른쪽 100%이고(세로형은 상단 100% → 하단 30%), 막대에 들어가지 않는 긴 항목명은 `…`로 줄이며 막대가 너무 짧거나 0이면 값 뒤에 항목명을 보여 줍니다. 전체 항목명은 툴팁에서 확인합니다. 선택 props는 `color`, `valueFormatter`입니다. `HMSignedBarChart`는 양수는 0선 위로, 음수는 아래로 그립니다. Stacked 세로형 막대(`HMBarChart stacked`)는 눈금선이 비치지 않도록 각 세그먼트를 불투명한 두 색(시리즈 색 → 같은 계열의 한 단계 밝은 HM 팔레트 색, `--hm-chart-N-light`)으로 칠합니다. 사용자 지정 `color`는 `lightColor`를 함께 주지 않으면 단색 세그먼트가 됩니다.
|
||||
|
||||
```tsx
|
||||
<HMHorizontalBarChart
|
||||
label="항목별 처리 건수"
|
||||
data={rows}
|
||||
categoryKey="item"
|
||||
valueKey="count"
|
||||
valueLabel="처리 건수"
|
||||
/>
|
||||
```
|
||||
|
||||
선 차트(`HMLineChart`) 옵션: `curve`(`"monotone"` 기본 | `"linear"` | `"step"`, 모든 시리즈에 적용), `dots`(기본 `true`; `false`면 기본 Dot 없이 선만 그리고 호버 시 활성 포인트·툴팁은 유지), `pointLabels`(큰 Dot + 값 라벨), `pointColorKey`(포인트별 색상 필드 — 문자열은 첫 시리즈, 객체는 `{ 시리즈키: 필드명 }`; 선·범례는 시리즈 색, Dot·라벨·툴팁 표시는 포인트 색). 시리즈가 둘 이상이면 같은 카테고리에서 값이 큰 쪽 라벨은 위, 작은 쪽은 아래에 두고, 선·Dot·다른 라벨·축 라벨과 닿으면 가까운 빈 자리(더 띄우기, 반대편, 좌우)로 옮깁니다. 점 간격이 라벨 폭보다 좁으면 라벨을 n개마다 표시하고 툴팁에는 모든 값이 나옵니다.
|
||||
|
||||
도넛 차트(`HMDonutChart`) 옵션: `totalLabel`(가운데에 전체 합계와 캡션), `activeId`(선택한 조각을 바깥쪽으로 확장하고 가운데에 그 이름·값 표시), `onActiveChange`(조각 클릭 시 id 전달; 상태는 소비 앱이 보관하며 호버로는 바뀌지 않음). 가운데 텍스트는 도넛 안쪽을 넘지 않도록 숫자를 줄이고(`1234.6억` 같은 축약) 이름은 `…`로 줄이며, 합계가 0이면 빈 링과 `0`을 표시합니다. 전체 이름은 범례와 툴팁에서 확인합니다.
|
||||
|
||||
파이(`HMPieChart`, `HMStackedPieChart`): `labels`(`"outside"` 조각 바깥 "이름 값"과 연결선, `"inside"` 조각 안 값)는 놓을 자리가 없으면 해당 Label만 생략하고 툴팁에는 전체 정보가 나옵니다. `HMStackedPieChart`는 `rings`(안쪽·바깥쪽 두 시리즈, 같은 `id`는 같은 색)로 동심원 링을 그립니다. 입체형 파이·도넛(`HMSolidPieChart`, `HMSolidDonutChart`): 기존 2D 차트와 같은 `data`(`id`·`label`·`value`·`color?`)·항목 순서·조각 각도·범례·툴팁을 쓰고, 조각을 약간 기울여 내려다본 두께 있는 원판(도넛은 바깥 옆면과 구멍 안쪽 벽)으로 그립니다. 회전 가능한 3D가 아니라 SVG로 두께와 깊이감만 표현하며 기존 2D 컴포넌트·Props는 바뀌지 않습니다. 윗면은 항목 색(`--color-<id>`), 옆면은 같은 계열의 한 단계 어두운 HM 토큰(`--hm-chart-1-side` ~ `--hm-chart-5-side`, 공용 `--hm-chart-N` 토큰은 그대로)을 쓰며, HM 색이 아닌 `color`는 그 색을 어둡게 섞어 옆면에 씁니다. 앞쪽 옆면과 도넛 안쪽 뒤 벽은 서로·윗면과 겹치지 않는 영역만 그리므로 가려지는 면이 비치지 않고 조각 사이에 틈이 없습니다. 두께와 크기는 차트 영역 너비·높이에 맞춰 줄어듭니다(차트 높이 `h-72`/`sm:h-80`은 `className`으로 조정). `HMSolidDonutChart`의 `totalLabel`은 기울어진 구멍 안에 합계와 캡션으로 표시하고 없으면 가운데를 비웁니다. 제약: 조각 클릭·Active(`activeId`)·Label(`labels`)은 지원하지 않으며, 값 0인 항목은 그리지 않고(범례에는 남음), 합계가 0이면 빈 원판·링을 표시합니다. 툴팁은 윗면·옆면 어디에서나 같은 항목을 표시하고 포인터를 따라갑니다. 방사형(`HMRadarChart`): `filled`(기본 영역, `false`면 선만), `dots`, `customLabels`(축 이름을 두 줄로 줄이거나 `…`로 줄임), `maxValue`(공통 축 상한, 기본 100). 영역(`HMAreaChart`): `curve`(`monotone`·`linear`·`step`), `dots`, `pointLabels`·`pointColorKey`(Line과 같은 Dot·Label·충돌 처리), `stacked`(값 누적), `expanded`(각 시점 합계를 100%로 정규화, Y축 %, 툴팁에 원래 값과 비율; 합계 0이면 0%). 영역 채움은 시리즈 색의 세로 그라데이션(위 진하게, 아래 옅게)입니다.
|
||||
|
||||
래디얼(`HMRadialBarChart`, `HMRadialMeterChart`, `HMRadialStackedChart`)은 공식 shadcn/ui radial 예제(simple·label·grid·text·shape·stacked)의 각도·반지름·트랙·중앙 Text를 따릅니다(`labels`, `grid`, `startAngle`/`endAngle`, `innerRadius`/`outerRadius`, `trackRadii`, `cornerRadius`). 중앙 숫자는 `CenterText`가 구멍 폭에 맞춰 줄이거나 `1234.6억`처럼 축약합니다. 도넛·래디얼의 링·막대 두께는 공식 예제의 1.5배입니다(2026-11-06): 단일 막대(text·shape·stacked)는 바깥 반지름을 두고 안쪽 반지름을 줄였고, 여러 막대(simple·label·grid)는 `barSize`(18, grid 16.5)·`ringGap`(4, grid 3, 기존 간격)과 기존 중앙 구멍(`innerRadius` 30)을 유지해 `outerRadius`를 `innerRadius + 항목 수 × (barSize + ringGap)`(5개: 140, grid 127.5)로 계산합니다. 래디얼 차트 영역 높이는 300px로 같고 범례는 차트 영역 아래에 따로 놓이며, 폭이 모자라면(320px) simple·label·grid의 반지름·두께·간격이 비례해 줄어듭니다. `HMRadialMeterChart`의 `trackRadii`는 막대와 같은 두께로 맞춥니다. `labels`(항목명 Label)는 막대 안쪽 시작 부분에 호를 따라 그려지며(`insideStart`) 막대 호 길이에 들어가지 않는 긴 이름은 `…`로 줄입니다(전체 이름은 범례와 툴팁).
|
||||
|
||||
> 위 설명은 코드(`src/components/*.tsx`의 Props 타입·기본값)와 Storybook(`Chart` 그룹)에서 확인된 동작만 적었습니다. 아래 "예제 컴포넌트"의 `*Example` 이름은 `@hm-design/chart/examples`에서 확인합니다.
|
||||
|
||||
## 5. 검증
|
||||
|
||||
```sh
|
||||
npm run build:ui && npm run build:chart # HMDesign 안: packages/ui/dist, packages/chart/dist
|
||||
npm run typecheck -w @hm-design/chart
|
||||
npm run build:chart-example # examples/chart (소비 예제 빌드)
|
||||
npm run dev:chart-example # 소비 예제 실행
|
||||
npm run storybook # Chart 그룹: Bar / Line / Donut / Pie / Radar / Area / Radial
|
||||
```
|
||||
|
||||
- 소비 프로젝트에서는 앱의 `tsc --noEmit`·`vite build` 후 브라우저에서 차트가 렌더링되고(SVG 표시) 툴팁·범례가 보이는지, 320px에서 가로 스크롤이 없는지 확인합니다.
|
||||
- 애니메이션이 끝난 뒤(약 1.5초) 화면을 확인합니다. 애니메이션 중에는 막대·Label이 일부만 그려집니다.
|
||||
|
||||
### 예제 컴포넌트
|
||||
|
||||
소비 예제(`examples/chart/main.tsx`)와 Storybook은 `@hm-design/chart/examples`의 예제 카드를 사용합니다: Bar(`BarChartExample`, `StackedBarChartExample`, `HorizontalBarChartExample`, `SignedBarChartExample`), Line(`LineChartExample` 외 Curve·Straight·Step 변형), Donut(`DonutChartExample` 외 Plain·Center·Active·Interactive·`DonutSolidExample`(입체형)), Pie(`PiePlainExample`, `PieLabelExample`, `PieLabelListExample`, `PieStackedExample`, `PieSolidExample`(입체형)), Radar(`RadarPlainExample`, `RadarDotExample`, `RadarLinesOnlyExample`, `RadarCustomLabelExample`), Area(`AreaCurveExample` 외 Straight·Step·Stacked·Expanded), Radial(`RadialSimpleExample`, `RadialLabelExample`, `RadialGridExample`, `RadialTextExample`, `RadialShapeExample`, `RadialStackedExample`), 묶음 `ChartExamples`. 예시 데이터(`sampleBar`, `samplePie`, `sampleRadial` 등)는 같은 진입점에서 export됩니다. 예제 카드는 `ExampleCard`(내부 컴포넌트)로 감싸며 실제 업무 데이터가 아님을 표시합니다.
|
||||
|
||||
## 공식 shadcn Chart와의 차이
|
||||
|
||||
`src/components/chart.tsx`는 `npx shadcn view chart`(radix-nova)의 공식 소스입니다. 차이는 세 가지뿐입니다: Dark 테마 항목 제거(HM은 Dark 없음), 툴팁·범례에 `data-slot` 추가, 툴팁 값의 `font-mono` 제거.
|
||||
|
||||
## 제한
|
||||
|
||||
- 차트 전용 HM 디자인 규격은 아직 없습니다. 색은 기존 HM 팔레트 Semantic 토큰의 별칭이며(`src/chart.css`), 차트 Token은 이 패키지 안에서만 정의합니다.
|
||||
- `@hm-design/chart/examples`의 `ExampleCard`는 공개 Export가 아닙니다(예제 카드를 통해서만 사용).
|
||||
- Dark 테마 없음. Runtime Theme(`@hm-design/ui/theme`)의 팔레트 override는 차트 별칭을 통해 반영됩니다.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@hm-design/chart",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"description": "HM Design System charts (shadcn/ui Chart + Recharts): bar, line, donut, pie, radar, area and radial. Reuses the @hm-design/ui theme tokens.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./examples": {
|
||||
"types": "./dist/examples/index.d.ts",
|
||||
"import": "./dist/examples.js",
|
||||
"default": "./dist/examples.js"
|
||||
},
|
||||
"./style.css": "./dist/style.css",
|
||||
"./tailwind.css": "./dist/tailwind.css"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"build": "node ../../scripts/build-package.mjs chart",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"sideEffects": [
|
||||
"**/*.css",
|
||||
"./dist/index.js",
|
||||
"./dist/examples.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"cn": "^0.4.0",
|
||||
"recharts": "3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hm-design/ui": "0.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/* Chart mapping: the shadcn/ui Chart (Recharts) wired to the HM Theme. HM has no separate chart spec yet, so series colors are
|
||||
derived from existing HM palette Semantic tokens (green / brown / warm neutral); no HEX value is defined here.
|
||||
Requires @hm-design/ui/theme.css (the --hm-sem-* tokens). Runtime Theme overrides of the palette flow through these aliases. */
|
||||
:root, [data-hm-theme] {
|
||||
/* Series order alternates green and brown so neighbouring series stay distinguishable (bar, line and donut slices). */
|
||||
--hm-chart-1: var(--hm-sem-palette-green-light-2);
|
||||
--hm-chart-2: var(--hm-sem-palette-brown-light-2);
|
||||
--hm-chart-3: var(--hm-sem-palette-green-light-3);
|
||||
--hm-chart-4: var(--hm-sem-palette-brown-light-4);
|
||||
--hm-chart-5: var(--hm-sem-palette-warm-neutral-mid);
|
||||
|
||||
/* Light end of a stacked segment: the next palette step(s) lighter in the same family as the series color.
|
||||
Brown Light 4 is the lightest brown token, so series 4 keeps its own color (a flat segment) instead of borrowing another family. */
|
||||
--hm-chart-1-light: var(--hm-sem-palette-green-light-3);
|
||||
--hm-chart-2-light: var(--hm-sem-palette-brown-light-3);
|
||||
--hm-chart-3-light: var(--hm-sem-palette-green-light-4);
|
||||
--hm-chart-4-light: var(--hm-sem-palette-brown-light-4);
|
||||
--hm-chart-5-light: var(--hm-sem-palette-warm-neutral-light);
|
||||
|
||||
/* Side walls of the solid (thick) pie and donut: the next palette step darker in the same family as the series color. */
|
||||
--hm-chart-1-side: var(--hm-sem-palette-green-light-1);
|
||||
--hm-chart-2-side: var(--hm-sem-palette-brown-light-1);
|
||||
--hm-chart-3-side: var(--hm-sem-palette-green-light-2);
|
||||
--hm-chart-4-side: var(--hm-sem-palette-brown-light-3);
|
||||
--hm-chart-5-side: var(--hm-sem-palette-warm-neutral-dark);
|
||||
|
||||
--hm-chart-tooltip-background: var(--hm-sem-bg-surface);
|
||||
--hm-chart-tooltip-border-color: var(--hm-sem-stroke-default);
|
||||
--hm-chart-tooltip-radius: var(--hm-radius-container);
|
||||
--hm-chart-tooltip-shadow: var(--hm-sem-floating-shadow);
|
||||
--hm-chart-tooltip-text: var(--hm-sem-fg-primary);
|
||||
--hm-chart-tooltip-label: var(--hm-sem-fg-secondary);
|
||||
--hm-chart-grid-color: var(--hm-sem-stroke-default);
|
||||
--hm-chart-axis-text: var(--hm-sem-fg-secondary);
|
||||
--hm-chart-center-value: var(--hm-sem-fg-primary);
|
||||
--hm-chart-font-family: var(--hm-font-family-base);
|
||||
}
|
||||
|
||||
[data-slot="chart"] {
|
||||
font-family: var(--hm-chart-font-family);
|
||||
/* The container may sit in a flex row or a narrow card: let it shrink instead of overflowing. */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
[data-slot="chart"] .recharts-cartesian-axis-tick text {
|
||||
fill: var(--hm-chart-axis-text);
|
||||
}
|
||||
[data-slot="chart"] .recharts-cartesian-grid line {
|
||||
stroke: var(--hm-chart-grid-color);
|
||||
stroke-opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-slot="chart-tooltip-content"] {
|
||||
border: 1px solid var(--hm-chart-tooltip-border-color);
|
||||
border-radius: var(--hm-chart-tooltip-radius);
|
||||
background: var(--hm-chart-tooltip-background);
|
||||
color: var(--hm-chart-tooltip-text);
|
||||
box-shadow: var(--hm-chart-tooltip-shadow);
|
||||
}
|
||||
[data-slot="chart-tooltip-content"] .text-muted-foreground {
|
||||
color: var(--hm-chart-tooltip-label);
|
||||
}
|
||||
|
||||
[data-slot="chart-legend-content"] {
|
||||
color: var(--hm-chart-tooltip-label);
|
||||
}
|
||||
|
||||
/* Donut center total. */
|
||||
[data-slot="chart-center-value"] {
|
||||
fill: var(--hm-chart-center-value);
|
||||
font-size: 24px;
|
||||
font-weight: var(--hm-font-weight-bold);
|
||||
}
|
||||
[data-slot="chart-center-label"] {
|
||||
fill: var(--hm-chart-axis-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* "Sample data" marker used by the @hm-design/chart/examples entry. */
|
||||
[data-slot="chart-sample-notice"] {
|
||||
color: var(--hm-chart-axis-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Horizontal bar chart labels. The item name sits on a gradient that starts at 30% of the series color, so the dark text gets
|
||||
a light halo (the card surface) to stay readable from the faint left end to the opaque right end. */
|
||||
:root, [data-hm-theme] {
|
||||
--hm-chart-bar-name-text: var(--hm-sem-fg-primary);
|
||||
--hm-chart-bar-name-halo: var(--hm-sem-bg-surface);
|
||||
--hm-chart-bar-value-text: var(--hm-sem-fg-primary);
|
||||
}
|
||||
[data-slot="chart-bar-name"],
|
||||
[data-slot="chart-bar-value"] {
|
||||
font-size: 12px;
|
||||
font-weight: var(--hm-font-weight-medium);
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-slot="chart-bar-name"] {
|
||||
fill: var(--hm-chart-bar-name-text);
|
||||
stroke: var(--hm-chart-bar-name-halo);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
paint-order: stroke;
|
||||
}
|
||||
[data-slot="chart-bar-value"] {
|
||||
fill: var(--hm-chart-bar-value-text);
|
||||
}
|
||||
/* Name of a bar too short to hold it: shown after the value on the card surface, so it uses the secondary text color. */
|
||||
[data-slot="chart-bar-outside-name"] {
|
||||
fill: var(--hm-chart-axis-text);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 0 line of the positive / negative bar chart: clearly darker than the regular gridlines. */
|
||||
:root, [data-hm-theme] {
|
||||
--hm-chart-zero-line: var(--hm-sem-fg-secondary);
|
||||
}
|
||||
|
||||
/* Value label above a line chart Dot; its color is set per point (series color or the point's own color). */
|
||||
[data-slot="chart-point-label"] {
|
||||
font-size: 12px;
|
||||
font-weight: var(--hm-font-weight-medium);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Pie: outside "name value" labels, value labels inside slices, and the ring key of the stacked pie. */
|
||||
[data-slot="chart-pie-label"] {
|
||||
fill: var(--hm-chart-axis-text);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-slot="chart-slice-label"] {
|
||||
fill: var(--hm-chart-bar-name-text);
|
||||
stroke: var(--hm-chart-bar-name-halo);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
paint-order: stroke;
|
||||
font-size: 12px;
|
||||
font-weight: var(--hm-font-weight-medium);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Radar: custom axis names. */
|
||||
[data-slot="chart-axis-label"] {
|
||||
fill: var(--hm-chart-axis-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Radial: item names curved along the start of each bar. The halo keeps the text readable on pale bar colors. */
|
||||
.hm-radial-name {
|
||||
fill: var(--hm-chart-bar-name-text);
|
||||
stroke: var(--hm-chart-bar-name-halo);
|
||||
stroke-width: 2px;
|
||||
stroke-linejoin: round;
|
||||
paint-order: stroke;
|
||||
font-size: 11px;
|
||||
font-weight: var(--hm-font-weight-medium);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useId, type ComponentProps } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Area, AreaChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { POINT_AXIS_PADDING, PointColorTooltip, PointDot, PointValueLabel } from "./line-points"
|
||||
import { seriesToConfig, type ChartSeries } from "./series"
|
||||
|
||||
// Area fill: the series color is strongest at the top of each area and fades towards the bottom. The overlap of two plain areas stays
|
||||
// readable because each fill is translucent; stacked areas are a bit denser because they never overlap.
|
||||
const FILL_TOP = 0.5
|
||||
const FILL_BOTTOM = 0.05
|
||||
const STACKED_FILL_TOP = 0.65
|
||||
const STACKED_FILL_BOTTOM = 0.2
|
||||
|
||||
export type HMAreaChartProps = {
|
||||
data: ReadonlyArray<Record<string, string | number>>
|
||||
/** Field of each data row used for the category (X) axis. */
|
||||
categoryKey: string
|
||||
series: readonly ChartSeries[]
|
||||
/** Line interpolation for every series: `monotone` (default), `linear` or `step`. */
|
||||
curve?: "monotone" | "linear" | "step"
|
||||
/** Small Dot on every point (default off; the active point and tooltip always appear on hover). Ignored when `pointLabels` or `pointColorKey` draw their own Dots. */
|
||||
dots?: boolean
|
||||
/** Larger Dot and value Label on every point, placed like the Line chart labels (omitted where they would collide). Not for stacked or expanded areas. */
|
||||
pointLabels?: boolean
|
||||
/** Per-point colors for Dots, Labels and tooltip indicators; same as `HMLineChart`. */
|
||||
pointColorKey?: string | Readonly<Record<string, string>>
|
||||
/** Stack the series: each area sits on the previous one, so the top edge is the running total. */
|
||||
stacked?: boolean
|
||||
/** Stack and normalize each category to 100% (Recharts `stackOffset="expand"`). The Y axis shows percent; the tooltip shows the original value and its share. A total of 0 shows 0%. */
|
||||
expanded?: boolean
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Area chart (shadcn Chart + Recharts): vertical gradient fills in HM series colors, plain, stacked or expanded to 100%. */
|
||||
export function HMAreaChart({
|
||||
data,
|
||||
categoryKey,
|
||||
series,
|
||||
curve = "monotone",
|
||||
dots = false,
|
||||
pointLabels = false,
|
||||
pointColorKey,
|
||||
stacked = false,
|
||||
expanded = false,
|
||||
label,
|
||||
className,
|
||||
}: HMAreaChartProps) {
|
||||
const scope = `hm-area-${useId().replace(/:/g, "")}`
|
||||
const gradientId = (key: string) => `${scope}-${key}`
|
||||
const stack = stacked || expanded
|
||||
const colorKeys: Readonly<Record<string, string>> =
|
||||
typeof pointColorKey === "string" ? (series[0] ? { [series[0].key]: pointColorKey } : {}) : (pointColorKey ?? {})
|
||||
const customPoints = !stack && (pointLabels || Object.keys(colorKeys).length > 0)
|
||||
const seriesKeys = series.map(item => item.key)
|
||||
|
||||
const share: ComponentProps<typeof ChartTooltipContent>["formatter"] = (_value, _name, item) => {
|
||||
const row = (item?.payload ?? {}) as Record<string, unknown>
|
||||
const key = String(item?.dataKey ?? "")
|
||||
const total = seriesKeys.reduce((sum, k) => sum + (Number(row[k]) || 0), 0)
|
||||
const value = Number(row[key]) || 0
|
||||
const percent = total > 0 ? Math.round((value / total) * 100) : 0
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: item?.color }} />
|
||||
<span className="text-muted-foreground">{series.find(s => s.key === key)?.label ?? key}</span>
|
||||
<span className="ml-auto font-medium text-foreground tabular-nums">{value.toLocaleString()} · {percent}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={seriesToConfig(series)}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={data as Record<string, string | number>[]}
|
||||
margin={{ top: 24, right: 12, left: 0, bottom: 0 }}
|
||||
stackOffset={expanded ? "expand" : undefined}
|
||||
>
|
||||
<defs>
|
||||
{series.map(item => (
|
||||
<linearGradient key={item.key} id={gradientId(item.key)} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: stack ? STACKED_FILL_TOP : FILL_TOP }} />
|
||||
<stop offset="100%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: stack ? STACKED_FILL_BOTTOM : FILL_BOTTOM }} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
{/* Inner padding keeps the first and last Dot, Label and active point from being clipped at the plot edge. */}
|
||||
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} padding={{ left: POINT_AXIS_PADDING, right: POINT_AXIS_PADDING }} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
width={expanded ? 44 : 40}
|
||||
domain={expanded ? [0, 1] : undefined}
|
||||
tickFormatter={expanded ? (value: number) => `${Math.round(value * 100)}%` : undefined}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor
|
||||
content={
|
||||
expanded
|
||||
? <ChartTooltipContent indicator="line" formatter={share} />
|
||||
: Object.keys(colorKeys).length > 0 && customPoints
|
||||
? <PointColorTooltip indicator="line" colorKeys={colorKeys} />
|
||||
: <ChartTooltipContent indicator="line" />
|
||||
}
|
||||
/>
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
|
||||
{series.map((item, index) => {
|
||||
const color = `var(--color-${item.key})`
|
||||
const colorKey = colorKeys[item.key]
|
||||
return (
|
||||
<Area
|
||||
key={item.key}
|
||||
dataKey={item.key}
|
||||
type={curve}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId(item.key)})`}
|
||||
stackId={stack ? "stack" : undefined}
|
||||
dot={customPoints ? <PointDot colorKey={colorKey} fallback={color} /> : dots ? { r: 3, fill: color, strokeWidth: 0 } : false}
|
||||
activeDot={customPoints ? <PointDot colorKey={colorKey} fallback={color} radius={6} /> : { r: 5 }}
|
||||
>
|
||||
{customPoints && pointLabels ? (
|
||||
<LabelList
|
||||
dataKey={item.key}
|
||||
content={<PointValueLabel data={data} seriesKeys={seriesKeys} seriesIndex={index} curve={curve} colorKey={colorKey} fallback={color} />}
|
||||
/>
|
||||
) : null}
|
||||
</Area>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useId } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient"
|
||||
import { seriesLightColor, seriesToConfig, type ChartSeries } from "./series"
|
||||
|
||||
export type HMBarChartProps = {
|
||||
data: ReadonlyArray<Record<string, string | number>>
|
||||
/** Field of each data row used for the category (X) axis. */
|
||||
categoryKey: string
|
||||
series: readonly ChartSeries[]
|
||||
/** Stack the series instead of grouping them. */
|
||||
stacked?: boolean
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical bar chart (shadcn Chart + Recharts) with HM series colors and a vertical gradient per bar, tooltip and legend.
|
||||
* Each bar (each segment when stacked) paints its own `linearGradient` in objectBoundingBox units, so the gradient follows
|
||||
* that bar's height. The `Bar` itself keeps the solid HM color, which the legend and tooltip indicators use.
|
||||
*/
|
||||
export function HMBarChart({ data, categoryKey, series, stacked = false, label, className }: HMBarChartProps) {
|
||||
// Unique per chart instance so several charts on one page never share gradient ids.
|
||||
const gradientScope = `hm-bar-${useId().replace(/:/g, "")}`
|
||||
const gradientId = (key: string) => `${gradientScope}-${key}`
|
||||
return (
|
||||
<ChartContainer
|
||||
config={seriesToConfig(series)}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
|
||||
>
|
||||
<BarChart accessibilityLayer data={data as Record<string, string | number>[]} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
|
||||
<defs>
|
||||
{series.map((item, index) => (
|
||||
<linearGradient key={item.key} id={gradientId(item.key)} x1="0" y1="0" x2="0" y2="1">
|
||||
{stacked ? (
|
||||
<>
|
||||
{/* Stacked segments stay opaque at both ends so gridlines never show through: the series color at the top, the
|
||||
lighter palette color of the same family at the bottom. */}
|
||||
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: seriesLightColor(index, item.color, item.lightColor), stopOpacity: 1 }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
|
||||
<stop offset="100%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{series.map((item, index) => (
|
||||
<Bar
|
||||
key={item.key}
|
||||
dataKey={item.key}
|
||||
fill={`var(--color-${item.key})`}
|
||||
shape={(props: BarShapeProps) => <Rectangle {...props} fill={`url(#${gradientId(item.key)})`} />}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
radius={stacked ? (index === series.length - 1 ? [4, 4, 0, 0] : 0) : [4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Shared by the bar charts: the gradient runs from a fully opaque HM color to 30% of it.
|
||||
// Vertical bars: top 100% -> bottom 30%. Horizontal bars: left 30% -> right 100%.
|
||||
export const BAR_GRADIENT_FULL_OPACITY = 1
|
||||
export const BAR_GRADIENT_FADED_OPACITY = 0.3
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client"
|
||||
|
||||
/* Official shadcn/ui Chart (registry radix-nova, `npx shadcn view chart`, recharts@3.8.0), kept as published.
|
||||
HM adaptations (the only differences):
|
||||
- THEMES has no `dark` entry: HM has no Dark theme (docs/guidelines/테마-토큰-가이드.md).
|
||||
- `data-slot="chart-tooltip-content"` / `"chart-legend-content"` hooks so chart.css can map HM tokens.
|
||||
- Tooltip values drop `font-mono` so numbers use the HM base font. */
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-tooltip-content"
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-legend-content"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { cn } from "cn"
|
||||
import { Pie, PieChart, Sector, usePlotArea, type PieSectorShapeProps } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { seriesToConfig } from "./series"
|
||||
import { fitText, textWidth } from "./text-fit"
|
||||
|
||||
/** One donut slice. `id` becomes a CSS variable name (`--color-<id>`): letters, digits, `-` or `_` only. */
|
||||
export type HMDonutDatum = {
|
||||
id: string
|
||||
label: string
|
||||
value: number
|
||||
/** Any CSS color. Defaults to the HM series color for the slice position. */
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type HMDonutChartProps = {
|
||||
data: readonly HMDonutDatum[]
|
||||
/** Caption under the total in the donut center. Omit it to hide the center total. */
|
||||
totalLabel?: string
|
||||
/** Id of the selected slice. It is drawn pushed outwards and the center shows its name and value instead of the total.
|
||||
* The chart is controlled: pair it with `onActiveChange` to let clicks change it. Hovering never changes it. */
|
||||
activeId?: string
|
||||
/** Called with the slice id when a slice is clicked (makes the slices clickable). */
|
||||
onActiveChange?: (id: string) => void
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Ring thickness is 1.5 times the earlier one (2026-11-06): the outer radius stays, the inner radius moves in.
|
||||
// Earlier: inner 58%, outer 82% (76% when slices are selectable) of the plot radius, i.e. 24% / 18% thick.
|
||||
const THICKNESS_SCALE = 1.5
|
||||
const LEGACY_INNER_PERCENT = 58
|
||||
const ringRadii = (selectable: boolean) => {
|
||||
const outer = selectable ? 76 : 82
|
||||
return { outer: `${outer}%`, inner: `${outer - (outer - LEGACY_INNER_PERCENT) * THICKNESS_SCALE}%` }
|
||||
}
|
||||
// The gap between slices is the 1 degree padding plus the 2px stroke. A smaller inner radius shortens the arc of that padding at
|
||||
// the middle of the ring, so the padding grows by the same ratio and the gap stays what it was (about 3.7px at the ring middle).
|
||||
const LEGACY_PADDING_ANGLE = 1
|
||||
const paddingAngle = (selectable: boolean) => {
|
||||
const outer = selectable ? 76 : 82
|
||||
const inner = outer - (outer - LEGACY_INNER_PERCENT) * THICKNESS_SCALE
|
||||
return Math.round(LEGACY_PADDING_ANGLE * ((outer + LEGACY_INNER_PERCENT) / (outer + inner)) * 100) / 100
|
||||
}
|
||||
const INNER_RATIO = 0.46// plain ring: inner 46% of the plot radius (used only when the label viewBox carries no radius)
|
||||
const EXPAND = 10 // how far the active slice grows outwards (px)
|
||||
const CENTER_MAX_SIZE = 24
|
||||
const CENTER_MIN_SIZE = 14
|
||||
|
||||
const compact = new Intl.NumberFormat("ko", { notation: "compact", maximumFractionDigits: 1 })
|
||||
|
||||
/** Center text that always stays inside the hole: the value shrinks first, then switches to a compact number, then is cut. */
|
||||
export function CenterText({
|
||||
cx,
|
||||
cy,
|
||||
innerRadius,
|
||||
value,
|
||||
caption,
|
||||
maxSize = CENTER_MAX_SIZE,
|
||||
dy = 0,
|
||||
}: {
|
||||
cx: number
|
||||
cy: number
|
||||
innerRadius?: number
|
||||
value: number
|
||||
caption?: string
|
||||
/** Largest value size in px (default 24). */
|
||||
maxSize?: number
|
||||
/** Vertical shift of the whole text, for centers that are not the middle of the hole. */
|
||||
dy?: number
|
||||
}) {
|
||||
const plot = usePlotArea()
|
||||
// The Label viewBox may not carry the radius; the hole is then INNER_RATIO of the plot radius.
|
||||
const hole = innerRadius !== undefined && innerRadius > 0 ? innerRadius : plot ? (Math.min(plot.width, plot.height) / 2) * INNER_RATIO : 40
|
||||
const maxWidth = Math.max(0, hole * 1.5)
|
||||
const full = value.toLocaleString()
|
||||
let text = full
|
||||
let size = maxSize
|
||||
while (size > CENTER_MIN_SIZE && textWidth(text, size) > maxWidth) size -= 1
|
||||
if (textWidth(text, size) > maxWidth) {
|
||||
text = compact.format(value)
|
||||
size = maxSize
|
||||
while (size > CENTER_MIN_SIZE && textWidth(text, size) > maxWidth) size -= 1
|
||||
if (textWidth(text, size) > maxWidth) text = fitText(text, maxWidth, size)
|
||||
}
|
||||
const shownCaption = caption ? fitText(caption, maxWidth, 12) : ""
|
||||
const baseline = (shownCaption ? cy - 2 : cy + size * 0.35) + dy
|
||||
return (
|
||||
<text x={cx} y={baseline} textAnchor="middle">
|
||||
<title>{caption ? `${caption} ${full}` : full}</title>
|
||||
<tspan data-slot="chart-center-value" x={cx} y={baseline} style={{ fontSize: size }}>
|
||||
{text}
|
||||
</tspan>
|
||||
{shownCaption ? (
|
||||
<tspan data-slot="chart-center-label" x={cx} y={baseline + 20}>
|
||||
{shownCaption}
|
||||
</tspan>
|
||||
) : null}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Donut chart (shadcn Chart + Recharts) with HM series colors, per-slice tooltip, legend and an optional center.
|
||||
* Center: the total with `totalLabel`, or the selected slice's name and value with `activeId`. A total of 0 draws an empty ring.
|
||||
*/
|
||||
export function HMDonutChart({ data, totalLabel, activeId, onActiveChange, label, className }: HMDonutChartProps) {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0)
|
||||
const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
|
||||
const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color })))
|
||||
const active = activeId === undefined ? undefined : data.find(item => item.id === activeId)
|
||||
const selectable = activeId !== undefined || onActiveChange !== undefined
|
||||
const showCenter = active !== undefined || Boolean(totalLabel)
|
||||
const ring = ringRadii(selectable)
|
||||
|
||||
// Drawn with the first slice (or the empty ring when the total is 0), from the slice's own center. The Label viewBox center
|
||||
// sits half a legend height below the ring center, which a hole as thick as the earlier ones hid but a thicker ring shows
|
||||
// (the caption touched the ring at 320px).
|
||||
const centerText = (props: PieSectorShapeProps, index: number) =>
|
||||
showCenter && index === 0 ? (
|
||||
<CenterText
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
innerRadius={props.innerRadius}
|
||||
value={active ? active.value : total}
|
||||
caption={active ? active.label : totalLabel}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={config}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={cn("aspect-auto h-72 w-full sm:h-80", className)}
|
||||
>
|
||||
<PieChart accessibilityLayer>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} />
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
|
||||
{total <= 0 ? (
|
||||
<Pie
|
||||
data={[{ value: 1 }]}
|
||||
dataKey="value"
|
||||
innerRadius={ring.inner}
|
||||
outerRadius={ring.outer}
|
||||
fill="var(--hm-sem-bg-subtle)"
|
||||
stroke="none"
|
||||
isAnimationActive={false}
|
||||
legendType="none"
|
||||
tooltipType="none"
|
||||
shape={(props: PieSectorShapeProps, index: number) => (
|
||||
<>
|
||||
<Sector {...props} />
|
||||
{centerText(props, index)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="id"
|
||||
innerRadius={ring.inner}
|
||||
// Room for the active slice to grow outwards without leaving the chart.
|
||||
outerRadius={ring.outer}
|
||||
paddingAngle={paddingAngle(selectable)}
|
||||
stroke="var(--hm-sem-bg-surface)"
|
||||
strokeWidth={2}
|
||||
onClick={onActiveChange ? (_slice, index) => onActiveChange(data[index].id) : undefined}
|
||||
shape={(props: PieSectorShapeProps, index: number) => (
|
||||
<>
|
||||
<Sector
|
||||
{...props}
|
||||
outerRadius={props.payload?.id === activeId ? props.outerRadius + EXPAND : props.outerRadius}
|
||||
cursor={onActiveChange ? "pointer" : undefined}
|
||||
/>
|
||||
{total > 0 ? centerText(props, index) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useId } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Bar, BarChart, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient"
|
||||
import { seriesToConfig } from "./series"
|
||||
|
||||
export type HMHorizontalBarChartProps = {
|
||||
data: ReadonlyArray<Record<string, string | number>>
|
||||
/** Field of each data row used as the item name (shown inside the bar, full text in the tooltip). */
|
||||
categoryKey: string
|
||||
/** Numeric field of each data row (bar length, shown after the bar end). */
|
||||
valueKey: string
|
||||
/** Name of the value, used as the tooltip row label. */
|
||||
valueLabel: string
|
||||
/** Any CSS color. Defaults to the first HM series color. */
|
||||
color?: string
|
||||
/** Formats the value shown after the bar end. Defaults to `toLocaleString()`. */
|
||||
valueFormatter?: (value: number) => string
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const FONT_SIZE = 12
|
||||
const ROW_HEIGHT = 40
|
||||
const BAR_SIZE = 26
|
||||
const NAME_PADDING = 10 // space between the bar start / end and the item name
|
||||
const VALUE_GAP = 8 // space between the bar end and the value
|
||||
const RIGHT_SAFE = 6 // extra room so the value never touches the chart edge
|
||||
const CHART_PADDING = 8
|
||||
|
||||
// Text is measured by estimate (full-width glyphs 1em, others ~0.6em) so truncation needs no DOM access.
|
||||
function textWidth(text: string) {
|
||||
let width = 0
|
||||
for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? FONT_SIZE : FONT_SIZE * 0.6
|
||||
return width
|
||||
}
|
||||
|
||||
/** Keeps the longest start of `text` (plus an ellipsis) that fits `maxWidth`; "" when not even one character fits. */
|
||||
function fitText(text: string, maxWidth: number) {
|
||||
if (textWidth(text) <= maxWidth) return text
|
||||
const chars = [...text]
|
||||
for (let count = chars.length - 1; count > 0; count -= 1) {
|
||||
const candidate = `${chars.slice(0, count).join("").trimEnd()}…`
|
||||
if (textWidth(candidate) <= maxWidth) return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal bar chart with custom labels (shadcn Chart + Recharts): the item name sits inside the bar at the left, the value
|
||||
* after the bar end. The gradient runs left 30% -> right 100% of the HM color. Names that do not fit the bar are shortened with
|
||||
* an ellipsis; a bar too short for any of its name (including zero) shows the name after the value instead. Names never run into
|
||||
* the value, and the tooltip always shows the full name.
|
||||
*/
|
||||
export function HMHorizontalBarChart({
|
||||
data,
|
||||
categoryKey,
|
||||
valueKey,
|
||||
valueLabel,
|
||||
color,
|
||||
valueFormatter = value => value.toLocaleString(),
|
||||
label,
|
||||
className,
|
||||
}: HMHorizontalBarChartProps) {
|
||||
const gradientId = `hm-hbar-${useId().replace(/:/g, "")}`
|
||||
const config = seriesToConfig([{ key: valueKey, label: valueLabel, color }])
|
||||
// Reserve the room the longest value needs on the right so no value is clipped by the chart edge.
|
||||
const valueRoom = Math.ceil(
|
||||
Math.max(0, ...data.map(row => textWidth(valueFormatter(Number(row[valueKey]) || 0)))) + VALUE_GAP + RIGHT_SAFE,
|
||||
)
|
||||
|
||||
const renderBar = (props: BarShapeProps) => {
|
||||
const { x, y, width, height, payload, value, parentViewBox } = props
|
||||
const name = String(payload?.[categoryKey] ?? "")
|
||||
const amount = Number(Array.isArray(value) ? value[1] : value) || 0
|
||||
const barWidth = Math.max(width, 0)
|
||||
const shownName = fitText(name, barWidth - NAME_PADDING * 2)
|
||||
const centerY = y + height / 2
|
||||
const valueText = valueFormatter(amount)
|
||||
// A bar too short to hold any of its name shows the name after the value instead, shortened to the space left of the plot edge.
|
||||
const outsideX = x + barWidth + VALUE_GAP + textWidth(valueText) + NAME_PADDING
|
||||
const outsideName = shownName ? "" : fitText(name, parentViewBox.x + parentViewBox.width - outsideX)
|
||||
return (
|
||||
<g role="img" aria-label={`${name}: ${valueText}`}>
|
||||
{barWidth > 0 ? <Rectangle {...props} fill={`url(#${gradientId})`} /> : null}
|
||||
{shownName ? (
|
||||
<text data-slot="chart-bar-name" x={x + NAME_PADDING} y={centerY} dominantBaseline="central">{shownName}</text>
|
||||
) : null}
|
||||
<text data-slot="chart-bar-value" x={x + barWidth + VALUE_GAP} y={centerY} dominantBaseline="central">
|
||||
{valueText}
|
||||
</text>
|
||||
{outsideName ? (
|
||||
<text data-slot="chart-bar-outside-name" x={outsideX} y={centerY} dominantBaseline="central">{outsideName}</text>
|
||||
) : null}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={config}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
// Same default height as the vertical bar charts so cards line up; many rows grow past it via the min-height.
|
||||
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
|
||||
style={{ minHeight: data.length * ROW_HEIGHT + CHART_PADDING * 2 }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={data as Record<string, string | number>[]}
|
||||
margin={{ top: CHART_PADDING, right: valueRoom, bottom: CHART_PADDING, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
|
||||
<stop offset="100%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis type="number" hide domain={[0, "dataMax"]} />
|
||||
<YAxis type="category" dataKey={categoryKey} hide />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar
|
||||
dataKey={valueKey}
|
||||
fill={`var(--color-${valueKey})`}
|
||||
barSize={BAR_SIZE}
|
||||
radius={[0, 4, 4, 0]}
|
||||
shape={renderBar}
|
||||
// Labels are positioned from the final bar width, so the bars do not animate in.
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cn } from "cn"
|
||||
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { POINT_AXIS_PADDING, PointColorTooltip, PointDot, PointValueLabel } from "./line-points"
|
||||
import { seriesToConfig, type ChartSeries } from "./series"
|
||||
|
||||
export type HMLineChartProps = {
|
||||
data: ReadonlyArray<Record<string, string | number>>
|
||||
/** Field of each data row used for the category (X) axis. */
|
||||
categoryKey: string
|
||||
series: readonly ChartSeries[]
|
||||
/** Line interpolation for every series. `monotone` (default) is a smooth curve that never overshoots the data; `linear` joins the points with straight segments; `step` runs horizontal then vertical between points. */
|
||||
curve?: "monotone" | "linear" | "step"
|
||||
/** Show the small default Dot on every point (default). `false` draws only the line; the active point and tooltip still appear on hover. Ignored when `pointLabels` or `pointColorKey` draw their own Dots. */
|
||||
dots?: boolean
|
||||
/** Draw a larger Dot on every point and its value next to it. With several series the higher value is labelled above its Dot and the lower
|
||||
* one below, and labels are thinned on narrow charts (the tooltip always shows every value). */
|
||||
pointLabels?: boolean
|
||||
/** Per-point colors (CSS colors, e.g. HM palette tokens) for Dots, labels and tooltip indicators; lines and the legend keep the series color.
|
||||
* A string names the row field for the first series; an object maps a series key to its row field. Implies the larger Dot. */
|
||||
pointColorKey?: string | Readonly<Record<string, string>>
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Line chart (shadcn Chart + Recharts) with HM series colors, line-cursor tooltip and legend. */
|
||||
export function HMLineChart({
|
||||
data,
|
||||
categoryKey,
|
||||
series,
|
||||
curve = "monotone",
|
||||
dots = true,
|
||||
pointLabels = false,
|
||||
pointColorKey,
|
||||
label,
|
||||
className,
|
||||
}: HMLineChartProps) {
|
||||
const colorKeys: Readonly<Record<string, string>> =
|
||||
typeof pointColorKey === "string" ? (series[0] ? { [series[0].key]: pointColorKey } : {}) : (pointColorKey ?? {})
|
||||
const customPoints = pointLabels || Object.keys(colorKeys).length > 0
|
||||
const seriesKeys = series.map(item => item.key)
|
||||
// A line without Dots keeps the same inner room as the Dot variants, so both align when shown side by side.
|
||||
const roomy = customPoints || !dots
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={seriesToConfig(series)}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
|
||||
>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={data as Record<string, string | number>[]}
|
||||
margin={{ top: roomy ? 24 : 8, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{/* Inner padding keeps the first and last Dot, label and active point from being clipped at the plot edge. */}
|
||||
<XAxis
|
||||
dataKey={categoryKey}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
padding={roomy ? { left: POINT_AXIS_PADDING, right: POINT_AXIS_PADDING } : undefined}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
|
||||
<ChartTooltip
|
||||
cursor
|
||||
content={
|
||||
Object.keys(colorKeys).length > 0
|
||||
? <PointColorTooltip indicator="line" colorKeys={colorKeys} />
|
||||
: <ChartTooltipContent indicator="line" />
|
||||
}
|
||||
/>
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
|
||||
{series.map((item, index) => {
|
||||
const color = `var(--color-${item.key})`
|
||||
const colorKey = colorKeys[item.key]
|
||||
return (
|
||||
<Line
|
||||
key={item.key}
|
||||
dataKey={item.key}
|
||||
type={curve}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={customPoints ? <PointDot colorKey={colorKey} fallback={color} /> : dots ? { r: 3, fill: color, strokeWidth: 0 } : false}
|
||||
activeDot={customPoints ? <PointDot colorKey={colorKey} fallback={color} radius={6} /> : { r: 5 }}
|
||||
>
|
||||
{pointLabels ? (
|
||||
<LabelList
|
||||
dataKey={item.key}
|
||||
content={
|
||||
<PointValueLabel data={data} seriesKeys={seriesKeys} seriesIndex={index} curve={curve} colorKey={colorKey} fallback={color} />
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Line>
|
||||
)
|
||||
})}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { ComponentProps } from "react"
|
||||
import { usePlotArea, useYAxisScale } from "recharts"
|
||||
import { ChartTooltipContent } from "./chart"
|
||||
|
||||
// Custom Dot, value label and tooltip for HMLineChart. They are rendered inside the chart, so they can read the plot area and scales.
|
||||
|
||||
/** Space kept between the plot edge and the first / last point so their Dot and label are not clipped. */
|
||||
export const POINT_AXIS_PADDING = 22
|
||||
const DOT_RADIUS = 5
|
||||
const LABEL_GAP = 7 // between the Dot edge and the label
|
||||
const FAR_LABEL_GAP = 18 // same, for the second try when the near spot touches a line
|
||||
const CHAR_WIDTH = 7.2 // estimated 12px digit width
|
||||
const LABEL_ASCENT = 10
|
||||
const LABEL_DESCENT = 2
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
type Curve = "monotone" | "linear" | "step"
|
||||
type Point = { x: number; y: number }
|
||||
type Box = { left: number; right: number; top: number; bottom: number }
|
||||
type Placement = { x: number; y: number; anchor: "start" | "middle" | "end"; box: Box }
|
||||
|
||||
function pointColor(row: Row | undefined, colorKey: string | undefined, fallback: string) {
|
||||
const own = colorKey ? row?.[colorKey] : undefined
|
||||
return typeof own === "string" && own ? own : fallback
|
||||
}
|
||||
|
||||
/** Dot with a surface-colored ring: the series color, or the row's own color when `colorKey` is set. */
|
||||
export function PointDot({
|
||||
cx,
|
||||
cy,
|
||||
payload,
|
||||
colorKey,
|
||||
fallback,
|
||||
radius = DOT_RADIUS,
|
||||
}: {
|
||||
cx?: number | string
|
||||
cy?: number | string
|
||||
payload?: Row
|
||||
colorKey?: string
|
||||
fallback: string
|
||||
radius?: number
|
||||
}) {
|
||||
const x = Number(cx)
|
||||
const y = Number(cy)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
||||
return (
|
||||
<circle
|
||||
data-slot="chart-point-dot"
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={radius}
|
||||
fill={pointColor(payload, colorKey, fallback)}
|
||||
stroke="var(--hm-sem-bg-surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function inflate(box: Box, by: number): Box {
|
||||
return { left: box.left - by, right: box.right + by, top: box.top - by, bottom: box.bottom + by }
|
||||
}
|
||||
const inside = (box: Box, x: number, y: number) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom
|
||||
|
||||
/** Number of sample points (about every 2px) of the segment that fall inside the box. */
|
||||
function segmentHits(box: Box, a: Point, b: Point) {
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 2))
|
||||
let hits = 0
|
||||
for (let i = 0; i <= steps; i += 1) {
|
||||
const t = i / steps
|
||||
if (inside(box, a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)) hits += 1
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function circleTouchesBox(box: Box, c: Point, radius: number) {
|
||||
const dx = Math.max(box.left - c.x, 0, c.x - box.right)
|
||||
const dy = Math.max(box.top - c.y, 0, c.y - box.bottom)
|
||||
return Math.hypot(dx, dy) < radius
|
||||
}
|
||||
|
||||
const boxesOverlap = (a: Box, b: Box) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top)
|
||||
|
||||
/** Line segments a series draws between two neighbouring points (a curve is approximated by its chord). */
|
||||
function connection(a: Point, b: Point, curve: Curve): Array<[Point, Point]> {
|
||||
if (curve !== "step") return [[a, b]]
|
||||
const mid = (a.x + b.x) / 2
|
||||
return [
|
||||
[a, { x: mid, y: a.y }],
|
||||
[{ x: mid, y: a.y }, { x: mid, y: b.y }],
|
||||
[{ x: mid, y: b.y }, b],
|
||||
]
|
||||
}
|
||||
|
||||
function candidateBoxes(p: Point, width: number, below: boolean): Placement[] {
|
||||
const above = (gap: number): Placement => {
|
||||
const baseline = p.y - DOT_RADIUS - gap
|
||||
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top: baseline - LABEL_ASCENT, bottom: baseline + LABEL_DESCENT } }
|
||||
}
|
||||
const under = (gap: number): Placement => {
|
||||
const top = p.y + DOT_RADIUS + gap
|
||||
const baseline = top + LABEL_ASCENT
|
||||
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top, bottom: baseline + LABEL_DESCENT } }
|
||||
}
|
||||
const side = (dir: 1 | -1): Placement => {
|
||||
const x = p.x + dir * (DOT_RADIUS + 4)
|
||||
return {
|
||||
x,
|
||||
y: p.y + 4,
|
||||
anchor: dir === 1 ? "start" : "end",
|
||||
box: { left: dir === 1 ? x : x - width, right: dir === 1 ? x + width : x, top: p.y - 6, bottom: p.y + 6 },
|
||||
}
|
||||
}
|
||||
const [first, second] = below ? [under, above] : [above, under]
|
||||
return [first(LABEL_GAP), first(FAR_LABEL_GAP), second(LABEL_GAP), second(FAR_LABEL_GAP), side(1), side(-1)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Value label of a Dot, in the Dot's color.
|
||||
* - Default side: one series is labelled above its Dot. With several series the higher value at a category goes above and the lower
|
||||
* below (a tie keeps the series order).
|
||||
* - The label then takes the first free spot of: default side near, default side farther, the other side near and farther, right,
|
||||
* left. A spot is free when it touches no line (curve, straight or step), no Dot, no earlier series' label and is neither above
|
||||
* the plot top nor on the axis labels. With no free spot the label is omitted; the Dot and the tooltip remain.
|
||||
* - When points are closer than a label is wide (narrow screens), only every n-th label is drawn; the tooltip shows every value.
|
||||
*/
|
||||
export function PointValueLabel({
|
||||
x,
|
||||
y,
|
||||
index,
|
||||
data,
|
||||
seriesKeys,
|
||||
seriesIndex,
|
||||
curve,
|
||||
colorKey,
|
||||
fallback,
|
||||
}: {
|
||||
x?: number | string
|
||||
y?: number | string
|
||||
index?: number
|
||||
data: ReadonlyArray<Row>
|
||||
seriesKeys: readonly string[]
|
||||
seriesIndex: number
|
||||
curve: Curve
|
||||
colorKey?: string
|
||||
fallback: string
|
||||
}) {
|
||||
const plot = usePlotArea()
|
||||
const yScale = useYAxisScale()
|
||||
const px = Number(x)
|
||||
const py = Number(y)
|
||||
const at = index ?? 0
|
||||
const row = data[at]
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !row) return null
|
||||
|
||||
const textOf = (key: string) => Number(row[key]).toLocaleString()
|
||||
const spacing = plot && data.length > 1 ? (plot.width - 2 * POINT_AXIS_PADDING) / (data.length - 1) : Infinity
|
||||
const widest = Math.max(...seriesKeys.flatMap(key => data.map(item => Number(item[key]).toLocaleString().length)))
|
||||
const step = Math.max(1, Math.ceil((widest * CHAR_WIDTH + 6) / spacing))
|
||||
if (at % step !== 0) return null
|
||||
|
||||
// Pixel position of any series' point at any category, from this point and the evenly spaced category axis.
|
||||
const scaleOk = typeof yScale === "function" && Number.isFinite(Number(yScale(Number(row[seriesKeys[seriesIndex]])))) &&
|
||||
Math.abs(Number(yScale(Number(row[seriesKeys[seriesIndex]]))) - py) < 1.5
|
||||
const pointOf = (seriesNo: number, i: number): Point | undefined => {
|
||||
if (!scaleOk || !Number.isFinite(spacing)) return undefined
|
||||
const value = Number(data[i]?.[seriesKeys[seriesNo]])
|
||||
const py2 = Number(yScale?.(value))
|
||||
return Number.isFinite(value) && Number.isFinite(py2) ? { x: px + (i - at) * spacing, y: py2 } : undefined
|
||||
}
|
||||
|
||||
const first = Math.max(0, at - 2)
|
||||
const last = Math.min(data.length - 1, at + 2)
|
||||
const bottomLimit = plot ? plot.y + plot.height + 4 : Infinity
|
||||
const topLimit = 2
|
||||
|
||||
const cost = (candidate: Placement, taken: Box[]) => {
|
||||
const box = inflate(candidate.box, 1.5)
|
||||
let total = 0
|
||||
if (candidate.box.bottom > bottomLimit || candidate.box.top < topLimit) total += 200
|
||||
for (const other of taken) if (boxesOverlap(candidate.box, other)) total += 30
|
||||
for (let s = 0; s < seriesKeys.length; s += 1) {
|
||||
for (let i = first; i <= last; i += 1) {
|
||||
const here = pointOf(s, i)
|
||||
if (!here) continue
|
||||
if (circleTouchesBox(box, here, DOT_RADIUS + 1)) total += 8
|
||||
const next = i < last ? pointOf(s, i + 1) : undefined
|
||||
if (next) for (const [a, b] of connection(here, next, curve)) total += segmentHits(box, a, b)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const place = (seriesNo: number): Placement | null => {
|
||||
const own = seriesNo === seriesIndex ? { x: px, y: py } : pointOf(seriesNo, at)
|
||||
if (!own) return null
|
||||
const taken: Box[] = []
|
||||
for (let earlier = 0; earlier < seriesNo; earlier += 1) {
|
||||
const placed = place(earlier)
|
||||
if (placed) taken.push(placed.box)
|
||||
}
|
||||
const mine = Number(row[seriesKeys[seriesNo]])
|
||||
const below = seriesKeys.some((key, i) => i !== seriesNo && (Number(row[key]) > mine || (Number(row[key]) === mine && i < seriesNo)))
|
||||
const width = textOf(seriesKeys[seriesNo]).length * CHAR_WIDTH + 2
|
||||
let best: Placement | null = null
|
||||
let bestCost = Infinity
|
||||
for (const candidate of candidateBoxes(own, width, below)) {
|
||||
const c = cost(candidate, taken)
|
||||
if (c < bestCost) {
|
||||
best = candidate
|
||||
bestCost = c
|
||||
}
|
||||
if (c === 0) break
|
||||
}
|
||||
// No free spot: omit this label (its Dot and the tooltip stay) rather than touch a line, a Dot or another label.
|
||||
return bestCost === 0 ? best : null
|
||||
}
|
||||
|
||||
// Without a usable scale (it never happens inside HMLineChart) fall back to "first series above, others below".
|
||||
const fallbackPlacement = (): Placement => {
|
||||
const below = seriesIndex > 0
|
||||
return candidateBoxes({ x: px, y: py }, textOf(seriesKeys[seriesIndex]).length * CHAR_WIDTH + 2, below)[0]
|
||||
}
|
||||
const placement = scaleOk ? place(seriesIndex) : fallbackPlacement()
|
||||
if (!placement) return null
|
||||
|
||||
return (
|
||||
<text
|
||||
data-slot="chart-point-label"
|
||||
x={placement.x}
|
||||
y={placement.y}
|
||||
textAnchor={placement.anchor}
|
||||
fill={pointColor(row, colorKey, fallback)}
|
||||
>
|
||||
{textOf(seriesKeys[seriesIndex])}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip whose row indicators use each series' per-point color (`colorKeys`: series key -> row field). The legend and the
|
||||
* line keep the series color.
|
||||
*/
|
||||
export function PointColorTooltip({
|
||||
colorKeys,
|
||||
payload,
|
||||
...props
|
||||
}: ComponentProps<typeof ChartTooltipContent> & { colorKeys: Readonly<Record<string, string>> }) {
|
||||
const colored = payload?.map(item => {
|
||||
const field = colorKeys[String(item.dataKey)]
|
||||
const color = field ? (item.payload as Row | undefined)?.[field] : undefined
|
||||
return typeof color === "string" && color ? { ...item, payload: { ...(item.payload as Row), fill: color } } : item
|
||||
})
|
||||
return <ChartTooltipContent {...props} payload={colored} />
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { ComponentProps } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Pie, PieChart, usePlotArea, useChartWidth } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import type { HMDonutDatum } from "./donut-chart"
|
||||
import { seriesToConfig } from "./series"
|
||||
import { fitText, textWidth } from "./text-fit"
|
||||
|
||||
const RADIAN = Math.PI / 180
|
||||
const LABEL_SIZE = 12
|
||||
const LINE_ELBOW = 16 // radial length of the connecting line
|
||||
const LINE_RUN = 10 // horizontal length of the connecting line
|
||||
const MIN_SHOWN_CHARS = 3
|
||||
|
||||
type Bounds = { left: number; right: number; top: number; bottom: number }
|
||||
type Box = { left: number; right: number; top: number; bottom: number }
|
||||
type OutsidePlacement = { path: string; x: number; y: number; anchor: "start" | "end"; text: string }
|
||||
|
||||
/** Middle angle of every slice (degrees, Recharts order: from 0° counter-clockwise), assuming the default start at 0° and no padding. */
|
||||
function midAngles(values: readonly number[]) {
|
||||
const total = values.reduce((sum, value) => sum + Math.max(value, 0), 0)
|
||||
let acc = 0
|
||||
return values.map(value => {
|
||||
const v = Math.max(value, 0)
|
||||
const mid = total > 0 ? ((acc + v / 2) / total) * 360 : 0
|
||||
acc += v
|
||||
return mid
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the "name value" labels go outside the slices, with a connecting line from each slice edge.
|
||||
* Labels are placed in slice order; a label is omitted when it would leave the chart, run into an earlier label, or
|
||||
* not even `MIN_SHOWN_CHARS` characters fit. Its slice and the tooltip still carry the full information.
|
||||
*/
|
||||
function layoutOutside(items: readonly HMDonutDatum[], cx: number, cy: number, outerRadius: number, bounds: Bounds): Array<OutsidePlacement | null> {
|
||||
const mids = midAngles(items.map(item => item.value))
|
||||
const placed: Box[] = []
|
||||
return items.map((item, index) => {
|
||||
if (!(item.value > 0)) return null
|
||||
const cos = Math.cos(-mids[index] * RADIAN)
|
||||
const sin = Math.sin(-mids[index] * RADIAN)
|
||||
const edge = { x: cx + outerRadius * cos, y: cy + outerRadius * sin }
|
||||
const elbow = { x: cx + (outerRadius + LINE_ELBOW) * cos, y: cy + (outerRadius + LINE_ELBOW) * sin }
|
||||
const side = cos >= 0 ? 1 : -1
|
||||
const end = { x: elbow.x + side * LINE_RUN, y: elbow.y }
|
||||
const available = side > 0 ? bounds.right - end.x - 6 : end.x - bounds.left - 6
|
||||
const text = fitText(`${item.label} ${item.value.toLocaleString()}`, available, LABEL_SIZE)
|
||||
if ([...text].length < MIN_SHOWN_CHARS) return null
|
||||
const width = textWidth(text, LABEL_SIZE)
|
||||
const box: Box = {
|
||||
left: side > 0 ? end.x + 3 : end.x - 3 - width,
|
||||
right: side > 0 ? end.x + 3 + width : end.x - 3,
|
||||
top: end.y - 10,
|
||||
bottom: end.y + 4,
|
||||
}
|
||||
if (box.top < bounds.top || box.bottom > bounds.bottom) return null
|
||||
if (placed.some(other => !(box.right <= other.left || other.right <= box.left || box.bottom <= other.top || other.bottom <= box.top))) return null
|
||||
placed.push(box)
|
||||
return { path: `M${edge.x},${edge.y}L${elbow.x},${elbow.y}L${end.x},${end.y}`, x: end.x + side * 3, y: end.y + 4, anchor: side > 0 ? "start" : "end", text }
|
||||
})
|
||||
}
|
||||
|
||||
type PieLabelProps = { cx?: number; cy?: number; outerRadius?: number; index?: number; midAngle?: number }
|
||||
|
||||
function PieOutsideLabel({ cx, cy, outerRadius, index, items }: PieLabelProps & { items: readonly HMDonutDatum[] }) {
|
||||
const plot = usePlotArea()
|
||||
const width = useChartWidth()
|
||||
if (cx === undefined || cy === undefined || outerRadius === undefined || index === undefined || !plot || !width) return null
|
||||
const bounds: Bounds = { left: 2, right: width - 2, top: plot.y + 2, bottom: plot.y + plot.height - 2 }
|
||||
const placement = layoutOutside(items, cx, cy, outerRadius, bounds)[index]
|
||||
if (!placement) return null
|
||||
return (
|
||||
<g>
|
||||
<path d={placement.path} fill="none" stroke="var(--hm-chart-axis-text)" strokeWidth={1} />
|
||||
<text data-slot="chart-pie-label" x={placement.x} y={placement.y} textAnchor={placement.anchor}>{placement.text}</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
/** Value inside its slice; omitted when the slice is too narrow at that radius (its name is in the legend, everything in the tooltip). */
|
||||
function PieSliceLabel({ cx, cy, outerRadius, index, midAngle, items }: PieLabelProps & { items: readonly HMDonutDatum[] }) {
|
||||
if (cx === undefined || cy === undefined || outerRadius === undefined || index === undefined || midAngle === undefined) return null
|
||||
const item = items[index]
|
||||
if (!item || !(item.value > 0)) return null
|
||||
const total = items.reduce((sum, entry) => sum + Math.max(entry.value, 0), 0)
|
||||
const radius = outerRadius * 0.62
|
||||
const text = item.value.toLocaleString()
|
||||
const arc = radius * (item.value / total) * 2 * Math.PI
|
||||
if (arc < textWidth(text, LABEL_SIZE) + 8) return null
|
||||
return (
|
||||
<text
|
||||
data-slot="chart-slice-label"
|
||||
x={cx + radius * Math.cos(-midAngle * RADIAN)}
|
||||
y={cy + radius * Math.sin(-midAngle * RADIAN)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export type HMPieChartProps = {
|
||||
data: readonly HMDonutDatum[]
|
||||
/** `"outside"`: "name value" outside each slice with a connecting line; `"inside"`: the value inside each slice (names in the legend).
|
||||
* Labels that find no free spot are omitted; the tooltip always has the full information. Omit for a plain pie. */
|
||||
labels?: "outside" | "inside"
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Pie chart (shadcn Chart + Recharts): no center hole, HM series colors, tooltip, legend and optional slice labels. */
|
||||
export function HMPieChart({ data, labels, label, className }: HMPieChartProps) {
|
||||
const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
|
||||
const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color })))
|
||||
const outside = labels === "outside"
|
||||
return (
|
||||
<ChartContainer config={config} role="group" aria-label={label} className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
|
||||
<PieChart accessibilityLayer>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} />
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="id"
|
||||
outerRadius={outside ? "58%" : "80%"}
|
||||
stroke="var(--hm-sem-bg-surface)"
|
||||
strokeWidth={2}
|
||||
label={
|
||||
outside ? <PieOutsideLabel items={data} /> : labels === "inside" ? <PieSliceLabel items={data} /> : false
|
||||
}
|
||||
labelLine={false}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export type HMPieRing = {
|
||||
/** Series id of the ring (not used as a CSS variable). */
|
||||
id: string
|
||||
/** Series name shown in the ring key and the tooltip. */
|
||||
label: string
|
||||
data: readonly HMDonutDatum[]
|
||||
}
|
||||
|
||||
export type HMStackedPieChartProps = {
|
||||
/** Inner ring first, outer ring second. Items with the same `id` share one color in both rings. */
|
||||
rings: readonly [HMPieRing, HMPieRing]
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Tooltip row label: the series (ring) name, then the item name and value of the hovered slice. */
|
||||
function RingTooltip(props: ComponentProps<typeof ChartTooltipContent>) {
|
||||
const ring = (props.payload?.[0]?.payload as { ringLabel?: string } | undefined)?.ringLabel
|
||||
return <ChartTooltipContent {...props} label={ring} nameKey="id" indicator="line" />
|
||||
}
|
||||
|
||||
/** Legend: which ring is which series, then the shared item legend (each item once). */
|
||||
function RingLegend({ rings, items, ...props }: ComponentProps<typeof ChartLegendContent> & { rings: readonly HMPieRing[]; items: readonly HMDonutDatum[] }) {
|
||||
const payload = items.map(item => ({ id: item.id, value: item.label, color: `var(--color-${item.id})`, type: "square" as const }))
|
||||
return (
|
||||
<div data-slot="chart-ring-legend" className="grid gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>안쪽 링 · {rings[0].label}</span>
|
||||
<span>바깥쪽 링 · {rings[1].label}</span>
|
||||
</div>
|
||||
<ChartLegendContent {...props} payload={payload} nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two data series as concentric rings (shadcn "pie stacked"): inner disc and outer ring, one color per item across both. */
|
||||
export function HMStackedPieChart({ rings, label, className }: HMStackedPieChartProps) {
|
||||
const items: HMDonutDatum[] = []
|
||||
for (const ring of rings) for (const item of ring.data) if (!items.some(known => known.id === item.id)) items.push(item)
|
||||
const config = seriesToConfig(items.map(item => ({ key: item.id, label: item.label, color: item.color })))
|
||||
const slices = rings.map(ring => ring.data.map(item => ({ ...item, fill: `var(--color-${item.id})`, ringLabel: ring.label })))
|
||||
return (
|
||||
<ChartContainer config={config} role="group" aria-label={label} className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
|
||||
<PieChart accessibilityLayer>
|
||||
<ChartTooltip content={<RingTooltip />} />
|
||||
<ChartLegend itemSorter={null} content={<RingLegend rings={rings} items={items} />} />
|
||||
<Pie data={slices[0]} dataKey="value" nameKey="id" outerRadius="46%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} />
|
||||
<Pie data={slices[1]} dataKey="value" nameKey="id" innerRadius="54%" outerRadius="76%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cn } from "cn"
|
||||
import { PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, useChartWidth } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { seriesToConfig, type ChartSeries } from "./series"
|
||||
import { fitText, textWidth } from "./text-fit"
|
||||
|
||||
const LABEL_SIZE = 12
|
||||
const LABEL_LINE = 14
|
||||
|
||||
/** Up to two lines: words fill the first line, the rest goes on the second line, shortened with an ellipsis if still too long. */
|
||||
function wrapLabel(text: string, maxWidth: number): string[] {
|
||||
if (maxWidth <= 0) return []
|
||||
if (textWidth(text, LABEL_SIZE) <= maxWidth) return [text]
|
||||
const words = text.split(/\s+/).filter(Boolean)
|
||||
let count = 0
|
||||
let first = ""
|
||||
while (count < words.length) {
|
||||
const next = first ? `${first} ${words[count]}` : words[count]
|
||||
if (textWidth(next, LABEL_SIZE) > maxWidth) break
|
||||
first = next
|
||||
count += 1
|
||||
}
|
||||
if (!first || count >= words.length) return [fitText(text, maxWidth, LABEL_SIZE)].filter(Boolean)
|
||||
return [first, fitText(words.slice(count).join(" "), maxWidth, LABEL_SIZE)].filter(Boolean)
|
||||
}
|
||||
|
||||
type TickProps = { x?: number | string; y?: number | string; textAnchor?: "start" | "middle" | "end"; payload?: { value?: string | number } }
|
||||
|
||||
/** Axis name as a custom label: wrapped to two lines and shortened to the room between the axis end and the chart edge. */
|
||||
function RadarAxisLabel({ x, y, textAnchor = "middle", payload }: TickProps) {
|
||||
const width = useChartWidth()
|
||||
const px = Number(x)
|
||||
const py = Number(y)
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !width) return null
|
||||
const room = textAnchor === "start" ? width - px - 4 : textAnchor === "end" ? px - 4 : Math.min(px, width - px) * 2 - 8
|
||||
const lines = wrapLabel(String(payload?.value ?? ""), room)
|
||||
if (lines.length === 0) return null
|
||||
// Lines grow away from the chart: upwards above the plot, downwards below, centered beside it.
|
||||
const first = py - ((lines.length - 1) * LABEL_LINE) / 2
|
||||
return (
|
||||
<text data-slot="chart-axis-label" x={px} y={first} textAnchor={textAnchor}>
|
||||
<title>{String(payload?.value ?? "")}</title>
|
||||
{lines.map((line, i) => (
|
||||
<tspan key={i} x={px} dy={i === 0 ? 0 : LABEL_LINE}>{line}</tspan>
|
||||
))}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export type HMRadarChartProps = {
|
||||
data: ReadonlyArray<Record<string, string | number>>
|
||||
/** Field of each data row used as the axis name. */
|
||||
categoryKey: string
|
||||
series: readonly ChartSeries[]
|
||||
/** Fill each series area (default). `false` draws only the lines. */
|
||||
filled?: boolean
|
||||
/** Dot on every axis of every series. */
|
||||
dots?: boolean
|
||||
/** Axis names as custom labels: wrapped to two lines, shortened to the room at the chart edge (full name in the label's title and the tooltip). */
|
||||
customLabels?: boolean
|
||||
/** Upper end of the value axis, shared by every series (the axis starts at 0). Default 100. */
|
||||
maxValue?: number
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Radar chart (shadcn Chart + Recharts): semi-transparent areas, outlines, optional dots, lines-only mode and custom axis labels. */
|
||||
export function HMRadarChart({ data, categoryKey, series, filled = true, dots = false, customLabels = false, maxValue = 100, label, className }: HMRadarChartProps) {
|
||||
return (
|
||||
<ChartContainer config={seriesToConfig(series)} role="group" aria-label={label} className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
|
||||
<RadarChart accessibilityLayer data={data as Record<string, string | number>[]} outerRadius="62%">
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="line" />} />
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey={categoryKey} tick={customLabels ? <RadarAxisLabel /> : undefined} />
|
||||
<PolarRadiusAxis domain={[0, maxValue]} tick={false} axisLine={false} />
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
|
||||
{series.map(item => (
|
||||
<Radar
|
||||
key={item.key}
|
||||
dataKey={item.key}
|
||||
fill={`var(--color-${item.key})`}
|
||||
fillOpacity={filled ? 0.25 : 0}
|
||||
stroke={`var(--color-${item.key})`}
|
||||
strokeWidth={2}
|
||||
dot={dots ? { r: 3, fillOpacity: 1 } : false}
|
||||
/>
|
||||
))}
|
||||
</RadarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Label, LabelList, PolarAngleAxis, PolarGrid, PolarRadiusAxis, RadialBar, RadialBarChart } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { CenterText, type HMDonutDatum } from "./donut-chart"
|
||||
import { seriesColor, seriesToConfig, type ChartSeries } from "./series"
|
||||
import { fitText } from "./text-fit"
|
||||
|
||||
// Radial charts follow the official shadcn/ui examples (chart-radial-simple, -label, -grid, -text, -shape, -stacked): the same
|
||||
// angle ranges, radii, background tracks, corner rounding, grid and center text. Radii are pixels, as in the official source.
|
||||
// Adjustments for HM: values come from `value`/`id` rows with HM series colors; the angle axis domain is explicit (0 to the largest
|
||||
// value or stack total, the same range Recharts picks automatically) so an all-zero data set cannot produce NaN; names and center
|
||||
// text are shortened to fit.
|
||||
|
||||
// Bar thickness is 1.5 times the earlier one (2026-11-06) in every radial chart. Recharts draws a bar over 80% of its band (10%
|
||||
// margin each side), so a single bar band 1.5 times as wide gives a 1.5 times thicker bar (text, shape, stacked: the outer radius
|
||||
// stays and the inner one moves in).
|
||||
// Ring charts with several bars (simple, label, grid) set the bar thickness (`barSize`) and the gap between rings directly: 18px
|
||||
// bars and 4px gaps (grid 16.5 and 3, the earlier gaps), and keep the earlier center hole (inner band radius 30), so the outer
|
||||
// radius grows to inner + bars x (bar + gap): 140 for five simple / label bars, 127.5 for five grid bars (2026-11-06, the outer
|
||||
// radius and chart height limits were lifted for this). Where the box is too narrow (320px screens) the three charts shrink all
|
||||
// radii, bar thickness and gaps in proportion (`useFitScale`).
|
||||
// The legend is drawn under the chart box, not inside it: the chart box is a fixed height that holds the largest ring, however
|
||||
// many lines the legend wraps to.
|
||||
const RADIAN = Math.PI / 180
|
||||
/** Height of the chart box of every radial chart (px): the largest ring (outer radius 140) plus the 5px chart margins. */
|
||||
const CHART_BOX = "aspect-auto h-[300px] w-full"
|
||||
const EDGE = 6 // px kept free on both sides of a ring (chart margin 5 + 1)
|
||||
const MIN_SCALE = 0.5
|
||||
const LABEL_SIZE = 11
|
||||
const MIN_LABEL_CHARS = 2
|
||||
|
||||
type PolarSector = { cx: number; cy: number; innerRadius: number; outerRadius: number; startAngle: number; endAngle: number }
|
||||
|
||||
/** Name inside the start of its bar, curved along the arc like the official "insideStart" label, shortened to the bar length. */
|
||||
function RadialNameLabel({ viewBox, value }: { viewBox?: unknown; value?: unknown }) {
|
||||
const sector = viewBox as Partial<PolarSector> | undefined
|
||||
if (!sector || sector.innerRadius === undefined || sector.outerRadius === undefined || sector.startAngle === undefined || sector.endAngle === undefined) return null
|
||||
const radius = (sector.innerRadius + sector.outerRadius) / 2
|
||||
const arcLength = radius * Math.abs(sector.endAngle - sector.startAngle) * RADIAN
|
||||
const text = fitText(String(value ?? ""), arcLength - 14, LABEL_SIZE)
|
||||
if ([...text].length < MIN_LABEL_CHARS) return null
|
||||
return <Label viewBox={viewBox as ComponentProps<typeof Label>["viewBox"]} position="insideStart" value={text} className="hm-radial-name" />
|
||||
}
|
||||
|
||||
/** Legend under the chart box, built from the series (same look as the shadcn legend; it needs no Recharts context). */
|
||||
function RadialLegend({ series }: { series: readonly ChartSeries[] }) {
|
||||
return (
|
||||
<div data-slot="chart-legend-content" className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs">
|
||||
{series.map((item, index) => (
|
||||
<div key={item.key} className="flex items-center gap-1.5">
|
||||
<div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: seriesColor(index, item.color) }} />
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Scale (at most 1) that makes a ring of `radius` px fit the width of the element behind `ref`; measured, so it follows the layout. */
|
||||
function useFitScale(radius: number) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const [scale, setScale] = useState(1)
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
const update = () => {
|
||||
const width = node.getBoundingClientRect().width
|
||||
if (width > 0) setScale(Math.min(1, Math.max(MIN_SCALE, Math.floor(((width / 2 - EDGE) / radius) * 100) / 100)))
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [radius])
|
||||
return [ref, scale] as const
|
||||
}
|
||||
|
||||
const px = (value: number) => Math.round(value * 10) / 10
|
||||
|
||||
function maxOf(values: readonly number[]) {
|
||||
const max = Math.max(0, ...values.filter(Number.isFinite))
|
||||
return max > 0 ? max : 1
|
||||
}
|
||||
|
||||
export type HMRadialBarChartProps = {
|
||||
data: readonly HMDonutDatum[]
|
||||
/** Official `chart-radial-label`: item names inside the start of each bar. */
|
||||
labels?: boolean
|
||||
/** Official `chart-radial-grid`: circular grid lines, no background track. */
|
||||
grid?: boolean
|
||||
/** Background track behind each bar (default on, off with `grid`). */
|
||||
background?: boolean
|
||||
/** Arc range in degrees. Official: simple/grid 0 to 360, label -90 to 380. */
|
||||
startAngle?: number
|
||||
endAngle?: number
|
||||
/** Pixel radius of the innermost bar band (the center hole). Default 30, as before the thickness change. */
|
||||
innerRadius?: number
|
||||
/** Pixel radius of the outermost bar band. Default: `innerRadius` + one `barSize + ringGap` per item (five items: 140, grid 127.5). */
|
||||
outerRadius?: number
|
||||
/** Bar thickness in px. Defaults: 18 (grid 16.5), 1.5 times the earlier 12 (grid 11). */
|
||||
barSize?: number
|
||||
/** Gap between neighboring bars in px. Defaults: 4 (grid 3), the same as before the thickness change. */
|
||||
ringGap?: number
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Radial bar chart with one bar per item (official simple, label and grid examples). */
|
||||
export function HMRadialBarChart({
|
||||
data,
|
||||
labels = false,
|
||||
grid = false,
|
||||
background,
|
||||
startAngle,
|
||||
endAngle,
|
||||
innerRadius = 30,
|
||||
outerRadius,
|
||||
barSize = grid ? 16.5 : 18,
|
||||
ringGap = grid ? 3 : 4,
|
||||
label,
|
||||
className,
|
||||
}: HMRadialBarChartProps) {
|
||||
const rows = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
|
||||
const series = data.map(item => ({ key: item.id, label: item.label, color: item.color }))
|
||||
const config = seriesToConfig(series)
|
||||
const outer = outerRadius ?? innerRadius + Math.max(data.length, 1) * (barSize + ringGap)
|
||||
const [fitRef, scale] = useFitScale(outer)
|
||||
return (
|
||||
<div ref={fitRef} data-slot="chart-radial" className="flex w-full flex-col">
|
||||
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
|
||||
<RadialBarChart
|
||||
accessibilityLayer
|
||||
data={rows}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={px(innerRadius * scale)}
|
||||
outerRadius={px(outer * scale)}
|
||||
>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel nameKey="id" />} />
|
||||
{grid ? <PolarGrid gridType="circle" /> : null}
|
||||
<PolarAngleAxis type="number" domain={[0, maxOf(data.map(item => item.value))]} tick={false} axisLine={false} />
|
||||
<RadialBar dataKey="value" background={background ?? !grid} barSize={px(barSize * scale)}>
|
||||
{labels ? <LabelList dataKey="label" content={<RadialNameLabel />} /> : null}
|
||||
</RadialBar>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
<RadialLegend series={series} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type HMRadialMeterChartProps = {
|
||||
/** The single value; `label` is the center caption's fallback. */
|
||||
item: HMDonutDatum
|
||||
/** Caption under the value in the center. Defaults to the item's name. */
|
||||
caption?: string
|
||||
/** Arc range in degrees. Official text: 0 to 250, shape: 0 to 100. */
|
||||
startAngle?: number
|
||||
endAngle?: number
|
||||
/** Pixel radii of the bar band. Text: 75 and 90, shape: 50 and 95 (official 80 / 90 and 65 / 95 were 1.5 times thinner). */
|
||||
innerRadius?: number
|
||||
outerRadius?: number
|
||||
/**
|
||||
* Pixel radii of the two filled circles that form the ring track behind the bar (official `polarRadius`), the outer one first.
|
||||
* The track is as thick as the bar and lines up with it: the bar fills 80% of its band, so the radii sit 10% of the band inside
|
||||
* it. Text: [88.5, 76.5], shape: [90.5, 54.5].
|
||||
*/
|
||||
trackRadii?: readonly [number, number]
|
||||
/** Corner rounding of the bar in px (official text: 10). */
|
||||
cornerRadius?: number
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Single-value radial chart with the value and caption in the center (official text and shape examples). */
|
||||
export function HMRadialMeterChart({
|
||||
item,
|
||||
caption,
|
||||
startAngle = 0,
|
||||
endAngle = 250,
|
||||
innerRadius = 75,
|
||||
outerRadius = 90,
|
||||
trackRadii = [88.5, 76.5],
|
||||
cornerRadius,
|
||||
label,
|
||||
className,
|
||||
}: HMRadialMeterChartProps) {
|
||||
const row = { ...item, fill: `var(--color-${item.id})` }
|
||||
const config = seriesToConfig([{ key: item.id, label: item.label, color: item.color }])
|
||||
return (
|
||||
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
|
||||
<RadialBarChart data={[row]} startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
|
||||
{/* Official: the first circle is the muted track, the second (smaller) one is the surface color that cuts it into a ring. */}
|
||||
<PolarGrid
|
||||
gridType="circle"
|
||||
radialLines={false}
|
||||
stroke="none"
|
||||
className="first:fill-muted last:fill-[var(--hm-sem-bg-surface)]"
|
||||
polarRadius={[...trackRadii]}
|
||||
/>
|
||||
<PolarAngleAxis type="number" domain={[0, maxOf([item.value])]} tick={false} axisLine={false} />
|
||||
<RadialBar dataKey="value" background cornerRadius={cornerRadius} />
|
||||
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
|
||||
return (
|
||||
<CenterText
|
||||
cx={Number(viewBox.cx)}
|
||||
cy={Number(viewBox.cy)}
|
||||
innerRadius={Math.min(trackRadii[1], innerRadius)}
|
||||
value={item.value}
|
||||
caption={caption ?? item.label}
|
||||
maxSize={36}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export type HMRadialStackedChartProps = {
|
||||
/** Stacked from the first series outwards along the arc. Official order: mobile (570) first, then desktop (1260). */
|
||||
series: readonly ChartSeries[]
|
||||
/** One value per series key. */
|
||||
row: Readonly<Record<string, number>>
|
||||
/** Caption under the total in the center. */
|
||||
totalLabel: string
|
||||
/** Arc range in degrees. Official: 0 to 180 (half circle). */
|
||||
startAngle?: number
|
||||
endAngle?: number
|
||||
innerRadius?: number
|
||||
outerRadius?: number
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Stacked radial chart: one row, each series a segment of the same arc, total in the center (official `chart-radial-stacked`). */
|
||||
export function HMRadialStackedChart({
|
||||
series,
|
||||
row,
|
||||
totalLabel,
|
||||
startAngle = 0,
|
||||
endAngle = 180,
|
||||
innerRadius = 65,
|
||||
outerRadius = 110,
|
||||
label,
|
||||
className,
|
||||
}: HMRadialStackedChartProps) {
|
||||
const total = series.reduce((sum, item) => sum + (Number(row[item.key]) || 0), 0)
|
||||
return (
|
||||
<div data-slot="chart-radial" className="flex w-full flex-col">
|
||||
<ChartContainer config={seriesToConfig(series)} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
|
||||
{/* The official chart sits at the middle of its square box; the half circle (outer radius 110) is moved down so it is centered in the 300px box. */}
|
||||
<RadialBarChart accessibilityLayer data={[row]} cy="68%" startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<PolarAngleAxis type="number" domain={[0, total > 0 ? total : 1]} tick={false} axisLine={false} />
|
||||
{series.map(item => (
|
||||
<RadialBar
|
||||
key={item.key}
|
||||
dataKey={item.key}
|
||||
stackId="a"
|
||||
fill={`var(--color-${item.key})`}
|
||||
cornerRadius={5}
|
||||
stroke="var(--hm-sem-bg-surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
|
||||
return <CenterText cx={Number(viewBox.cx)} cy={Number(viewBox.cy)} innerRadius={innerRadius} value={total} caption={totalLabel} dy={-14} />
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
<RadialLegend series={series} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ChartConfig } from "./chart"
|
||||
|
||||
/** HM series colors. They resolve to existing HM palette tokens (see chart.css), never to HEX values. */
|
||||
export const chartColors = [
|
||||
"var(--hm-chart-1)",
|
||||
"var(--hm-chart-2)",
|
||||
"var(--hm-chart-3)",
|
||||
"var(--hm-chart-4)",
|
||||
"var(--hm-chart-5)",
|
||||
] as const
|
||||
|
||||
/** Light end of each HM series color (stacked bar segments): the same palette family, lighter (see chart.css). */
|
||||
export const chartLightColors = [
|
||||
"var(--hm-chart-1-light)",
|
||||
"var(--hm-chart-2-light)",
|
||||
"var(--hm-chart-3-light)",
|
||||
"var(--hm-chart-4-light)",
|
||||
"var(--hm-chart-5-light)",
|
||||
] as const
|
||||
|
||||
/** Darker tone of each HM series color, for the side walls of the solid (thick) pie and donut: the same palette family, the next step darker (see chart.css). */
|
||||
export const chartSideColors = [
|
||||
"var(--hm-chart-1-side)",
|
||||
"var(--hm-chart-2-side)",
|
||||
"var(--hm-chart-3-side)",
|
||||
"var(--hm-chart-4-side)",
|
||||
"var(--hm-chart-5-side)",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Side-wall color of a solid slice. An HM series color (default or passed explicitly) maps to its darker HM token; any other CSS
|
||||
* color is darkened where it is drawn, so no shared token changes.
|
||||
*/
|
||||
export function seriesSideColor(index: number, color?: string) {
|
||||
if (color === undefined) return chartSideColors[index % chartSideColors.length]
|
||||
const known = (chartColors as readonly string[]).indexOf(color)
|
||||
return known >= 0 ? chartSideColors[known] : `color-mix(in srgb, ${color} 68%, black)`
|
||||
}
|
||||
|
||||
/** One data series. `key` becomes a CSS variable name (`--color-<key>`), so use letters, digits, `-` or `_` only. */
|
||||
export type ChartSeries = {
|
||||
key: string
|
||||
label: string
|
||||
/** Any CSS color. Defaults to the HM series color for the series position. */
|
||||
color?: string
|
||||
/** Light end of a stacked bar segment (any CSS color). Defaults to the HM light color of the series position.
|
||||
* A series with a custom `color` and no `lightColor` is drawn as a flat segment. */
|
||||
lightColor?: string
|
||||
}
|
||||
|
||||
export function seriesColor(index: number, color?: string) {
|
||||
return color ?? chartColors[index % chartColors.length]
|
||||
}
|
||||
|
||||
export function seriesLightColor(index: number, color?: string, lightColor?: string) {
|
||||
return lightColor ?? color ?? chartLightColors[index % chartLightColors.length]
|
||||
}
|
||||
|
||||
export function seriesToConfig(series: readonly ChartSeries[]): ChartConfig {
|
||||
return Object.fromEntries(
|
||||
series.map((item, index) => [item.key, { label: item.label, color: seriesColor(index, item.color) }]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useId } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Bar, BarChart, CartesianGrid, Rectangle, ReferenceLine, XAxis, YAxis, type BarShapeProps } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart"
|
||||
import { chartColors, chartLightColors } from "./series"
|
||||
|
||||
export type HMSignedBarChartProps = {
|
||||
data: ReadonlyArray<Record<string, string | number>>
|
||||
/** Field of each data row used for the category (X) axis. */
|
||||
categoryKey: string
|
||||
/** Numeric field of each data row. Positive values rise above the 0 line, negative values fall below it. */
|
||||
valueKey: string
|
||||
/** Name of the value, used as the tooltip row label. */
|
||||
valueLabel: string
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const POSITIVE = 0 // HM series 1 (green)
|
||||
const NEGATIVE = 1 // HM series 2 (brown)
|
||||
|
||||
/**
|
||||
* Vertical bar chart for positive and negative values (shadcn Chart + Recharts) around a clearly marked 0 line.
|
||||
* Color follows the sign only: positive uses the first HM series color, negative the second. Each bar is a gradient that is
|
||||
* light at the 0 line and full color at the outer end (opaque at both ends, light end = the palette's lighter step of the same
|
||||
* family), and only the outer end is rounded. The tooltip indicator uses the same solid color as the bar.
|
||||
*/
|
||||
export function HMSignedBarChart({ data, categoryKey, valueKey, valueLabel, label, className }: HMSignedBarChartProps) {
|
||||
const scope = `hm-signed-${useId().replace(/:/g, "")}`
|
||||
const positiveGradient = `${scope}-positive`
|
||||
const negativeGradient = `${scope}-negative`
|
||||
const config: ChartConfig = {
|
||||
[valueKey]: { label: valueLabel, color: chartColors[POSITIVE] },
|
||||
positive: { label: "양수", color: chartColors[POSITIVE] },
|
||||
negative: { label: "음수", color: chartColors[NEGATIVE] },
|
||||
}
|
||||
// `fill` on the row feeds the tooltip indicator, so it always matches the bar's solid color.
|
||||
const rows = data.map(row => ({ ...row, fill: Number(row[valueKey]) < 0 ? "var(--color-negative)" : "var(--color-positive)" }))
|
||||
const isNegative = (value: BarShapeProps["value"]) => Number(Array.isArray(value) ? value[1] : value) < 0
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={config}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
|
||||
>
|
||||
<BarChart accessibilityLayer data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
{/* Positive: full color at the top (outer end), light at the bottom (0 line). */}
|
||||
<linearGradient id={positiveGradient} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: "var(--color-positive)", stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: chartLightColors[POSITIVE], stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
{/* Negative: light at the top (0 line), full color at the bottom (outer end). */}
|
||||
<linearGradient id={negativeGradient} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: chartLightColors[NEGATIVE], stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: "var(--color-negative)", stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={44} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ReferenceLine y={0} stroke="var(--hm-chart-zero-line)" strokeWidth={1.5} />
|
||||
<Bar
|
||||
dataKey={valueKey}
|
||||
fill="var(--color-positive)"
|
||||
shape={(props: BarShapeProps) => {
|
||||
const negative = isNegative(props.value)
|
||||
return (
|
||||
<Rectangle
|
||||
{...props}
|
||||
fill={`url(#${negative ? negativeGradient : positiveGradient})`}
|
||||
// Flat at the 0 line, rounded only at the outer end. Recharts gives a negative bar a negative height, which
|
||||
// mirrors the corner order, so the same [4, 4, 0, 0] rounds the outer (bottom) end of a negative bar too.
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useId, useState, type MouseEvent, type ReactNode } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Pie, PieChart, usePlotArea, type PieSectorShapeProps } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { CenterText, type HMDonutDatum } from "./donut-chart"
|
||||
import { seriesSideColor, seriesToConfig } from "./series"
|
||||
|
||||
/*
|
||||
* Solid (thick) Pie and Donut, drawn as SVG - not a rotatable 3D scene.
|
||||
*
|
||||
* The disc is seen from above at a fixed tilt: the top face is an ellipse (rx, ry = rx * TILT) and the walls are that ellipse
|
||||
* shifted down by the thickness. Only Recharts' sector angles are used (angles, order and tooltip stay the real data); the
|
||||
* geometry is projected here, so text and tooltips are never skewed.
|
||||
*
|
||||
* Draw order inside one slice, which is also the whole occlusion rule (slices are flush, so no slice hides another):
|
||||
* 1. front part of the outer wall (angles 180-360, the half that faces the viewer; the back wall is hidden by the top face)
|
||||
* 2. back part of the inner wall (donut only, angles 0-180, the half seen through the hole; clipped to the hole opening so
|
||||
* it never paints over the front part of the ring)
|
||||
* 3. top face
|
||||
* Walls and the top face of different slices never overlap, so the layer order of the slices does not matter. Every part sits in
|
||||
* the slice's own Recharts layer, so hovering the top or a wall gives the same tooltip.
|
||||
*/
|
||||
|
||||
const RAD = Math.PI / 180
|
||||
const TILT = 0.6 // ry / rx of the top ellipse
|
||||
const THICKNESS = 0.2 // wall height / rx
|
||||
const MIN_THICKNESS = 8
|
||||
const HOLE = 0.55 // donut hole radius / outer radius
|
||||
const SIDE_PAD = 4
|
||||
const MIN_SPAN = 0.01 // degrees; narrower slices are not drawn
|
||||
const FULL = 359.99
|
||||
|
||||
type Projection = { cx: number; cy: number; rx: number; ry: number; h: number }
|
||||
|
||||
/** Largest tilted disc (with its wall) that fits the plot area, centered in it. Scales with the chart width on small screens. */
|
||||
function project(plot: { x: number; y: number; width: number; height: number }): Projection {
|
||||
const fit = Math.min((plot.width - SIDE_PAD * 2) / 2, (plot.height - SIDE_PAD * 2) / (2 * TILT + THICKNESS), plot.height * 0.55)
|
||||
const rx = Math.max(fit, 8)
|
||||
const h = Math.max(rx * THICKNESS, MIN_THICKNESS)
|
||||
const ry = rx * TILT
|
||||
const top = plot.y + (plot.height - (2 * ry + h)) / 2
|
||||
return { cx: plot.x + plot.width / 2, cy: top + ry, rx, ry, h }
|
||||
}
|
||||
|
||||
const num = (value: number) => Math.round(value * 100) / 100
|
||||
/** Point on the (optionally scaled) top ellipse at a Recharts angle (degrees, counter-clockwise from 3 o'clock). */
|
||||
const at = (p: Projection, scale: number, angle: number, dy = 0) =>
|
||||
`${num(p.cx + p.rx * scale * Math.cos(angle * RAD))},${num(p.cy - p.ry * scale * Math.sin(angle * RAD) + dy)}`
|
||||
|
||||
/** Elliptical arc between two angles. `forward` follows increasing angles (counter-clockwise on screen). */
|
||||
function arc(p: Projection, scale: number, from: number, to: number, forward: boolean, dy = 0) {
|
||||
const span = Math.abs(to - from)
|
||||
return `A${num(p.rx * scale)},${num(p.ry * scale)} 0 ${span > 180 ? 1 : 0} ${forward ? 0 : 1} ${at(p, scale, to, dy)}`
|
||||
}
|
||||
|
||||
/** Closed outline of a full ellipse (two half arcs, since one arc cannot start and end at the same point). */
|
||||
const ellipse = (p: Projection, scale: number, dy = 0) =>
|
||||
`M${at(p, scale, 0, dy)}A${num(p.rx * scale)},${num(p.ry * scale)} 0 1 0 ${at(p, scale, 180, dy)}A${num(p.rx * scale)},${num(p.ry * scale)} 0 1 0 ${at(p, scale, 360, dy)}Z`
|
||||
|
||||
/** Top face of one slice: a wedge (pie) or a ring segment (donut). */
|
||||
function topPath(p: Projection, hole: number, from: number, to: number) {
|
||||
if (to - from >= FULL) return hole > 0 ? `${ellipse(p, 1)}${ellipse(p, hole)}` : ellipse(p, 1)
|
||||
if (hole <= 0) return `M${p.cx},${p.cy}L${at(p, 1, from)}${arc(p, 1, from, to, true)}Z`
|
||||
return `M${at(p, 1, from)}${arc(p, 1, from, to, true)}L${at(p, hole, to)}${arc(p, hole, to, from, false)}Z`
|
||||
}
|
||||
|
||||
/** Wall band along an ellipse between two angles: the top edge, then the same edge `h` lower, walked back. */
|
||||
function wallPath(p: Projection, scale: number, from: number, to: number) {
|
||||
return `M${at(p, scale, from)}${arc(p, scale, from, to, true)}L${at(p, scale, to, p.h)}${arc(p, scale, to, from, false, p.h)}Z`
|
||||
}
|
||||
|
||||
type SolidSliceProps = {
|
||||
sector: PieSectorShapeProps
|
||||
hole: number
|
||||
sideColor: string
|
||||
topColor: string
|
||||
center?: ReactNode | ((p: Projection) => ReactNode)
|
||||
}
|
||||
|
||||
function SolidSlice({ sector, hole, sideColor, topColor, center }: SolidSliceProps) {
|
||||
const plot = usePlotArea()
|
||||
const clipId = `solid-hole-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`
|
||||
if (!plot || plot.width <= 0 || plot.height <= 0) return null
|
||||
const p = project(plot)
|
||||
const from = Math.max(Math.min(sector.startAngle, sector.endAngle), 0)
|
||||
const to = Math.min(Math.max(sector.startAngle, sector.endAngle), 360)
|
||||
const drawn = to - from > MIN_SPAN
|
||||
const frontFrom = Math.max(from, 180)
|
||||
const frontTo = Math.min(to, 360)
|
||||
const backFrom = Math.max(from, 0)
|
||||
const backTo = Math.min(to, 180)
|
||||
const seam = "var(--hm-sem-bg-surface)"
|
||||
// The seam between slices is a surface-colored stroke; on a very thin slice it thins out so the slice stays visible.
|
||||
const arcPx = (to - from) * RAD * p.rx
|
||||
const seamTop = arcPx < 4 ? 0.5 : arcPx < 10 ? 1 : 2
|
||||
const seamWall = Math.min(1.5, seamTop)
|
||||
return (
|
||||
<g data-slot="chart-solid-slice">
|
||||
{drawn && frontTo - frontFrom > MIN_SPAN ? (
|
||||
<path d={wallPath(p, 1, frontFrom, frontTo)} style={{ fill: sideColor }} stroke={seam} strokeWidth={seamWall} strokeLinejoin="round" />
|
||||
) : null}
|
||||
{drawn && hole > 0 && backTo - backFrom > MIN_SPAN ? (
|
||||
<>
|
||||
<clipPath id={clipId}>
|
||||
<path d={ellipse(p, hole)} />
|
||||
</clipPath>
|
||||
<path d={wallPath(p, hole, backFrom, backTo)} clipPath={`url(#${clipId})`} style={{ fill: sideColor }} stroke={seam} strokeWidth={seamWall} strokeLinejoin="round" />
|
||||
</>
|
||||
) : null}
|
||||
{drawn ? (
|
||||
<path d={topPath(p, hole, from, to)} fillRule="evenodd" style={{ fill: topColor }} stroke={seam} strokeWidth={seamTop} strokeLinejoin="round" />
|
||||
) : null}
|
||||
{typeof center === "function" ? center(p) : center}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip position that follows the pointer. Recharts places a pie tooltip from the circle it would draw (not from the tilted
|
||||
* ellipse), which would put it away from the hovered face; the pointer is the same point for the top face and the walls.
|
||||
* Only the position changes: the tooltip content and style are the regular ones.
|
||||
*/
|
||||
function useFollowTooltip() {
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | undefined>()
|
||||
const onMouseMove = (_data: unknown, _index: number, event: MouseEvent<SVGElement>) => {
|
||||
const svg = event.currentTarget.ownerSVGElement
|
||||
const wrapper = svg?.closest(".recharts-wrapper")
|
||||
if (!svg || !wrapper) return
|
||||
const box = svg.getBoundingClientRect()
|
||||
const tip = wrapper.querySelector<HTMLElement>(".recharts-tooltip-wrapper")
|
||||
const width = tip?.offsetWidth ?? 0
|
||||
const height = tip?.offsetHeight ?? 0
|
||||
const px = event.clientX - box.left
|
||||
const py = event.clientY - box.top
|
||||
let x = px + 14
|
||||
if (x + width > box.width) x = px - 14 - width
|
||||
let y = py + 14
|
||||
if (y + height > box.height) y = py - 14 - height
|
||||
setPosition({ x: Math.max(0, x), y: Math.max(0, y) })
|
||||
}
|
||||
return { position, onMouseMove }
|
||||
}
|
||||
|
||||
const sliceColors = (data: readonly HMDonutDatum[]) =>
|
||||
data.map((item, index) => ({ id: item.id, top: `var(--color-${item.id})`, side: seriesSideColor(index, item.color) }))
|
||||
|
||||
/** Neutral stand-in drawn when the total is 0, so an empty chart still shows a disc / ring. */
|
||||
const EMPTY_TOP = "var(--hm-sem-bg-subtle)"
|
||||
const EMPTY_SIDE = "var(--hm-sem-stroke-default)"
|
||||
|
||||
export type HMSolidPieChartProps = {
|
||||
data: readonly HMDonutDatum[]
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid pie chart: a thick tilted disc with HM series colors on the top face and a darker tone of the same family on the sides.
|
||||
* Same data, order, angles, tooltip and legend as `HMPieChart`. Drawn as SVG (no rotation).
|
||||
*/
|
||||
export function HMSolidPieChart({ data, label, className }: HMSolidPieChartProps) {
|
||||
const follow = useFollowTooltip()
|
||||
const total = data.reduce((sum, item) => sum + Math.max(item.value, 0), 0)
|
||||
const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
|
||||
const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color })))
|
||||
const colors = sliceColors(data)
|
||||
return (
|
||||
<ChartContainer config={config} role="group" aria-label={label} data-solid="pie" className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
|
||||
<PieChart accessibilityLayer>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} position={follow.position} />
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
|
||||
{total <= 0 ? (
|
||||
<Pie
|
||||
data={[{ value: 1 }]}
|
||||
dataKey="value"
|
||||
isAnimationActive={false}
|
||||
legendType="none"
|
||||
tooltipType="none"
|
||||
shape={(sector: PieSectorShapeProps) => <SolidSlice sector={sector} hole={0} topColor={EMPTY_TOP} sideColor={EMPTY_SIDE} />}
|
||||
/>
|
||||
) : null}
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="id"
|
||||
isAnimationActive={false}
|
||||
onMouseEnter={follow.onMouseMove}
|
||||
onMouseMove={follow.onMouseMove}
|
||||
shape={(sector: PieSectorShapeProps, index: number) => (
|
||||
<SolidSlice sector={sector} hole={0} topColor={colors[index].top} sideColor={colors[index].side} />
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export type HMSolidDonutChartProps = {
|
||||
data: readonly HMDonutDatum[]
|
||||
/** Caption under the total in the hole. Omit it to hide the center total. */
|
||||
totalLabel?: string
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid donut chart: a thick tilted ring with an outer wall and an inner wall (the back of the hole). The center total sits in
|
||||
* the tilted hole. Same data, order, angles, tooltip and legend as `HMDonutChart`. A total of 0 draws an empty ring.
|
||||
*/
|
||||
export function HMSolidDonutChart({ data, totalLabel, label, className }: HMSolidDonutChartProps) {
|
||||
const follow = useFollowTooltip()
|
||||
const total = data.reduce((sum, item) => sum + Math.max(item.value, 0), 0)
|
||||
const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
|
||||
const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color })))
|
||||
const colors = sliceColors(data)
|
||||
// The center text is drawn once, with the first slice that is actually drawn (a 0 slice has no layer).
|
||||
const centerIndex = data.findIndex(item => item.value > 0)
|
||||
|
||||
const centerOf = (p: Projection) => {
|
||||
if (!totalLabel) return null
|
||||
const holeRx = p.rx * HOLE
|
||||
// The wall hides the top `h` of the hole opening, so the text sits lower; its size follows the free height of the opening.
|
||||
const free = 2 * p.ry * HOLE - p.h
|
||||
const maxSize = Math.max(14, Math.min(24, Math.floor((free * 0.9 - 26) / 0.75)))
|
||||
return (
|
||||
<g pointerEvents="none" data-slot="chart-solid-center">
|
||||
<CenterText cx={p.cx} cy={p.cy} innerRadius={holeRx * 0.92} value={total} caption={totalLabel} maxSize={maxSize} dy={p.h * 0.62} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartContainer config={config} role="group" aria-label={label} data-solid="donut" className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
|
||||
<PieChart accessibilityLayer>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} position={follow.position} />
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
|
||||
{total <= 0 ? (
|
||||
<Pie
|
||||
data={[{ value: 1 }]}
|
||||
dataKey="value"
|
||||
isAnimationActive={false}
|
||||
legendType="none"
|
||||
tooltipType="none"
|
||||
shape={(sector: PieSectorShapeProps) => (
|
||||
<SolidSlice sector={sector} hole={HOLE} topColor={EMPTY_TOP} sideColor={EMPTY_SIDE} center={centerOf} />
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="id"
|
||||
isAnimationActive={false}
|
||||
onMouseEnter={follow.onMouseMove}
|
||||
onMouseMove={follow.onMouseMove}
|
||||
shape={(sector: PieSectorShapeProps, index: number) => (
|
||||
<SolidSlice
|
||||
sector={sector}
|
||||
hole={HOLE}
|
||||
topColor={colors[index].top}
|
||||
sideColor={colors[index].side}
|
||||
center={total > 0 && index === centerIndex ? centerOf : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SVG text cannot ellipsize itself, so labels are measured by estimate (full-width glyphs 1em, others ~0.6em) and shortened here.
|
||||
|
||||
export function textWidth(text: string, fontSize: number) {
|
||||
let width = 0
|
||||
for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? fontSize : fontSize * 0.6
|
||||
return width
|
||||
}
|
||||
|
||||
/** The longest start of `text` plus an ellipsis that fits `maxWidth`; "" when not even one character fits. */
|
||||
export function fitText(text: string, maxWidth: number, fontSize: number) {
|
||||
if (textWidth(text, fontSize) <= maxWidth) return text
|
||||
const chars = [...text]
|
||||
for (let count = chars.length - 1; count > 0; count -= 1) {
|
||||
const candidate = `${chars.slice(0, count).join("").trimEnd()}…`
|
||||
if (textWidth(candidate, fontSize) <= maxWidth) return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
// Side-effect stylesheet imports (chart.css) have no exports.
|
||||
declare module "*.css" {}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { cn } from "cn"
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@hm-design/ui"
|
||||
import { HMBarChart, HMDonutChart, HMHorizontalBarChart, HMLineChart, HMSignedBarChart, HMSolidDonutChart } from "../index"
|
||||
import { sampleBar, sampleDataNotice, sampleDonut, sampleDonutStates, sampleHorizontalBar, sampleLine, sampleLineCompare, sampleLinePointColors, sampleSignedBar } from "./sample-data"
|
||||
|
||||
/** Marks a chart as sample data. Use it next to every chart that is fed with `sample-data`. */
|
||||
export function SampleDataBadge() {
|
||||
return <Badge variant="outline" data-slot="chart-sample-badge" title={sampleDataNotice}>예시 데이터</Badge>
|
||||
}
|
||||
|
||||
export function ExampleCard({ title, description, className, toolbar, children }: { title: string; description?: string; className?: string; toolbar?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
// h-full lets a card fill its grid cell; the notice sits at the card bottom so cards of one row line up.
|
||||
<Card className={cn("h-full", className)} data-chart-example={title}>
|
||||
<CardHeader>
|
||||
<CardTitle className="break-keep">{title}</CardTitle>
|
||||
{description ? <CardDescription className="break-keep">{description}</CardDescription> : null}
|
||||
<CardAction><SampleDataBadge /></CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
{toolbar !== undefined ? <div data-slot="chart-example-toolbar" className="mb-3 flex min-h-[34px] items-center text-sm text-muted-foreground">{toolbar}</div> : null}
|
||||
{children}
|
||||
<p data-slot="chart-sample-notice" className="mt-auto pt-3">{sampleDataNotice}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarChartExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="월별 접수·완료 현황" description="막대 차트 · 상반기 월별 건수" className={className}>
|
||||
<HMBarChart {...sampleBar} label="월별 접수·완료 건수 막대 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function StackedBarChartExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="월별 접수·완료 현황(누적)" description="막대 차트 · stacked" className={className}>
|
||||
<HMBarChart {...sampleBar} stacked label="월별 접수·완료 건수 누적 막대 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function HorizontalBarChartExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="항목별 처리 건수" description="가로 막대 차트 · 항목별 건수" className={className}>
|
||||
<HMHorizontalBarChart {...sampleHorizontalBar} label="항목별 처리 건수 가로 막대 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function SignedBarChartExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="월별 순증감" description="막대 차트 · 0 기준 양수·음수" className={className}>
|
||||
<HMSignedBarChart {...sampleSignedBar} label="월별 순증감 양수·음수 막대 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineChartExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="월별 처리 시간 추이" description="선 차트 · 단위: 시간" className={className}>
|
||||
<HMLineChart {...sampleLine} label="월별 평균 처리 시간 선 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineCurveExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="곡선형" description="monotone · Dot 없음" className={className}>
|
||||
<HMLineChart {...sampleLineCompare} curve="monotone" dots={false} label="월별 접수·완료 곡선형 선 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineCurveDotLabelExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="곡선형 · Dot + Label" description="monotone · Dot과 값 Label" className={className}>
|
||||
<HMLineChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 라벨 선 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineStraightExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="직선형" description="linear · Dot 없음" className={className}>
|
||||
<HMLineChart {...sampleLineCompare} curve="linear" dots={false} label="월별 접수·완료 직선형 선 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineStraightColorLabelExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="직선형 · Color Dot" description="linear · Dot 개별 색상 + 값 Label" className={className}>
|
||||
<HMLineChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 선 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineStepExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="계단형" description="step · Dot 없음" className={className}>
|
||||
<HMLineChart {...sampleLineCompare} curve="step" dots={false} label="월별 접수·완료 계단형 선 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function LineStepDotLabelExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="계단형 · Dot + Label" description="step · Dot과 값 Label" className={className}>
|
||||
<HMLineChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 라벨 선 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DonutChartExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="업무 상태 비중" description="도넛 차트 · 상태별 건수" className={className}>
|
||||
<HMDonutChart {...sampleDonut} label="업무 상태별 비중 도넛 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bar, line and donut examples: two per row from 1024px (HM desktop boundary), one per row below. With an odd count the last card keeps one column's width. */
|
||||
export function ChartExamples() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2" data-chart-examples="">
|
||||
<BarChartExample className="min-w-0" />
|
||||
<LineChartExample className="min-w-0" />
|
||||
<DonutChartExample className="min-w-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Four donut states on one data set: plain, center total, active (fixed selection) and interactive (Select + slice click).
|
||||
// The toolbar row has the Select height in every card, so the donuts of a row start at the same height.
|
||||
export function DonutPlainExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="기본형" description="도넛 차트 · 기본" className={className} toolbar="중앙 표시 없음">
|
||||
<HMDonutChart data={sampleDonutStates.data} label="업무 상태 도넛 차트, 가운데 값 없음(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DonutCenterExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="기본형 + 텍스트" description="도넛 차트 · 전체 합계" className={className} toolbar="중앙 표시: 전체 합계">
|
||||
<HMDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 도넛 차트, 가운데 전체 합계(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DonutSolidExample({ className }: { className?: string }) {
|
||||
return (
|
||||
<ExampleCard title="입체형" className={className}>
|
||||
<HMSolidDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 입체형 도넛 차트, 가운데 전체 합계(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DonutActiveExample({ className }: { className?: string }) {
|
||||
const selected = sampleDonutStates.data.find(item => item.id === sampleDonutStates.activeId)
|
||||
return (
|
||||
<ExampleCard title="Active 상태" description="도넛 차트 · 선택된 항목" className={className} toolbar={`선택 항목: ${selected?.label ?? ""}`}>
|
||||
<HMDonutChart data={sampleDonutStates.data} activeId={sampleDonutStates.activeId} label="업무 상태 도넛 차트, 선택된 항목 강조(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DonutInteractiveExample({ className }: { className?: string }) {
|
||||
const [activeId, setActiveId] = useState(sampleDonutStates.activeId)
|
||||
return (
|
||||
<ExampleCard
|
||||
title="Interactive"
|
||||
description="도넛 차트 · 선택 또는 클릭"
|
||||
className={className}
|
||||
toolbar={
|
||||
<Select value={activeId} onValueChange={setActiveId}>
|
||||
<SelectTrigger size="medium" aria-label="강조할 업무 상태 선택" className="w-full max-w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sampleDonutStates.data.map(item => (
|
||||
<SelectItem key={item.id} value={item.id}>{item.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
>
|
||||
<HMDonutChart
|
||||
data={sampleDonutStates.data}
|
||||
activeId={activeId}
|
||||
onActiveChange={setActiveId}
|
||||
label="업무 상태 도넛 차트, 항목을 선택하거나 조각을 클릭해 강조(예시 데이터)"
|
||||
/>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Sample data and ready-made example cards. Everything here is illustrative, not business data.
|
||||
export { sampleBar, sampleLine, sampleDonut, sampleDonutStates, sampleHorizontalBar, sampleSignedBar, sampleLineCompare, sampleLinePointColors, sampleDataNotice } from "./sample-data"
|
||||
export { BarChartExample, StackedBarChartExample, HorizontalBarChartExample, SignedBarChartExample, LineChartExample, LineCurveExample, LineCurveDotLabelExample, LineStraightExample, LineStraightColorLabelExample, LineStepExample, LineStepDotLabelExample, DonutChartExample, DonutPlainExample, DonutCenterExample, DonutSolidExample, DonutActiveExample, DonutInteractiveExample, ChartExamples, SampleDataBadge } from "./chart-examples"
|
||||
export { samplePie, sampleStackedPie, sampleRadar } from "./sample-data"
|
||||
export {
|
||||
PiePlainExample,
|
||||
PieLabelExample,
|
||||
PieLabelListExample,
|
||||
PieStackedExample,
|
||||
PieSolidExample,
|
||||
RadarPlainExample,
|
||||
RadarDotExample,
|
||||
RadarLinesOnlyExample,
|
||||
RadarCustomLabelExample,
|
||||
AreaCurveExample,
|
||||
AreaCurveDotLabelExample,
|
||||
AreaStraightExample,
|
||||
AreaStraightColorLabelExample,
|
||||
AreaStepExample,
|
||||
AreaStepDotLabelExample,
|
||||
AreaStackedExample,
|
||||
AreaExpandedExample,
|
||||
} from "./pie-radar-area-examples"
|
||||
export { sampleRadial, sampleRadialText, sampleRadialShape, sampleRadialStacked } from "./sample-data"
|
||||
export {
|
||||
RadialSimpleExample,
|
||||
RadialLabelExample,
|
||||
RadialGridExample,
|
||||
RadialTextExample,
|
||||
RadialShapeExample,
|
||||
RadialStackedExample,
|
||||
} from "./radial-examples"
|
||||
@@ -0,0 +1,129 @@
|
||||
import { HMAreaChart, HMPieChart, HMRadarChart, HMSolidPieChart, HMStackedPieChart } from "../index"
|
||||
import { ExampleCard } from "./chart-examples"
|
||||
import { sampleLineCompare, sampleLinePointColors, samplePie, sampleRadar, sampleStackedPie } from "./sample-data"
|
||||
|
||||
// Pie, Radar and Area examples on sample data. Titles follow the Line and Donut examples.
|
||||
type Props = { className?: string }
|
||||
|
||||
export function PiePlainExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="기본 Pie" description="파이 차트 · 기본" className={className}>
|
||||
<HMPieChart {...samplePie} label="업무 상태 파이 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function PieLabelExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Pie + Label" description="파이 차트 · 이름과 값" className={className}>
|
||||
<HMPieChart {...samplePie} labels="outside" label="업무 상태 파이 차트, 조각 바깥 이름·값 Label(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function PieLabelListExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Pie + Label List" description="파이 차트 · 조각 안 값" className={className}>
|
||||
<HMPieChart {...samplePie} labels="inside" label="업무 상태 파이 차트, 조각 안 값 Label(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function PieStackedExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Pie + Stacked" description="파이 차트 · 두 시리즈 링" className={className}>
|
||||
<HMStackedPieChart {...sampleStackedPie} label="분기별 업무 상태 Stacked 파이 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function PieSolidExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="입체형" className={className}>
|
||||
<HMSolidPieChart {...samplePie} label="업무 상태 입체형 파이 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function RadarPlainExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radar" description="방사형 차트 · 영역" className={className}>
|
||||
<HMRadarChart {...sampleRadar} label="단계별 처리 수준 방사형 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadarDotExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radar + Dot" description="방사형 차트 · 축별 Dot" className={className}>
|
||||
<HMRadarChart {...sampleRadar} dots label="단계별 처리 수준 방사형 차트, Dot(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadarLinesOnlyExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radar + Lines Only" description="방사형 차트 · 선만" className={className}>
|
||||
<HMRadarChart {...sampleRadar} filled={false} label="단계별 처리 수준 방사형 차트, 선만(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadarCustomLabelExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radar + Custom Label" description="방사형 차트 · 축 Label" className={className}>
|
||||
<HMRadarChart {...sampleRadar} customLabels label="단계별 처리 수준 방사형 차트, 축 Label(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function AreaCurveExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="곡선형" description="영역 차트 · monotone" className={className}>
|
||||
<HMAreaChart {...sampleLineCompare} curve="monotone" label="월별 접수·완료 곡선형 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function AreaCurveDotLabelExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="곡선형 · Dot + Label" description="monotone · Dot과 값 Label" className={className}>
|
||||
<HMAreaChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 Label 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function AreaStraightExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="직선형" description="영역 차트 · linear" className={className}>
|
||||
<HMAreaChart {...sampleLineCompare} curve="linear" label="월별 접수·완료 직선형 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function AreaStraightColorLabelExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="직선형 · Color Dot" description="linear · Dot 개별 색상 + 값 Label" className={className}>
|
||||
<HMAreaChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function AreaStepExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="계단형" description="영역 차트 · step" className={className}>
|
||||
<HMAreaChart {...sampleLineCompare} curve="step" label="월별 접수·완료 계단형 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function AreaStepDotLabelExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="계단형 · Dot + Label" description="step · Dot과 값 Label" className={className}>
|
||||
<HMAreaChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 Label 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function AreaStackedExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Stacked" description="영역 차트 · 값 누적" className={className}>
|
||||
<HMAreaChart {...sampleLineCompare} stacked label="월별 접수·완료 Stacked 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function AreaExpandedExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Expanded" description="영역 차트 · 합계 100%" className={className}>
|
||||
<HMAreaChart {...sampleLineCompare} expanded label="월별 접수·완료 Expanded 영역 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HMRadialBarChart, HMRadialMeterChart, HMRadialStackedChart } from "../index"
|
||||
import { ExampleCard } from "./chart-examples"
|
||||
import { sampleRadial, sampleRadialShape, sampleRadialStacked, sampleRadialText } from "./sample-data"
|
||||
|
||||
// Radial examples on sample data, one per official shadcn/ui radial example.
|
||||
type Props = { className?: string }
|
||||
|
||||
export function RadialSimpleExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radial Chart" description="radial-simple · 항목별 막대" className={className}>
|
||||
<HMRadialBarChart {...sampleRadial} label="업무 상태 방사형 막대 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadialLabelExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radial Chart + Label" description="radial-label · 막대 안 항목명" className={className}>
|
||||
<HMRadialBarChart {...sampleRadial} labels startAngle={-90} endAngle={380} label="업무 상태 방사형 막대 차트, 항목명 Label(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadialGridExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radial Chart - Grid" description="radial-grid · 원형 Grid" className={className}>
|
||||
<HMRadialBarChart {...sampleRadial} grid label="업무 상태 방사형 막대 차트, Grid(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadialTextExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radial Chart + Text" description="radial-text · 중앙 Text" className={className}>
|
||||
<HMRadialMeterChart {...sampleRadialText} startAngle={0} endAngle={250} innerRadius={75} outerRadius={90} trackRadii={[88.5, 76.5]} cornerRadius={10} label="진행 중 건수 방사형 차트, 중앙 Text(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadialShapeExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radial Chart - Shape" description="radial-shape · 링 트랙" className={className}>
|
||||
<HMRadialMeterChart {...sampleRadialShape} startAngle={0} endAngle={100} innerRadius={50} outerRadius={95} trackRadii={[90.5, 54.5]} label="진행 중 건수 방사형 차트, Shape(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
export function RadialStackedExample({ className }: Props) {
|
||||
return (
|
||||
<ExampleCard title="Radial Chart + Stacked" description="radial-stacked · 반원 누적" className={className}>
|
||||
<HMRadialStackedChart {...sampleRadialStacked} label="업무 상태 방사형 Stacked 차트(예시 데이터)" />
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { chartColors, type ChartSeries, type HMDonutDatum, type HMPieRing } from "../index"
|
||||
|
||||
// SAMPLE DATA ONLY. These values are invented to show the charts; they are not business data and must not be reported as such.
|
||||
// Real data belongs to the consuming app and is passed to HMBarChart / HMLineChart / HMDonutChart as props.
|
||||
export const sampleDataNotice = "예시 데이터 · 실제 업무 데이터가 아닙니다"
|
||||
|
||||
export const sampleBar = {
|
||||
categoryKey: "month",
|
||||
series: [
|
||||
{ key: "received", label: "접수" },
|
||||
{ key: "completed", label: "완료" },
|
||||
] satisfies ChartSeries[],
|
||||
data: [
|
||||
{ month: "1월", received: 186, completed: 142 },
|
||||
{ month: "2월", received: 305, completed: 238 },
|
||||
{ month: "3월", received: 237, completed: 221 },
|
||||
{ month: "4월", received: 173, completed: 190 },
|
||||
{ month: "5월", received: 209, completed: 196 },
|
||||
{ month: "6월", received: 264, completed: 231 },
|
||||
],
|
||||
}
|
||||
|
||||
export const sampleLine = {
|
||||
categoryKey: "month",
|
||||
series: [
|
||||
{ key: "average", label: "평균 처리 시간" },
|
||||
{ key: "target", label: "목표" },
|
||||
] satisfies ChartSeries[],
|
||||
data: [
|
||||
{ month: "1월", average: 4.8, target: 4 },
|
||||
{ month: "2월", average: 4.4, target: 4 },
|
||||
{ month: "3월", average: 4.1, target: 4 },
|
||||
{ month: "4월", average: 3.7, target: 4 },
|
||||
{ month: "5월", average: 3.9, target: 4 },
|
||||
{ month: "6월", average: 3.4, target: 4 },
|
||||
],
|
||||
}
|
||||
|
||||
export const sampleDonut = {
|
||||
totalLabel: "전체(건)",
|
||||
data: [
|
||||
{ id: "done", label: "완료", value: 118 },
|
||||
{ id: "inProgress", label: "진행 중", value: 42 },
|
||||
{ id: "delayed", label: "지연", value: 12 },
|
||||
{ id: "onHold", label: "보류", value: 8 },
|
||||
] satisfies HMDonutDatum[],
|
||||
}
|
||||
|
||||
// Item names are deliberately uneven: one very long name, short bars and a zero value exercise the label rules.
|
||||
export const sampleHorizontalBar = {
|
||||
categoryKey: "item",
|
||||
valueKey: "count",
|
||||
valueLabel: "처리 건수",
|
||||
data: [
|
||||
{ item: "종합 민원 접수 및 처리 현황 보고서 작성", count: 186 },
|
||||
{ item: "시설 점검", count: 142 },
|
||||
{ item: "예산 집행 검토", count: 96 },
|
||||
{ item: "계약 변경 승인 요청 건", count: 38 },
|
||||
{ item: "보류 항목", count: 3 },
|
||||
{ item: "반려", count: 0 },
|
||||
],
|
||||
}
|
||||
|
||||
// Mixed signs with a zero value. A positive-only or negative-only list works the same way.
|
||||
export const sampleSignedBar = {
|
||||
categoryKey: "month",
|
||||
valueKey: "change",
|
||||
valueLabel: "순증감",
|
||||
data: [
|
||||
{ month: "1월", change: 120 },
|
||||
{ month: "2월", change: -80 },
|
||||
{ month: "3월", change: 45 },
|
||||
{ month: "4월", change: -150 },
|
||||
{ month: "5월", change: 0 },
|
||||
{ month: "6월", change: 95 },
|
||||
],
|
||||
}
|
||||
|
||||
// One data set for all line examples (curve, straight, dots, per-point colors, step), so the connection styles compare directly on the same axis range.
|
||||
// It is built to exercise the label rules: 3월 is a tie (40 = 40), 2월 and 4월 are close, the lines cross between 5월 and 6월, and 완료 drops to 0 in 5월.
|
||||
const lineCompareRows = [
|
||||
{ month: "1월", received: 48, completed: 30 },
|
||||
{ month: "2월", received: 72, completed: 60 },
|
||||
{ month: "3월", received: 40, completed: 40 },
|
||||
{ month: "4월", received: 85, completed: 70 },
|
||||
{ month: "5월", received: 20, completed: 0 },
|
||||
{ month: "6월", received: 56, completed: 62 },
|
||||
]
|
||||
export const sampleLineCompare = {
|
||||
categoryKey: "month",
|
||||
series: [
|
||||
{ key: "received", label: "접수" },
|
||||
{ key: "completed", label: "완료" },
|
||||
] satisfies ChartSeries[],
|
||||
data: lineCompareRows,
|
||||
}
|
||||
|
||||
// Per-point colors are HM palette tokens by value, one family per series: 접수 in greens, 완료 in browns, and the orange token below 30
|
||||
// (all dark enough for the value label to read on the card).
|
||||
const receivedColor = (value: number) =>
|
||||
value >= 60 ? "var(--hm-sem-palette-green-light-1)"
|
||||
: value >= 30 ? "var(--hm-sem-palette-green-tone-1)"
|
||||
: "var(--hm-sem-palette-orange-default)"
|
||||
const completedColor = (value: number) =>
|
||||
value >= 60 ? "var(--hm-sem-palette-brown-light-1)"
|
||||
: value >= 30 ? "var(--hm-sem-palette-brown-light-2)"
|
||||
: "var(--hm-sem-palette-orange-default)"
|
||||
export const sampleLinePointColors = {
|
||||
...sampleLineCompare,
|
||||
pointColorKey: { received: "receivedColor", completed: "completedColor" },
|
||||
data: lineCompareRows.map(row => ({
|
||||
...row,
|
||||
receivedColor: receivedColor(row.received),
|
||||
completedColor: completedColor(row.completed),
|
||||
})),
|
||||
}
|
||||
|
||||
// One data set for the four donut states (plain, center total, active, interactive), so they compare directly.
|
||||
// It includes a long name (shortened in the center, complete in the legend and tooltip) and a zero item.
|
||||
export const sampleDonutStates = {
|
||||
totalLabel: "전체(건)",
|
||||
/** The selected slice of the active and interactive examples. */
|
||||
activeId: "inProgress",
|
||||
data: [
|
||||
{ id: "done", label: "완료", value: 118 },
|
||||
{ id: "inProgress", label: "진행 중", value: 42 },
|
||||
{ id: "review", label: "관계 기관 협의 및 보완 요청 대기", value: 24 },
|
||||
{ id: "delayed", label: "지연", value: 12 },
|
||||
{ id: "rejected", label: "반려", value: 0 },
|
||||
] satisfies HMDonutDatum[],
|
||||
}
|
||||
|
||||
// Pie examples. Items keep one color by id (the order of this list), in every pie and in both rings of the stacked pie.
|
||||
// It includes a long name (omitted or shortened where it does not fit) and a very small slice.
|
||||
const pieMaster = [
|
||||
{ id: "done", label: "완료" },
|
||||
{ id: "inProgress", label: "진행 중" },
|
||||
{ id: "review", label: "관계 기관 협의 및 보완 요청 대기" },
|
||||
{ id: "delayed", label: "지연" },
|
||||
{ id: "onHold", label: "보류" },
|
||||
]
|
||||
const pieItem = (id: string, value: number): HMDonutDatum => {
|
||||
const index = pieMaster.findIndex(item => item.id === id)
|
||||
return { id, label: pieMaster[index].label, value, color: chartColors[index % chartColors.length] }
|
||||
}
|
||||
export const samplePie = {
|
||||
data: [pieItem("done", 118), pieItem("inProgress", 42), pieItem("review", 24), pieItem("delayed", 12), pieItem("onHold", 3)],
|
||||
}
|
||||
export const sampleStackedPie = {
|
||||
rings: [
|
||||
{ id: "q1", label: "1분기", data: [pieItem("done", 80), pieItem("inProgress", 30), pieItem("delayed", 8)] },
|
||||
{ id: "q2", label: "2분기", data: [pieItem("done", 118), pieItem("inProgress", 42), pieItem("review", 24), pieItem("delayed", 12)] },
|
||||
] satisfies [HMPieRing, HMPieRing],
|
||||
}
|
||||
|
||||
// Radar examples: six axes, two series on one 0-100 range. The first axis (top) has a long name to exercise the custom label.
|
||||
export const sampleRadar = {
|
||||
categoryKey: "area",
|
||||
maxValue: 100,
|
||||
series: [
|
||||
{ key: "current", label: "이번 분기" },
|
||||
{ key: "previous", label: "지난 분기" },
|
||||
] satisfies ChartSeries[],
|
||||
data: [
|
||||
{ area: "관계 기관 협의 및 보완 요청", current: 62, previous: 48 },
|
||||
{ area: "접수", current: 88, previous: 72 },
|
||||
{ area: "검토", current: 70, previous: 82 },
|
||||
{ area: "승인", current: 55, previous: 60 },
|
||||
{ area: "배정", current: 92, previous: 65 },
|
||||
{ area: "완료", current: 76, previous: 58 },
|
||||
],
|
||||
}
|
||||
|
||||
// Radial examples. Same items and color mapping as the pies (by id). Values are on the scale of the official examples so the arcs read
|
||||
// the same way: five bars (simple, label, grid), one value (text: 200, shape: 1260) and one stacked row (570 + 1260).
|
||||
const radialItem = (id: string, value: number) => pieItem(id, value)
|
||||
export const sampleRadial = {
|
||||
data: [radialItem("done", 275), radialItem("inProgress", 200), radialItem("review", 187), radialItem("delayed", 173), radialItem("onHold", 90)],
|
||||
}
|
||||
export const sampleRadialText = { item: radialItem("inProgress", 200), caption: "진행 중 건수" }
|
||||
export const sampleRadialShape = { item: radialItem("inProgress", 1260), caption: "진행 중 건수" }
|
||||
export const sampleRadialStacked = {
|
||||
series: [
|
||||
{ key: "inProgress", label: "진행 중", color: chartColors[1] },
|
||||
{ key: "done", label: "완료", color: chartColors[0] },
|
||||
] satisfies ChartSeries[],
|
||||
row: { inProgress: 570, done: 1260 },
|
||||
totalLabel: "전체(건)",
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import "./chart.css"
|
||||
|
||||
// shadcn/ui Chart primitives (official source) for custom Recharts compositions.
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
} from "./components/chart"
|
||||
export type { ChartConfig } from "./components/chart"
|
||||
|
||||
// Ready-made HM charts. Sample data lives in the separate "@hm-design/chart/examples" entry.
|
||||
export { HMBarChart } from "./components/bar-chart"
|
||||
export type { HMBarChartProps } from "./components/bar-chart"
|
||||
export { HMHorizontalBarChart } from "./components/horizontal-bar-chart"
|
||||
export type { HMHorizontalBarChartProps } from "./components/horizontal-bar-chart"
|
||||
export { HMSignedBarChart } from "./components/signed-bar-chart"
|
||||
export type { HMSignedBarChartProps } from "./components/signed-bar-chart"
|
||||
export { HMLineChart } from "./components/line-chart"
|
||||
export type { HMLineChartProps } from "./components/line-chart"
|
||||
export { HMDonutChart } from "./components/donut-chart"
|
||||
export type { HMDonutChartProps, HMDonutDatum } from "./components/donut-chart"
|
||||
export { chartColors } from "./components/series"
|
||||
export type { ChartSeries } from "./components/series"
|
||||
|
||||
export { HMPieChart, HMStackedPieChart } from "./components/pie-chart"
|
||||
export type { HMPieChartProps, HMPieRing, HMStackedPieChartProps } from "./components/pie-chart"
|
||||
export { HMSolidPieChart, HMSolidDonutChart } from "./components/solid-chart"
|
||||
export type { HMSolidPieChartProps, HMSolidDonutChartProps } from "./components/solid-chart"
|
||||
export { HMRadarChart } from "./components/radar-chart"
|
||||
export type { HMRadarChartProps } from "./components/radar-chart"
|
||||
export { HMAreaChart } from "./components/area-chart"
|
||||
export type { HMAreaChartProps } from "./components/area-chart"
|
||||
|
||||
export { HMRadialBarChart, HMRadialMeterChart, HMRadialStackedChart } from "./components/radial-chart"
|
||||
export type { HMRadialBarChartProps, HMRadialMeterChartProps, HMRadialStackedChartProps } from "./components/radial-chart"
|
||||
@@ -0,0 +1,3 @@
|
||||
/* Lets the consumer's Tailwind scan the utility classes of the shadcn Chart markup in this package.
|
||||
Import it after "tailwindcss" and "@hm-design/ui/tailwind.css". The dist copy scans its own folder. */
|
||||
@source "./";
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["react", "react-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 `@hm-design/ui`·`@hm-design/sw`·`@hm-design/center-graphic`·`@hm-design/chart`의 peer이고, `@hm-design/org-chart-react`는 React `^19`만 peer로 요구합니다. `@hm-design/org-chart`(Core)는 peer가 없습니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 `@hm-design/ui`·`@hm-design/sw`·`@hm-design/center-graphic`·`@hm-design/chart`의 peer이고, `@hm-design/org-chart-react`는 React `^19`만 peer로 요구합니다. `@hm-design/org-chart`(Core)는 peer가 없습니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|---|---|
|
||||
| Auto(Expanded)·Chart Only 캔버스 | 흰색 `#FFFFFF` |
|
||||
| Fixed readOnly / Full Composition 배경 | `#F5F8FB`(`bg-content` 기본) |
|
||||
| 회사 메인 컬러(Auto 그룹 제목·카드 강조) | `hanmac` 한맥기술 `#CB2E28`, `saman` 삼안 `#F08300`, `jangheon-industry` 장헌산업 `#4580A6`, `jangheon` 장헌 `#1D418D`, `ptc` 피티씨 `#84338E`, `halla` 한라산업개발 `#0068B7`, `baron` 바론컨설턴트 `#0C367F` |
|
||||
| 회사 메인 컬러(Auto 그룹 제목·카드 강조) | `hanmac` 한맥기술 `#CB2E28`, `saman` 삼안 `#F08300`, `jangheon-industry` 장헌산업 `#4580A6`, `jangheon` (주)장헌 `#1D418D`, `ptc` 피티씨 `#84338E`, `halla` 한라산업개발 `#0068B7`, `baron` 바론컨설턴트 `#0C367F` |
|
||||
| 그룹 제목 글자 | 흰색 |
|
||||
| Auto 연결선 | `#738399`(`expanded-line-color` 기본) |
|
||||
| Fixed 부서 Gradient | 6종(`business-management`, `business-planning`, `talent-growth`, `integrated-system`, `design-planning`, `solution-sales`). 회사색과 별개 — 서로 대체하지 않음 |
|
||||
|
||||
@@ -8,7 +8,7 @@ const expected: ReadonlyArray<CompanyColorPreset & { readonly companyId: Company
|
||||
{ companyId: 'hanmac', companyName: '한맥기술', primary: '#CB2E28', secondary: ['#1D4289'] },
|
||||
{ companyId: 'saman', companyName: '삼안', primary: '#F08300', secondary: ['#6D6C6C'] },
|
||||
{ companyId: 'jangheon-industry', companyName: '장헌산업', primary: '#4580A6', secondary: ['#92BED4'] },
|
||||
{ companyId: 'jangheon', companyName: '장헌', primary: '#1D418D', secondary: ['#AE232A'] },
|
||||
{ companyId: 'jangheon', companyName: '(주)장헌', primary: '#1D418D', secondary: ['#AE232A'] },
|
||||
{ companyId: 'ptc', companyName: '피티씨', primary: '#84338E', secondary: ['#F7AF00', '#0066B5'] },
|
||||
{ companyId: 'halla', companyName: '한라산업개발', primary: '#0068B7', secondary: ['#231815'] },
|
||||
{ companyId: 'baron', companyName: '바론컨설턴트', primary: '#0C367F', secondary: [] },
|
||||
|
||||
@@ -27,7 +27,7 @@ export const companyColorPresets = Object.freeze({
|
||||
primary: '#4580A6', secondary: ['#92BED4'],
|
||||
}),
|
||||
jangheon: freezePreset({
|
||||
companyId: 'jangheon', companyName: '장헌',
|
||||
companyId: 'jangheon', companyName: '(주)장헌',
|
||||
primary: '#1D418D', secondary: ['#AE232A'],
|
||||
}),
|
||||
ptc: freezePreset({
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 `@hm-design/ui`·`@hm-design/sw`·`@hm-design/center-graphic`·`@hm-design/chart`의 peer이고, `@hm-design/org-chart-react`는 React `^19`만 peer로 요구합니다. `@hm-design/org-chart`(Core)는 peer가 없습니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ import "@hm-design/sw/styles.css" // 앱 진입점에서 한 번
|
||||
|
||||
### 3.1 테마(토큰) 설정 — 공식 방법 (2026-10-20)
|
||||
|
||||
SW 토큰(`--hm-sw-header-*`, `--hm-sw-property-*`, `--hm-sw-viewer-*`, `--hm-sw-footer-*`)의 기본값은 **가장 바깥 SW 범위**(`<SWTheme>`, 또는 단독으로 쓴 `SWHeader`·`SWViewerHeader`·`SWViewerPanel`·`SWViewerMenu`·`SWFooter`의 루트)에서 한 번만 선언됩니다. 그 안의 SW 요소 — Header의 Dropdown·More, `SWViewerMenu`, `SWViewerPanel`과 안쪽 컨트롤, `SWFooterCommandLine`, Tooltip·안내 — 는 자기 루트에 `hm-sw-theme`이 있어도 값을 다시 정하지 않고 **상속**합니다.
|
||||
SW 토큰(`--hm-sw-header-*`, `--hm-sw-property-*`, `--hm-sw-popup-*`, `--hm-sw-viewer-*`, `--hm-sw-footer-*`)의 기본값은 **가장 바깥 SW 범위**(`<SWTheme>`, 또는 단독으로 쓴 `SWHeader`·`SWViewerHeader`·`SWViewerPanel`·`SWViewerMenu`·`SWFooter`의 루트)에서 한 번만 선언됩니다. 그 안의 SW 요소 — Header의 Dropdown·More, `SWViewerMenu`, `SWViewerPanel`과 안쪽 컨트롤, `SWFooterCommandLine`, Tooltip·안내 — 는 자기 루트에 `hm-sw-theme`이 있어도 값을 다시 정하지 않고 **상속**합니다.
|
||||
|
||||
1. 화면 전체(Header·Panel·Menu가 같은 값)를 바꾸려면 화면을 `<SWTheme style={theme}>` 하나로 감싸고 거기서만 토큰을 정합니다. 프로그램 class(`<SWTheme className="app-sw-theme">` + `.app-sw-theme { --hm-sw-header-font-family: … }`)도 됩니다.
|
||||
```tsx
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
- 목록 최대 높이 240px(8행), 넘치면 내부 스크롤. 색상 팔레트 118px.
|
||||
- 속성창·K 가이드는 `position: fixed` Overlay(뷰포트 안 보정, Portal·Dim 없음).
|
||||
- 한 번에 하나의 Dropdown(SWHeader). 단독 사용 시 각 컴포넌트가 바깥 클릭으로 스스로 닫힙니다.
|
||||
- More(`...`) 메뉴 색(2026-11-10 개별 토큰, 값 변경 없음): 면 `--hm-sw-header-more-menu-background`(`#0E3D2F`), 그림자 `--hm-sw-header-more-menu-shadow`(`0 4px 12px rgba(0,0,0,.5)`), 테두리 `--hm-sw-header-more-menu-border-color`(기본 `var(--hm-sw-header-object-border-color)`), 항목 Hover `--hm-sw-header-more-item-hover-background`(기본 `var(--hm-sw-header-hover)`). 뷰어 Header의 Dropdown·로고 메뉴도 같은 토큰을 읽습니다([VIEWER.md](./VIEWER.md)).
|
||||
|
||||
## 6. Motion
|
||||
|
||||
@@ -102,11 +103,13 @@
|
||||
| 항목 | 값 | 토큰 |
|
||||
|---|---|---|
|
||||
| 폭 / 높이 | 314px / 콘텐츠 높이(최대 화면 − 8px), 넘치면 본문 세로 스크롤 | — |
|
||||
| 표면 / 테두리 / radius | `#E4DBC9` / 1px `#A67D03` / 4px, 안쪽 1px 검정 그림자 | `--hm-sw-property-surface`, `--hm-sw-property-border` |
|
||||
| 제목줄 | 30px, Figma 제목 아트, 제목 14px 700 `#FFC600` + 검정 외곽선, 닫기 44 × 30(Hover 자산) | `--hm-sw-header-selected-color` |
|
||||
| 표면 / 테두리 / radius | `#E4DBC9` / 1px `#A67D03` / 4px, 안쪽 1px 검정 그림자 | `--hm-sw-property-surface`, `--hm-sw-property-border`, `--hm-sw-popup-frame-shadow` |
|
||||
| 제목줄 | 30px, Figma 제목 아트, 면 = 파일명 면 gradient, 제목 14px 700 `#FFC600` + 검정 외곽선, 닫기 44 × 30(Hover 자산) | `--hm-sw-popup-titlebar-background`(기본 `var(--hm-sw-header-document-background)`), `--hm-sw-popup-title-color`(기본 `var(--hm-sw-header-selected-color)`), `--hm-sw-popup-title-outline`(기본 `var(--hm-sw-header-label-outline)`) |
|
||||
| 본문 | padding 7px, 그룹 사이 8px | — |
|
||||
| 본문 스크롤바 | `thin`, Thumb `#9A9284` / Track `#E0DACE`(DWG/DXF Viewer 패널과 동일). 본문과 그 안의 스크롤 영역에 적용, 페이지 스크롤바는 그대로 | `--hm-sw-popup-scrollbar-thumb`, `--hm-sw-popup-scrollbar-track` |
|
||||
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px | `--hm-sw-property-content` |
|
||||
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px, 안쪽 입체 그림자 | `--hm-sw-property-content`, `--hm-sw-popup-group-heading-color`, `--hm-sw-popup-group-border-color`, `--hm-sw-popup-group-shadow` |
|
||||
| 구분선 | 개수 목록 아래선 검정 50%, 위치 표 선 검정 40% | `--hm-sw-popup-divider-color`, `--hm-sw-popup-table-line-color` |
|
||||
| Switch 그림자 | Off·On 안쪽 그림자 + 테두리 링 + 1px `#FCF0D8` 하이라이트, Thumb `0 .5px 1px rgba(0,0,0,.6)` | `--hm-sw-switch-off-shadow`, `--hm-sw-switch-on-shadow`, `--hm-sw-switch-thumb-shadow` |
|
||||
| 속성 행(`SWPopupPropertyRow`) | 속성창 행 그대로(라벨 72px 700, 값 상자 28px, `picker` 필드). 데이터 = `SWHeaderProperty` + `menus` | `--hm-sw-property-*`, `--hm-sw-property-field-*` |
|
||||
| 행(`SWPopupRow`) | 최소 32px, 라벨 72px 700 검정, 값 글자 `#E60403` 500, 단위 12px | `--hm-sw-property-label`, `--hm-sw-header-input-color` |
|
||||
| 값 상자 | 28px, Hover 1px `rgba(33,29,19,.4)`, 숫자 편집 On `#0F0D0A`(단위 흰색), Dropdown On `#211D13` | `--hm-sw-property-field-*` |
|
||||
@@ -139,7 +142,7 @@
|
||||
5. 값은 Props, 동작은 Callback. CAD 데이터를 내부에서 바꾸지 않는다.
|
||||
6. `hm-sw-*` class를 새로 쓰거나 덮어쓰지 않는다(자기 접두사 사용, 예: `app-`).
|
||||
7. 버튼·탭·토글이면 먼저 `SWToolButton`의 세 모양(§9)으로 해결되는지 본다. 팝업·설정창이면 `SWPopup` + `SWPopupSection` / `SWPopupRow`(§8).
|
||||
8. 팝업 안의 새 콘텐츠는 §8의 Popup 토큰(`--hm-sw-property-*`)만 읽는다. Header용 어두운 토큰을 밝은 Popup 면에 섞지 않는다.
|
||||
8. 팝업 안의 새 콘텐츠는 §8의 Popup 토큰(`--hm-sw-property-*`, `--hm-sw-popup-*`)만 읽는다. Header용 어두운 토큰을 밝은 Popup 면에 섞지 않는다. Popup 제목줄 토큰의 기본값은 Header 토큰을 따르므로 Header 토큰을 바꾼 테마는 그대로 적용되고, Popup만 바꿀 때는 `--hm-sw-popup-*`를 지정한다(2026-11-10).
|
||||
|
||||
## 11. 뷰어 모드 토큰·상태 요약 (`SWViewer*` 전용)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
- **반응형(2026-10-21, 실제 가용 폭 기준 3단계):** 고정 Breakpoint를 쓰지 않고 Header 행의 실제 남는 폭(로고·파일명·메뉴·Gap·Padding·More 버튼 포함)을 측정합니다. 파일명 면은 글자 수가 아니라 Header 폭으로 정한 기본 폭으로 계산하므로, 같은 폭에서는 짧은 파일명과 긴 파일명의 단계·파일 열기 버튼·파일명 면 너비가 같고 긴 이름은 `…`입니다(2026-10-21). ① 모두 들어가면 기본 형태(아이콘+텍스트), ② 부족하면 `icon`과 `iconOnlyBelow`가 있는 항목을 모두 아이콘만(라벨은 Tooltip·접근성 이름), ③ 그래도 부족하면 `collapseBelow` 항목을 하나씩 More(⋯, 베이지 260px Dropdown)로 옮깁니다(값이 큰 것부터, 같으면 오른쪽 먼저). ③의 판단(2026-10-26)은 tabs를 최소 폭(`--hm-sw-viewer-tabs-min-width`)으로 세므로, tabs가 `…`로 좁아져서 들어가면 More로 옮기지 않습니다. 파일명 면 기본 폭은 `clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px)`(1280px 이상은 이전과 같음), Header 560px 미만에서는 56px입니다. 넓어지면 More → 아이콘 → 기본 순서로 복귀하고, 상단과 More에 같은 항목이 동시에 나오지 않습니다. `collapseBelow`·`iconOnlyBelow`의 숫자는 px Breakpoint가 아니며 순서만 정합니다(`iconOnlyBelow`는 지정 여부만, `collapseBelow`는 값이 큰 것부터; Props 형식 동일). 로고 옆 파일 열기 버튼(`onDocumentOpen`)은 ② 단계에서 아이콘만으로 줄고 More로 옮기지 않으며, 샘플은 `documents`. 레이어·설정·속성은 `collapseBelow` 없이 아이콘까지만. 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김(CSS container)은 그대로이고, 560px 미만에서는 tabs 개수 숨김·switch `shortLabel` 캡션·switch 좌우 여백 1px도 적용됩니다(2026-10-26). 가로 스크롤은 만들지 않습니다.
|
||||
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
|
||||
- **Dropdown 색(2026-10-21):** Header 안의 Dropdown — 메뉴 버튼(`type: "menu"`), More(⋯), 로고 메뉴 — 은 크기·간격·글자는 `SWViewerMenu` 그대로, 색은 일반 `SWHeader`의 `...` 메뉴와 같습니다(면 #0e3d2f, Border `--hm-sw-header-object-border-color`, 그림자 0 4px 12px rgba(0,0,0,.5), 글자 `--hm-sw-header-text-color`, 보조 글자는 같은 색 80%, Hover `--hm-sw-header-hover`, 구분선 `--hm-sw-header-document-menu-divider-color`). 파일명 목록(`documents`)은 `SWHeader` 파일명 목록 모양을 유지하고, 단독 `SWViewerMenu`(우클릭 메뉴 등)는 기존 색 그대로입니다.
|
||||
- **Dropdown 색(2026-10-21):** Header 안의 Dropdown — 메뉴 버튼(`type: "menu"`), More(⋯), 로고 메뉴 — 은 크기·간격·글자는 `SWViewerMenu` 그대로, 색은 일반 `SWHeader`의 `...` 메뉴와 같은 More 토큰을 읽습니다(2026-11-10: 면 `--hm-sw-header-more-menu-background` #0e3d2f, Border `--hm-sw-header-more-menu-border-color` = `var(--hm-sw-header-object-border-color)`, 그림자 `--hm-sw-header-more-menu-shadow` 0 4px 12px rgba(0,0,0,.5), 글자 `--hm-sw-header-text-color`, 보조 글자는 같은 색 80%, Hover `--hm-sw-header-more-item-hover-background` = `var(--hm-sw-header-hover)`, 구분선 `--hm-sw-header-document-menu-divider-color`). 파일명 목록(`documents`)은 `SWHeader` 파일명 목록 모양을 유지하고, 단독 `SWViewerMenu`(우클릭 메뉴 등)는 기존 색 그대로입니다.
|
||||
- **상태 표현:** Hover·Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(면·선택 박스 없음, Hover 전후 크기·위치 동일 — 2026-10-21 Hover 면 #266651 제거), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선.
|
||||
- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `SWViewerIcon`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke를 **SVG stroke**로 재현: 아이콘 뒤 복사본의 모든 도형을 검정, 원래 선 두께 + 화면 기준 양쪽 1px(`--hm-sw-viewer-icon-outline-width`)로 그림. 위 아이콘은 그대로라 색·모양·선 두께 유지. CSS `filter` 미사용, glow는 생략). Header 항목·`SWViewerHeaderButton`의 `icon`은 자동 적용, `custom` / `trailing`의 직접 그린 아이콘은 `<SWViewerIcon>`으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
padding: 0 12px;
|
||||
color: inherit;
|
||||
}
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:is(:hover, :focus):not(:disabled) { background: var(--hm-sw-header-hover); outline: none; }
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:is(:hover, :focus):not(:disabled) { background: var(--hm-sw-header-more-item-hover-background); outline: none; }
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:disabled { opacity: 0.5; }
|
||||
/* Title = SWViewerMenu label, directly above its items (one group, no divider); icons = SWViewerMenu icon slot. */
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-title { min-height: 0; padding: 6px 12px; font-size: 12px; font-weight: 500; line-height: 18px; }
|
||||
@@ -297,18 +297,18 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
a segment (none around the license button), no gap, top and bottom on the object surface border. The strip ends at the object surface edge, as in SWHeader. Status stays display only. */
|
||||
.hm-sw-viewer-header__object:has(> .hm-sw-viewer-header__actions > :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons):last-child) { padding-right: 0; }
|
||||
/* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the
|
||||
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the
|
||||
object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text
|
||||
unchanged. Size, spacing and type stay SWViewerMenu. A standalone SWViewerMenu (context menu) and the document list
|
||||
keep their own colors. */
|
||||
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its More tokens for face, shadow, border and
|
||||
row hover (2026-11-10, same values as before), the header text and secondary text at 80% (as its values), the text
|
||||
unchanged on hover. Size, spacing and type stay SWViewerMenu. A standalone SWViewerMenu (context menu) and the
|
||||
document list keep their own colors. */
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu[data-surface] {
|
||||
border-color: var(--hm-sw-header-object-border-color);
|
||||
background: #0e3d2f;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
border-color: var(--hm-sw-header-more-menu-border-color);
|
||||
background: var(--hm-sw-header-more-menu-background);
|
||||
box-shadow: var(--hm-sw-header-more-menu-shadow);
|
||||
color: var(--hm-sw-header-text-color);
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) {
|
||||
background: var(--hm-sw-header-hover);
|
||||
background: var(--hm-sw-header-more-item-hover-background);
|
||||
color: inherit;
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu :is(.hm-sw-viewer-menu__label, .hm-sw-viewer-menu__shortcut, .hm-sw-viewer-menu__chevron) { color: inherit; opacity: 0.8; }
|
||||
|
||||
@@ -96,6 +96,28 @@
|
||||
--hm-sw-switch-on-border: #d2570b;
|
||||
--hm-sw-switch-thumb-off: linear-gradient(180deg, #fbedd4 0%, #eee3da 100%);
|
||||
--hm-sw-switch-thumb-on: linear-gradient(180deg, #e8741e 0%, #c0520d 100%);
|
||||
/* Switch depth (2026-11-10 individual tokens, values as before): inner shadows + ring + 1px highlight, thumb drop. */
|
||||
--hm-sw-switch-off-shadow: inset 1px 0 2px rgba(0, 0, 0, .3), inset 0 2px 2px rgba(0, 0, 0, .3), inset 0 0 0 1px var(--hm-sw-switch-off-border), 1px 1px 1px #fcf0d8;
|
||||
--hm-sw-switch-on-shadow: inset 1px 0 1px 1px rgba(0, 0, 0, .2), inset 0 1px 1px 1px rgba(0, 0, 0, .2), inset 0 0 0 .8px var(--hm-sw-switch-on-border), 1px 1px 1px #fcf0d8;
|
||||
--hm-sw-switch-thumb-shadow: 0 .5px 1px rgba(0, 0, 0, .6);
|
||||
/* Popup frame (SWPopupFrame: SWPopup, property window, SWViewerPanel; 2026-11-10 individual tokens). The title bar
|
||||
defaults follow the header tokens it used so far, so a theme that sets those header tokens is unchanged; set the
|
||||
popup token to change the popup alone. */
|
||||
--hm-sw-popup-titlebar-background: var(--hm-sw-header-document-background);
|
||||
--hm-sw-popup-title-color: var(--hm-sw-header-selected-color);
|
||||
--hm-sw-popup-title-outline: var(--hm-sw-header-label-outline);
|
||||
--hm-sw-popup-frame-shadow: inset 0 0 0 1px #000;
|
||||
--hm-sw-popup-group-heading-color: rgba(0, 0, 0, .7);
|
||||
--hm-sw-popup-group-border-color: #585349;
|
||||
--hm-sw-popup-group-shadow: 0 2px 2px rgba(255, 255, 255, .6), inset 0 2px 4px #e1cfab, inset 0 -2px 2px rgba(104, 95, 76, .8);
|
||||
--hm-sw-popup-divider-color: rgba(0, 0, 0, .5);
|
||||
--hm-sw-popup-table-line-color: rgba(0, 0, 0, .4);
|
||||
/* SWHeader `...` More menu (2026-11-10 individual tokens, values as before). The viewer header dropdowns and the
|
||||
viewer logo menu take the same face and shadow. Border and row hover default to the header tokens they used. */
|
||||
--hm-sw-header-more-menu-background: #0e3d2f;
|
||||
--hm-sw-header-more-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
--hm-sw-header-more-menu-border-color: var(--hm-sw-header-object-border-color);
|
||||
--hm-sw-header-more-item-hover-background: var(--hm-sw-header-hover);
|
||||
/* Popup scrollbar (every SW popup body: SWPopup, SWViewerPanel, property window). Same as the DWG/DXF viewer
|
||||
panels (thin, HM warm-neutral-mid thumb on warm-neutral-light track). */
|
||||
--hm-sw-popup-scrollbar-thumb: #9a9284;
|
||||
@@ -1353,10 +1375,10 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
min-width: 200px;
|
||||
max-width: calc(100vw - 16px);
|
||||
padding: 4px;
|
||||
border: 1px solid var(--hm-sw-header-object-border-color);
|
||||
border: 1px solid var(--hm-sw-header-more-menu-border-color);
|
||||
border-radius: var(--hm-sw-header-property-radius);
|
||||
background: #0e3d2f;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
background: var(--hm-sw-header-more-menu-background);
|
||||
box-shadow: var(--hm-sw-header-more-menu-shadow);
|
||||
}
|
||||
.hm-sw-header__more-item {
|
||||
display: none;
|
||||
@@ -1368,7 +1390,7 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-sw-header__more-item:hover:not(:disabled) { background: var(--hm-sw-header-hover); }
|
||||
.hm-sw-header__more-item:hover:not(:disabled) { background: var(--hm-sw-header-more-item-hover-background); }
|
||||
.hm-sw-header__more-item[role="menuitemcheckbox"]::after { content: ""; width: 14px; text-align: center; }
|
||||
.hm-sw-header__more-item[aria-checked="true"]::after { content: "✓"; }
|
||||
.hm-sw-header__more-value { font-size: var(--hm-sw-header-label-size); opacity: 0.8; }
|
||||
@@ -1619,13 +1641,13 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
overflow: hidden; border: 1px solid var(--hm-sw-property-border); border-radius: 4px;
|
||||
background: var(--hm-sw-property-surface); color: var(--hm-sw-property-label);
|
||||
font-size: 14px; font-weight: 400; line-height: 16px; letter-spacing: .02em;
|
||||
box-shadow: inset 0 0 0 1px #000;
|
||||
box-shadow: var(--hm-sw-popup-frame-shadow);
|
||||
}
|
||||
.hm-sw-property__titlebar { position: relative; display: flex; align-items: center; flex: 0 0 30px; height: 30px; background: var(--hm-sw-header-document-background); }
|
||||
.hm-sw-property__titlebar { position: relative; display: flex; align-items: center; flex: 0 0 30px; height: 30px; background: var(--hm-sw-popup-titlebar-background); }
|
||||
/* Figma paints btn_close first, then Union over its left side. Keep Union's diagonal and shadow
|
||||
above the close background; the title and tool remain interactive on top of it. */
|
||||
.hm-sw-property__title-art { position: absolute; z-index: 1; width: 308px; height: 38px; left: 0; top: -2px; transform: scaleY(-1); pointer-events: none; }
|
||||
.hm-sw-property__title { position: relative; z-index: 2; flex: 1; min-width: 0; margin-left: 7px; padding-right: 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 700; letter-spacing: .04em; color: var(--hm-sw-header-selected-color); text-shadow: var(--hm-sw-header-label-outline); }
|
||||
.hm-sw-property__title { position: relative; z-index: 2; flex: 1; min-width: 0; margin-left: 7px; padding-right: 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 700; letter-spacing: .04em; color: var(--hm-sw-popup-title-color); text-shadow: var(--hm-sw-popup-title-outline); }
|
||||
.hm-sw-property__tool { position: absolute; z-index: 2; right: 43px; top: 1px; width: 29px; height: 27.13px; }
|
||||
.hm-sw-property:not([data-selection-kind="single"]) .hm-sw-property__title { margin-right: 29px; }
|
||||
.hm-sw-property__tool > img { position: absolute; top: 0; left: 0; display: block; width: 29px; height: 27.13px; }
|
||||
@@ -1649,17 +1671,17 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
box-sizing: border-box; display: inline-flex; align-items: center; flex-shrink: 0;
|
||||
width: 37px; height: 16px; padding: 0; border: 0; border-radius: 100px;
|
||||
background: var(--hm-sw-switch-off-background);
|
||||
box-shadow: inset 1px 0 2px rgba(0, 0, 0, .3), inset 0 2px 2px rgba(0, 0, 0, .3), inset 0 0 0 1px var(--hm-sw-switch-off-border), 1px 1px 1px #fcf0d8;
|
||||
box-shadow: var(--hm-sw-switch-off-shadow);
|
||||
}
|
||||
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch)[data-state="checked"] {
|
||||
background: var(--hm-sw-switch-on-background);
|
||||
box-shadow: inset 1px 0 1px 1px rgba(0, 0, 0, .2), inset 0 1px 1px 1px rgba(0, 0, 0, .2), inset 0 0 0 .8px var(--hm-sw-switch-on-border), 1px 1px 1px #fcf0d8;
|
||||
box-shadow: var(--hm-sw-switch-on-shadow);
|
||||
}
|
||||
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch):hover:not(:disabled) { background-image: none; }
|
||||
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch):focus-visible { outline: 2px solid var(--hm-sw-switch-on-border); outline-offset: 1px; }
|
||||
.hm-sw-property [role="switch"] > :is([data-slot="switch-thumb"], .hm-sw-viewer-switch__thumb) {
|
||||
width: 12px; height: 12px; border-radius: 6.5px;
|
||||
background: var(--hm-sw-switch-thumb-off); box-shadow: 0 .5px 1px rgba(0, 0, 0, .6);
|
||||
background: var(--hm-sw-switch-thumb-off); box-shadow: var(--hm-sw-switch-thumb-shadow);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.hm-sw-property [role="switch"][data-state="checked"] > :is([data-slot="switch-thumb"], .hm-sw-viewer-switch__thumb) {
|
||||
@@ -1668,8 +1690,8 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-property[data-selection-kind="all"] .hm-sw-property__body { padding-bottom: 0; }
|
||||
.hm-sw-property[data-selection-kind="all"] .hm-sw-property__counts { margin-top: -8px; }
|
||||
.hm-sw-property__group { flex: 0 0 auto; }
|
||||
.hm-sw-property__group > h3 { margin: 0 0 4px; font: inherit; font-weight: 700; color: rgba(0,0,0,.7); }
|
||||
.hm-sw-property__group--rows > .hm-sw-property__group-content { border: 1px solid #585349; border-radius: 4px; padding: 2px; background: var(--hm-sw-property-content); box-shadow: 0 2px 2px rgba(255,255,255,.6), inset 0 2px 4px #e1cfab, inset 0 -2px 2px rgba(104,95,76,.8); }
|
||||
.hm-sw-property__group > h3 { margin: 0 0 4px; font: inherit; font-weight: 700; color: var(--hm-sw-popup-group-heading-color); }
|
||||
.hm-sw-property__group--rows > .hm-sw-property__group-content { border: 1px solid var(--hm-sw-popup-group-border-color); border-radius: 4px; padding: 2px; background: var(--hm-sw-property-content); box-shadow: var(--hm-sw-popup-group-shadow); }
|
||||
.hm-sw-property__row { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 6px; }
|
||||
.hm-sw-property__row > label { flex: 0 0 72px; min-width: 0; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hm-sw-property__value { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
|
||||
@@ -1738,7 +1760,7 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-property__expand > img { display: block; width: 296px; height: 12px; margin-left: calc((100% - 296px) / 2); }
|
||||
.hm-sw-property__expand[aria-expanded="true"] > img { transform: rotate(180deg); }
|
||||
.hm-sw-property__counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.hm-sw-property__counts > span { min-height: 28px; padding: 5px 0; border-bottom: 1px solid rgba(0,0,0,.5); font-weight: 700; }
|
||||
.hm-sw-property__counts > span { min-height: 28px; padding: 5px 0; border-bottom: 1px solid var(--hm-sw-popup-divider-color); font-weight: 700; }
|
||||
/* 2026-10-21 design review: each object type entry (원 1, 폴리선 10 …) changes its own text color on Hover, with the
|
||||
SW row accent #FF5C00 (the header list row hover color). Display only: no click, selection or filter. */
|
||||
/* Only the object list (columns 3-4); the summary entries of columns 1-2 (data-count-summary) stay plain text. */
|
||||
@@ -1768,17 +1790,17 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-property__group--position { margin-top: 8px; }
|
||||
.hm-sw-property__group--position .hm-sw-property__group-content { margin: 0 4px; }
|
||||
.hm-sw-property__position-head, .hm-sw-property__group--position .hm-sw-property__editors { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
|
||||
.hm-sw-property__position-head { background: var(--hm-sw-property-table); border-top: 1px solid rgba(0,0,0,.4); }
|
||||
.hm-sw-property__position-head > span, .hm-sw-property__position-label { min-height: 24px; padding: 3px 0; text-align: center; font-weight: 700; border-bottom: 1px solid rgba(0,0,0,.4); }
|
||||
.hm-sw-property__position-head > span + span, .hm-sw-property__group--position .hm-sw-property__editor { border-left: 1px solid rgba(0,0,0,.4); }
|
||||
.hm-sw-property__position-head { background: var(--hm-sw-property-table); border-top: 1px solid var(--hm-sw-popup-table-line-color); }
|
||||
.hm-sw-property__position-head > span, .hm-sw-property__position-label { min-height: 24px; padding: 3px 0; text-align: center; font-weight: 700; border-bottom: 1px solid var(--hm-sw-popup-table-line-color); }
|
||||
.hm-sw-property__position-head > span + span, .hm-sw-property__group--position .hm-sw-property__editor { border-left: 1px solid var(--hm-sw-popup-table-line-color); }
|
||||
.hm-sw-property__position-label { background: var(--hm-sw-property-table); }
|
||||
.hm-sw-property__group--position .hm-sw-property__row { padding: 0; gap: 0; min-height: 24px; }
|
||||
.hm-sw-property__group--position label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.hm-sw-property__group--position .hm-sw-header__input { padding: 0; width: 100%; height: 24px; text-align: center; }
|
||||
.hm-sw-property__group--position .hm-sw-property__editor { border-bottom: 1px solid rgba(0,0,0,.4); }
|
||||
.hm-sw-property__group--position .hm-sw-property__editor { border-bottom: 1px solid var(--hm-sw-popup-table-line-color); }
|
||||
.hm-sw-property__overview { position: relative; flex: 0 0 28px; height: 28px; text-align: left; overflow: hidden; }
|
||||
.hm-sw-property__overview > img { position: absolute; left: -3px; top: -2px; width: 317px; height: 36.0847px; transform: scaleY(-1); }
|
||||
.hm-sw-property__overview > span { position: relative; display: block; padding: 6px 7px; color: var(--hm-sw-header-selected-color); text-shadow: var(--hm-sw-header-label-outline); font-weight: 700; }
|
||||
.hm-sw-property__overview > span { position: relative; display: block; padding: 6px 7px; color: var(--hm-sw-popup-title-color); text-shadow: var(--hm-sw-popup-title-outline); font-weight: 700; }
|
||||
.hm-sw-property__overview:disabled { opacity: 1; }
|
||||
.hm-sw-property__row[data-disabled="true"] { opacity: .4; }
|
||||
.hm-sw-property button:focus-visible, .hm-sw-property input:focus-visible, .hm-sw-property select:focus-visible { outline: 2px solid var(--hm-sw-property-border); outline-offset: -2px; }
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
// Test-only host for property-fields.browser.mjs (2026-11-10). Not a Storybook Story and not shipped (package "files" is
|
||||
// dist + ai). It restores the host data and host controls of the two demos removed in 5faa1f1 that the field checks
|
||||
// need: SW/Header › Property Fields (data-fields live / default / pinned hover) and the Header-hosted Property Panel
|
||||
// (원 1개 selection). Records and controls are copied from src/sw/sw-header.stories.tsx at 5faa1f1^ without changes.
|
||||
// Uses only the public @hm-design/sw API, like a consuming project. The pinned Hover column uses the Storybook pseudo-state
|
||||
// mirror (.storybook/pseudo-states.ts) exactly as the retired Story did.
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import {
|
||||
SWHeader, SWHeaderPropertyPanel, SWTheme, swHeaderLinePatterns,
|
||||
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderLayer, type SWHeaderLineType, type SWHeaderLineWeight,
|
||||
type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertyMenus, type SWHeaderPropertySelection,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { ensurePseudoStates } from "../../../../.storybook/pseudo-states"
|
||||
|
||||
const FIGMA_COLORS: SWHeaderColorOption[] = [
|
||||
{ id: "white", color: "#ffffff", label: "흰색" },
|
||||
{ id: "red", color: "#f10404", label: "빨강" },
|
||||
{ id: "green", color: "#48ff08", label: "초록" },
|
||||
{ id: "yellow", color: "#ffff00", label: "노랑" },
|
||||
{ id: "blue", color: "#0012ff", label: "파랑" },
|
||||
{ id: "magenta", color: "#ff00f6", label: "자홍" },
|
||||
{ id: "cyan", color: "#00f6ff", label: "청록" },
|
||||
]
|
||||
const FIGMA_LINE_TYPES: SWHeaderLineType[] = [
|
||||
{ id: "byblock", name: "ByBlock" },
|
||||
{ id: "bylayer", name: "ByLayer" },
|
||||
{ id: "continuous", name: "Continuous" },
|
||||
{ id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center },
|
||||
{ id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden },
|
||||
]
|
||||
const FIGMA_LINE_WEIGHTS: SWHeaderLineWeight[] = [
|
||||
{ id: "w-bylayer", name: "ByLayer" },
|
||||
{ id: "w-byblock", name: "ByBlock" },
|
||||
{ id: "w-default", name: "ByDefault", value: "0.25" },
|
||||
{ id: "w-user", name: "User", value: "0.25", editable: true },
|
||||
]
|
||||
const PROPERTY_GROUPS: SWHeaderPropertyGroup[] = [
|
||||
{ id: "common", collapsedCount: 6 },
|
||||
{ id: "shape", name: "형상", layout: "circle" },
|
||||
{ id: "position", name: "위치", layout: "position", positionLabel: "중간점" },
|
||||
]
|
||||
const PROPERTY_FIXTURE: SWHeaderProperty[] = [
|
||||
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 },
|
||||
{ id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" },
|
||||
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] },
|
||||
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 },
|
||||
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
|
||||
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
|
||||
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
|
||||
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
|
||||
{ id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" },
|
||||
{ id: "circumference", name: "원주", value: "0.25", type: "computed", groupId: "shape", graphicSlot: "circumference" },
|
||||
{ id: "diameter", name: "지름", value: "8.1502", type: "number", groupId: "shape", graphicSlot: "diameter", min: 0 },
|
||||
{ id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", graphicSlot: "radius", min: 0 },
|
||||
{ id: "area", name: "면적", value: "51.5962", type: "computed", groupId: "shape", graphicSlot: "area" },
|
||||
{ id: "x", name: "X", value: "48.0808", type: "number", groupId: "position" },
|
||||
{ id: "y", name: "Y", value: "14.9160", type: "number", groupId: "position" },
|
||||
{ id: "z", name: "Z", value: "0.0000", type: "number", groupId: "position" },
|
||||
]
|
||||
const PROPERTY_COUNTS = [
|
||||
{ id: "shape", name: "도형", count: 10, row: 1, column: 1 }, { id: "line", name: "직선", count: 10, row: 1, column: 2 },
|
||||
{ id: "circle", name: "원", count: 1, row: 1, column: 3 }, { id: "ray", name: "광선", count: 1, row: 1, column: 4 },
|
||||
{ id: "curve", name: "곡선", count: 10, row: 2, column: 3 }, { id: "arc", name: "호", count: 1, row: 2, column: 4 },
|
||||
{ id: "polyline", name: "폴리선", count: 10, row: 3, column: 3 }, { id: "polyline2", name: "폴리선", count: 2, row: 3, column: 4 },
|
||||
{ id: "text1", name: "문자", count: 1, row: 4, column: 1 }, { id: "text2", name: "문자", count: 1, row: 4, column: 2 },
|
||||
{ id: "text3", name: "문자", count: 1, row: 4, column: 3 },
|
||||
]
|
||||
type PropertyExample = "none" | "all" | "circle" | "expanded"
|
||||
function propertySelection(example: PropertyExample): SWHeaderPropertySelection {
|
||||
return example === "none" ? { id: "none", title: "선택된 객체 - 없음", kind: "none" }
|
||||
: example === "all" ? { id: "all", title: "선택된 객체 - 전체 11", kind: "all", counts: PROPERTY_COUNTS }
|
||||
: { id: "circle", title: "선택된 객체 - 원1", kind: "single", overviewTitle: "선택된 객체 - 전체 11" }
|
||||
}
|
||||
function propertyFixture(example: PropertyExample) {
|
||||
return PROPERTY_FIXTURE.filter(item => (example === "none" || example === "all") ? item.groupId === "common" : true).map(item => ({
|
||||
...item, ...(example === "none" && item.id === "layer" ? { value: "0" } : {}),
|
||||
...(example === "none" && item.id === "color" ? { value: "138,138,138" } : {}),
|
||||
...(example === "all" && item.id === "layer" ? { mixed: true } : {}),
|
||||
...((example === "none" || example === "all") && item.id === "opacity" ? { value: "0" } : {}),
|
||||
}))
|
||||
}
|
||||
const PROPERTY_LAYERS: SWHeaderLayer[] = [
|
||||
{ id: "0", name: "0" },
|
||||
{ id: "0_Layer_Cludmark", name: "0_Layer_Cludmark", color: "#ff0000" },
|
||||
{ id: "CC-D GNL", name: "CC-D GNL" },
|
||||
{ id: "Sample-Wall", name: "Sample-Wall", color: "#ffe500", visible: false },
|
||||
{ id: "Sample-Door", name: "Sample-Door", color: "#00ff00", frozen: true },
|
||||
{ id: "Sample-Text", name: "Sample-Text", color: "#00ffff", locked: true },
|
||||
{ id: "Sample-Defpoints", name: "Sample-Defpoints", color: "#808080", disabled: true },
|
||||
]
|
||||
const PROPERTY_LINE_TYPES: SWHeaderLineType[] = FIGMA_LINE_TYPES.map(item => ({ ...item, id: item.name }))
|
||||
const linePatternOf = (value: string) => PROPERTY_LINE_TYPES.find(item => item.id === value)?.pattern
|
||||
function usePropertyMenus(record: (line: string) => void, setProperties: (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void): SWHeaderPropertyMenus {
|
||||
const [layers, setLayers] = useState(PROPERTY_LAYERS)
|
||||
const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS)
|
||||
const [weightId, setWeightId] = useState("w-default")
|
||||
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
|
||||
const setValue = (id: string, value: string) => setProperties(items => items.map(item => item.id === id ? { ...item, value, mixed: false } : item))
|
||||
const scale = "1.0000"
|
||||
const toggle = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => {
|
||||
record(`layer.${key}(${layer.id}, ${next})`)
|
||||
setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item))
|
||||
}
|
||||
return {
|
||||
layers, onLayerVisibilityChange: toggle("visible"), onLayerFreezeChange: toggle("frozen"), onLayerLockChange: toggle("locked"),
|
||||
onLayerDetail: () => record("layerDetail"),
|
||||
lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale,
|
||||
onLineWeightSelect: weight => { record(`lineWeight(${weight.id})`); setWeightId(weight.id); if (weight.value) setValue("weight", weight.value) },
|
||||
onLineWeightValueChange: (weight, value) => { record(`lineWeightValue(${weight.id}, ${value})`); setWeights(items => items.map(item => item.id === weight.id ? { ...item, value } : item)) },
|
||||
onLineScaleChange: value => { record(`lineScale(${value})`); setValue("scale", Number(value).toFixed(4)) },
|
||||
onLineStyleDetail: () => record("lineStyleDetail"),
|
||||
colors: FIGMA_COLORS, colorMode,
|
||||
onColorModeChange: mode => { record(`colorMode(${mode})`); setColorMode(mode) },
|
||||
onColorPick: property => record(`colorPick(${property.id})`),
|
||||
onColorDetail: () => record("colorDetail"),
|
||||
}
|
||||
}
|
||||
function PinHover({ selectors, children }: { selectors: string[]; children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
ensurePseudoStates()
|
||||
ref.current?.querySelectorAll(selectors.join(",")).forEach(element => element.setAttribute("data-sb-hover", ""))
|
||||
}, [selectors])
|
||||
return <div ref={ref}>{children}</div>
|
||||
}
|
||||
|
||||
/* SW/Header › Property Fields (retired in 5faa1f1): the common rows of PROPERTY_FIXTURE plus a mixed layer row. Figma
|
||||
sample text 255,259,0 is out of range: shown as host text only, never accepted as input. */
|
||||
const FIELD_FIXTURE: SWHeaderProperty[] = PROPERTY_FIXTURE.filter(item => item.groupId === "common").flatMap(item =>
|
||||
item.id === "layer" ? [{ ...item, id: "layer-mixed", mixed: true }, item]
|
||||
: item.id === "color" ? [{ ...item, value: "255,259,0", swatch: "#ffe500" }]
|
||||
: [item])
|
||||
const FIELD_HOVER = [".hm-sw-property__value[data-field]", ".hm-sw-property__compact"]
|
||||
function FieldPanel({ title, properties, onChange, menus }: { title: string; properties: SWHeaderProperty[]; onChange?: (property: SWHeaderProperty, value: string) => void; menus?: SWHeaderPropertyMenus }) {
|
||||
return <SWTheme><SWHeaderPropertyPanel selection={{ id: title, title, kind: "none" }} properties={properties} groups={[{ id: "common" }]} onPropertyChange={onChange} menus={menus} /></SWTheme>
|
||||
}
|
||||
function PropertyFieldsHost() {
|
||||
const [properties, setProperties] = useState(FIELD_FIXTURE)
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const record = (line: string) => setLog(lines => [...lines.slice(-11), line])
|
||||
const menus = usePropertyMenus(record, setProperties as (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void)
|
||||
const [noMenus, setNoMenus] = useState(false)
|
||||
const patch = (id: string, change: Partial<SWHeaderProperty>) => setProperties(items => items.map(item => item.id === id ? { ...item, ...change } : item))
|
||||
const change = (property: SWHeaderProperty, value: string) => {
|
||||
record(`property(${property.id}, ${value})`)
|
||||
patch(property.id, { value, mixed: false, ...(property.id === "line" ? { linePattern: linePatternOf(value) } : {}), ...(property.id === "color" ? { swatch: undefined } : {}) })
|
||||
}
|
||||
return <div data-fields-demo="">
|
||||
<div><strong>외부 데이터</strong>
|
||||
<button data-action="layer-mixed" onClick={() => patch("layer-mixed", { mixed: !properties.find(item => item.id === "layer-mixed")?.mixed })}>레이어 다양함 ↔ 단일</button>
|
||||
<button data-action="layer-external" onClick={() => patch("layer", { value: "CC-D GNL" })}>레이어 외부 값 변경</button>
|
||||
<button data-action="scale-external" onClick={() => patch("scale", { value: "2.5000" })}>선축적 외부 값 변경</button>
|
||||
<button data-action="thickness-unit" onClick={() => patch("thickness", { unit: properties.find(item => item.id === "thickness")?.unit === "mm" ? "cm" : "mm" })}>두께 단위 변경</button>
|
||||
<button data-action="line-center" onClick={() => patch("line", { value: "CENTER", linePattern: linePatternOf("CENTER") })}>선스타일 외부 CENTER</button>
|
||||
<button data-action="readonly" onClick={() => setProperties(items => items.map(item => item.id === "thickness" || item.id === "layer" ? { ...item, readOnly: !item.readOnly } : item))}>두께·레이어 ReadOnly</button>
|
||||
<button data-action="disabled" onClick={() => setProperties(items => items.map(item => item.id === "scale" || item.id === "color" ? { ...item, disabled: !item.disabled } : item))}>선축적·색상 Disabled</button>
|
||||
<button data-action="add-row" onClick={() => setProperties(items => [...items, { id: `extra-${items.length}`, name: "추가 속성", value: "호스트 값", type: "text", groupId: "common" }])}>속성 추가</button>
|
||||
<button data-action="remove-row" onClick={() => setProperties(items => items.filter(item => item.id !== "link"))}>하이퍼링크 삭제</button>
|
||||
<button data-action="no-menus" onClick={() => setNoMenus(value => !value)}>menus 없음(options 사용): {String(noMenus)}</button>
|
||||
</div>
|
||||
<p data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
|
||||
<div style={{ display: "flex", gap: 24, alignItems: "flex-start" }}>
|
||||
<figure style={{ margin: 0 }} data-fields="live"><FieldPanel title="실제 조작" properties={properties} onChange={change} menus={noMenus ? undefined : menus} /></figure>
|
||||
<figure style={{ margin: 0 }} data-fields="default"><FieldPanel title="Default" properties={FIELD_FIXTURE} /></figure>
|
||||
<figure style={{ margin: 0 }} data-fields="hover"><PinHover selectors={FIELD_HOVER}><FieldPanel title="Hover" properties={FIELD_FIXTURE} /></PinHover></figure>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
/* SW/Header › Property Panel (retired in 5faa1f1): SWHeader with the propertyPanel prop and the selection buttons. */
|
||||
function HeaderPanelHost() {
|
||||
const [selection, setSelection] = useState(propertySelection("none"))
|
||||
const [properties, setProperties] = useState(propertyFixture("none"))
|
||||
const [expanded, setExpanded] = useState<string[]>([])
|
||||
const menus = usePropertyMenus(() => {}, setProperties)
|
||||
const changeExample = (next: PropertyExample) => {
|
||||
setSelection(propertySelection(next)); setProperties(propertyFixture(next))
|
||||
setExpanded(next === "expanded" ? ["common"] : [])
|
||||
}
|
||||
const panel = {
|
||||
selection, properties, groups: PROPERTY_GROUPS, expandedGroupIds: expanded, menus,
|
||||
onExpandedGroupsChange: setExpanded,
|
||||
onPropertyChange: (property: SWHeaderProperty, value: string) => setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false } : item)),
|
||||
onOverviewClick: () => changeExample("all"),
|
||||
}
|
||||
return <div data-property-demo="">
|
||||
<div>{(["none", "all", "circle", "expanded"] as const).map((name, index) => <button key={name} data-example-button={name} onClick={() => changeExample(name)}>{["선택 없음", "전체 선택", "원 1개", "모두 펼침"][index]}</button>)}</div>
|
||||
<div style={{ width: 1920, minHeight: 820 }}>
|
||||
<SWHeader documentTitle="C0020101-006-평면및종단면도(06)" drawingModeLabel="그리기모드" layer={{ name: "0_Layer_Cludmark" }} color={{ value: "255,229,0", swatch: "#ffe500" }}
|
||||
lineStyle={{ name: "Continuous", weight: "0.25", scale: "1.00" }} textStyle={{ font: "Arial", name: "Standard" }} dimensionStyle={{ name: "Standard" }} propertyPanel={panel} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const view = new URLSearchParams(location.search).get("view")
|
||||
createRoot(document.getElementById("root")!).render(view === "header" ? <HeaderPanelHost /> : <PropertyFieldsHost />)
|
||||
@@ -1,17 +1,34 @@
|
||||
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-fields). Kept as evidence
|
||||
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
|
||||
// Property fields (Figma 26:12388 ~ 26:12649): Default / real pointer Hover / Mouse Leave / On (editing, dropdown),
|
||||
// selection callbacks, keyboard, Escape, external data, Disabled / ReadOnly, and the four panel states.
|
||||
// 2026-11-10 (test alignment): the SW/Header › Property Fields and Property Panel demos this suite opened were removed in
|
||||
// 5faa1f1 (approved). Sections 1–9 and the Header-hosted panel run on the test-only fixture fixtures/property-fields.tsx,
|
||||
// which restores those demos' host data and host controls with the public API (as header-composition does); the four
|
||||
// panel states run on the current Story sw-header--property-panel-compare (SW/Popup › 속성창). Every check is unchanged.
|
||||
// Usage: node packages/sw/tests/property-fields.browser.mjs <evidence-dir> <label> [storybook-url]
|
||||
// Storybook serves packages/sw/dist: build @hm-design/sw first.
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
// The fixture and Storybook use packages/sw/dist: build @hm-design/sw first.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadPlaywright } from './playwright-runtime.mjs'
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/property-fields-qa')
|
||||
const label = process.argv[3] ?? 'run'
|
||||
const base = process.argv[4] ?? 'http://127.0.0.1:6024'
|
||||
const { chromium } = loadPlaywright()
|
||||
await mkdir(out, { recursive: true })
|
||||
// Test-only fixture page (built into the evidence directory, served only for this run).
|
||||
const site = path.join(out, 'fixture')
|
||||
await mkdir(site, { recursive: true })
|
||||
await createRequire(import.meta.url)('esbuild').build({
|
||||
entryPoints: [fileURLToPath(new URL('./fixtures/property-fields.tsx', import.meta.url))], bundle: true, outdir: site, format: 'esm', jsx: 'automatic', minify: true,
|
||||
loader: { '.ttf': 'file', '.woff': 'file', '.woff2': 'file', '.svg': 'file', '.png': 'file' }, define: { 'process.env.NODE_ENV': '"production"' }, logLevel: 'warning',
|
||||
})
|
||||
await writeFile(path.join(site, 'index.html'), '<!doctype html><html lang="ko"><head><meta charset="utf-8"><link rel="icon" href="data:,"><link rel="stylesheet" href="property-fields.css"></head><body style="margin:0"><div id="root"></div><script type="module" src="property-fields.js"></script></body></html>')
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||||
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(site, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(site, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const fixture = `http://127.0.0.1:${server.address().port}/`
|
||||
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 2 })
|
||||
const report = { label, checks: [], errors: [] }
|
||||
@@ -19,7 +36,7 @@ page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m =
|
||||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
|
||||
const shot = async (name, locator) => writeFile(path.join(out, `${label}-${name}.png`), await (locator ?? page).screenshot())
|
||||
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--property-fields&viewMode=story`)
|
||||
await page.goto(fixture)
|
||||
await page.locator('[data-fields="live"] .hm-sw-property').waitFor(); await page.evaluate(() => document.fonts.ready)
|
||||
const live = page.locator('[data-fields="live"]')
|
||||
const row = id => live.locator(`.hm-sw-property__row[data-property-id="${id}"]`)
|
||||
@@ -35,9 +52,13 @@ for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thi
|
||||
const s = await style(field(id), 'borderTopColor', 'backgroundColor', 'borderTopLeftRadius')
|
||||
check(`Default ${id}: 200 x 28, radius 2, no border / surface`, s.w === 200 && s.h === 28 && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.borderTopLeftRadius === '2px', s)
|
||||
}
|
||||
// 2026-11-10: since the user-approved 2026-10-21 design review (2fc1b3e) 선가중치 is no longer the 41px hug field but the
|
||||
// 200 x 28 box like 선축적, and the 투명도 value is its own 28px box before the gauge (SWHeaderPropertyPanel.tsx). The
|
||||
// same properties are checked on the current fields: size, padding (7px incl. no border), radius, no border / surface.
|
||||
const compactField = id => id === 'weight' ? field('weight') : row(id).locator('.hm-sw-property__slider-value')
|
||||
for (const id of ['weight', 'opacity']) {
|
||||
const s = await style(row(id).locator('.hm-sw-property__compact'), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight')
|
||||
check(`Default ${id}: compact 28px hug field (pl 6 / pr 4 incl. border), no border`, s.h === 28 && s.w < 60 && s.borderTopColor === CLEAR && s.paddingLeft === '5px' && s.paddingRight === '3px', s)
|
||||
const s = await style(compactField(id), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight', 'borderTopLeftRadius')
|
||||
check(`Default ${id}: ${id === 'weight' ? '200 x 28 box' : '28px slider value box'} (pl / pr 7), radius 2, no border / surface`, s.h === 28 && (id === 'weight' ? s.w === 200 : s.w < 200) && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.paddingLeft === '7px' && s.paddingRight === '7px' && s.borderTopLeftRadius === '2px', s)
|
||||
}
|
||||
const typo = await row('scale').evaluate(r => { const v = getComputedStyle(r.querySelector('.hm-sw-header__input')), u = getComputedStyle(r.querySelector('.hm-sw-property__unit')); return { value: [v.color, v.fontSize], unit: [u.color, u.fontSize] } })
|
||||
check('Typography: value #E60403 14px, unit #000 12px', typo.value.join() === 'rgb(230, 4, 3),14px' && typo.unit.join() === 'rgb(0, 0, 0),12px', typo)
|
||||
@@ -56,9 +77,9 @@ for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thi
|
||||
check(`Mouse Leave ${id}: back to Default`, left.borderTopColor === CLEAR, left)
|
||||
}
|
||||
for (const id of ['weight', 'opacity']) {
|
||||
const c = row(id).locator('.hm-sw-property__compact')
|
||||
const c = compactField(id)
|
||||
await c.hover(); const s = await style(c, 'borderTopColor'); await page.mouse.move(1, 1); const l = await style(c, 'borderTopColor')
|
||||
check(`Hover / Leave ${id} compact`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
|
||||
check(`Hover / Leave ${id} ${id === 'weight' ? 'box' : 'slider value box'}`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
|
||||
}
|
||||
await field('scale').hover(); await shot('hover-scale', field('scale')); await page.mouse.move(1, 1)
|
||||
|
||||
@@ -78,8 +99,9 @@ check('Enter: 선축적 2 → validated, 4 decimals committed', (await log()).in
|
||||
await scale.fill('7'); await page.keyboard.press('Escape')
|
||||
check('Escape: draft cancelled (2.0000 restored, no callback)', await scale.inputValue() === '2.0000' && !(await log()).includes('7'), await scale.inputValue())
|
||||
const weight = row('weight').locator('input')
|
||||
await weight.click(); const w = await style(weight, 'backgroundColor')
|
||||
check('On(편집) 선가중치 compact: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
|
||||
// 2fc1b3e (2026-10-21): the editing face is on the 200px box (as 선축적), no longer on the hug field's input.
|
||||
await weight.click(); const w = await style(field('weight'), 'backgroundColor')
|
||||
check('On(편집) 선가중치 box: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
|
||||
await shot('on-weight', row('weight'))
|
||||
await weight.fill('-1'); await page.keyboard.press('Enter')
|
||||
check('Invalid 선가중치 -1 (min 0): restored, no callback', await weight.inputValue() === '0.25' && !(await log()).includes('weight, -1'), await weight.inputValue())
|
||||
@@ -87,10 +109,11 @@ await weight.fill('0.5'); await page.keyboard.press('Enter')
|
||||
check('선가중치 0.5 committed', (await log()).includes('property(weight, 0.5)'), await log())
|
||||
await page.mouse.click(1, 1)
|
||||
|
||||
// 4. No invented On: 하이퍼링크 (text) focus keeps the Hover look only; 재료 (computed) is read-only.
|
||||
// 4. 하이퍼링크 (editable text): since 6d46289 (2026-10-22, approved) its editing face is the numbers' On (#0F0D0A,
|
||||
// Hover border); 재료 (computed) stays read-only without On.
|
||||
await row('link').locator('input').click()
|
||||
const link = await style(field('link'), 'backgroundColor', 'borderTopColor')
|
||||
check('하이퍼링크 focus: no On surface (Figma has no On), editable', link.backgroundColor === CLEAR && link.borderTopColor === HOVER, link)
|
||||
check('하이퍼링크 focus: editing face #0F0D0A like numbers (6d46289), Hover border, editable', link.backgroundColor === 'rgb(15, 13, 10)' && link.borderTopColor === HOVER, link)
|
||||
await row('link').locator('input').fill('http://a'); await page.keyboard.press('Enter')
|
||||
check('하이퍼링크 commit via host callback', (await log()).includes('property(link, http://a)'), await log())
|
||||
await row('material').locator('input').click()
|
||||
@@ -132,16 +155,20 @@ check('Keyboard: ArrowDown opens + focuses list, Escape closes to trigger', focu
|
||||
await layerTrigger.click(); await page.mouse.click(1300, 900)
|
||||
check('Outside click closes', await drop.count() === 0, null)
|
||||
|
||||
// 6. Line style (26:12388 / 26:12404): 466 = 182 + 142 + 142, right-aligned, preview follows the value.
|
||||
// 6. Line style (26:12388 / 26:12404): right-aligned, preview follows the value. Since 2fc1b3e (2026-10-21 design
|
||||
// review, DESIGN_RULES 속성창 입력) the property window list is the line type column only (preview + name; type column
|
||||
// 182px, growing to 240px only for wider font names); weights / scale stay in the SWHeader line style field.
|
||||
await field('line').locator('.hm-sw-property__trigger').click()
|
||||
f = await style(field('line'), 'backgroundColor'); d = await style(drop.locator('.hm-sw-header__line-drop'))
|
||||
const cols = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.map(e => +e.getBoundingClientRect().width.toFixed(2)))
|
||||
const unitOn = await style(field('line').locator('.hm-sw-property__unit'), 'color')
|
||||
check('Line style On: #211D13, (ByL) #D4CBBD, 3 columns, right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 3 && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols })
|
||||
check('Line style drop 466 x 190 (type column grows only for wider font names)', d.w >= 466 && d.h === 190, { w: d.w, h: d.h, cols })
|
||||
const typeOnly = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.length === 1 && es[0].classList.contains('hm-sw-header__line-column--type'))
|
||||
check('Line style On: #211D13, (ByL) #D4CBBD, 1 column (line types), right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 1 && typeOnly && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols, typeOnly })
|
||||
check('Line style drop 182–240 x 190 (type column grows only for wider font names)', d.w >= 182 && d.w <= 240 && Math.abs(d.w - cols[0]) < 0.5 && d.h === 190, { w: d.w, h: d.h, cols })
|
||||
await shot('on-line', page)
|
||||
await drop.getByRole('button', { name: 'ByBlock', exact: true }).nth(1).click()
|
||||
check('Weight column: callback only, stays open', (await log()).includes('lineWeight(w-byblock)') && await drop.count() === 1, await log())
|
||||
// No weight column: the only ByBlock entry is the line type, and no weight / scale callback was called.
|
||||
const byBlock = await drop.getByRole('button', { name: 'ByBlock', exact: true }).count()
|
||||
check('Weight column: absent (ByBlock only as a line type), no lineWeight / lineScale callback, list stays open', byBlock === 1 && !/lineWeight|lineScale/.test(await log()) && await drop.count() === 1, { byBlock, log: await log() })
|
||||
await drop.getByRole('button', { name: 'CENTER' }).click()
|
||||
const segs = await field('line').locator('.hm-sw-header__line-preview > i').count()
|
||||
check('Line type select: property(line, CENTER), closes, preview = CENTER pattern (4 segments)', (await log()).includes('property(line, CENTER)') && await drop.count() === 0 && segs === 4, { segs })
|
||||
@@ -212,7 +239,7 @@ const want = { none: [314, 256], all: [314, 361], circle: [314, 658], expanded:
|
||||
check('4 panel states: 314 x 256 / 361 / 658 / 754', sizes.every(([k, w, h]) => w === want[k][0] && h === want[k][1]), sizes)
|
||||
await shot('compare', page.locator('.sw-story-compare'))
|
||||
// The header-hosted panel: layer field opens its list over the header page as well.
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--property-panel&viewMode=story`)
|
||||
await page.goto(`${fixture}?view=header`)
|
||||
await page.locator('.hm-sw-header').waitFor()
|
||||
await page.getByRole('button', { name: '원 1개', exact: true }).click()
|
||||
await page.locator('.hm-sw-header [data-property-panel-trigger]').click()
|
||||
@@ -223,7 +250,7 @@ const stillOpen = await page.locator('.hm-sw-property').count()
|
||||
check('Header-hosted panel: layer list attached; Escape closes list only', hosted && Math.abs(hosted.right) < 0.5 && Math.abs(hosted.top) < 0.5 && stillOpen === 1, { hosted, stillOpen })
|
||||
check('no console / page errors', report.errors.length === 0, report.errors)
|
||||
await writeFile(path.join(out, `${label}-result.json`), JSON.stringify(report, null, 2))
|
||||
await browser.close()
|
||||
await browser.close(); server.close()
|
||||
const failed = report.checks.filter(c => c.status === 'FAIL').length
|
||||
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"package": "@hm-design/sw",
|
||||
"approvedCommit": "8cb98b373f9e5321c8d6c032a6d2031e942dc508",
|
||||
"approvedCommit": "3cbe87991010f08b131e8b80c9f478c3fa721004",
|
||||
"manifest": {
|
||||
"name": "@hm-design/sw",
|
||||
"exports": {
|
||||
@@ -137,6 +137,18 @@
|
||||
"SWPopupRowProps": "type",
|
||||
"SWPopupSection": "value",
|
||||
"SWPopupSectionProps": "type",
|
||||
"SWProcessAppearance": "type",
|
||||
"SWProcessBar": "value",
|
||||
"SWProcessBarProps": "type",
|
||||
"SWProcessGroup": "type",
|
||||
"SWProcessHeader": "value",
|
||||
"SWProcessHeaderHideableControlId": "type",
|
||||
"SWProcessHeaderLayer": "type",
|
||||
"SWProcessHeaderProps": "type",
|
||||
"SWProcessSplitButton": "value",
|
||||
"SWProcessSplitButtonProps": "type",
|
||||
"SWProcessSplitType": "type",
|
||||
"SWProcessStep": "type",
|
||||
"SWRecentFiles": "value",
|
||||
"SWRecentFilesProps": "type",
|
||||
"SWSelect": "value",
|
||||
@@ -207,7 +219,7 @@
|
||||
"components/sw-header/components/SWHeaderDropdown.d.ts": "06ce5aa914826b6dc85df923a24baded80d16f43aebce39845f5b18d9d996504",
|
||||
"components/sw-header/components/SWHeaderField.d.ts": "741fdd1b0e7da4cf8f7adeaec021eb4eb103d7312f34e80135d00c3510159901",
|
||||
"components/sw-header/components/SWHeaderFields.d.ts": "43950332583b4c74231a793e18651b45494165626cc45e3dfcf50169ec189edc",
|
||||
"components/sw-header/components/SWHeaderHome.d.ts": "aa1e56611e92f309d588fcb12c089bfb0cd336de050d9aca2f043118ba01ae54",
|
||||
"components/sw-header/components/SWHeaderHome.d.ts": "656f93b57e670cb422946abdf711ebe779ddff41786159cdb6f4091bf366f7ee",
|
||||
"components/sw-header/components/SWHeaderInput.d.ts": "6b379ee70b9bcbf6e9a0808ca891b86838a9bfa355bfa12f036088b26241dd9c",
|
||||
"components/sw-header/components/SWHeaderLayerIcons.d.ts": "bb4b955a7850c445a3ff14c0e25034124dca49208aed0fafd922878ff7099ac5",
|
||||
"components/sw-header/components/SWHeaderLayerMenu.d.ts": "0fb65b14f52cd2abf54e74ea09ed4b783fe1665416dfd910bd6cd7d3e1995711",
|
||||
@@ -219,23 +231,28 @@
|
||||
"components/sw-header/components/SWHeaderWindowControls.d.ts": "db8bfbd14340e03c28e407f58fc08cef55b9eef49f406ba53fc50e994f5f0834",
|
||||
"components/sw-header/index.d.ts": "958a64efd05f7b8ab683ecb342fdcdef488db0be97cf9a2bf5ad3e3ac4632ab7",
|
||||
"components/sw-header/SWHeader.d.ts": "3adac1db372451d9f86a6f6df1df0e9696cd40c3ed7945531ee378da122136d8",
|
||||
"components/sw-header/types.d.ts": "fa7602374753bf7b6b48c0fa9f1c30a932ed12ccb4d16488c836e964b76f9122",
|
||||
"components/sw-header/types.d.ts": "fdc3fafb111331f2edd754029bdc78231b18e563df662b849d8a6a8eaf30ebcd",
|
||||
"components/sw-home-menu/index.d.ts": "b1f67a4e16c7b3a64b93f348bfb17fcd2ada999527cb93ccb77ca6308564091c",
|
||||
"components/sw-home-menu/SWHomeMenu.d.ts": "2ce530d253374a8b20a20d8b6ddd5c733abfc8d518162d5a7490d432986a662c",
|
||||
"components/sw-home-menu/SWRecentFiles.d.ts": "0c3c8ececc537ab6a03ce14a91dd1c878e9539b4022ce486ebe161262fb2c710",
|
||||
"components/sw-home-menu/types.d.ts": "81499bc8edf2f2acfb2f556d7f397019ba99046a808e0607cb96a29a67b6f315",
|
||||
"components/sw-popup/SWPopup.d.ts": "9af7b5f34bed9c863e343018ed64dc6960d09f5664fdfc1c26fcc7204acdd041",
|
||||
"components/sw-process-header/index.d.ts": "1fe66c0854bac5f2fa0eeb2bd0bb16056ab1af08750b9911841d34881ec7abb5",
|
||||
"components/sw-process-header/SWProcessBar.d.ts": "51e69c5dfeeaecee0db96b3b1511ee32d69667ea85aa67e01210bc9fe70854fb",
|
||||
"components/sw-process-header/SWProcessHeader.d.ts": "e7740f8a1131963a3e43b271ff1664fdd9f06a5fca60217501d7598f557e6b65",
|
||||
"components/sw-process-header/SWProcessSplitButton.d.ts": "b865f46609301677f9686a6d6d00e8caacfc94e5fdc776eb87fb8f93184f96ff",
|
||||
"components/sw-process-header/types.d.ts": "02778af5e73c4b3be04bc8fe939a105fe3588526ce10614cf43aebe1fce68849",
|
||||
"components/sw-select/SWSelect.d.ts": "9cf523e8a8329a2be7bdfba56c1a2c647b2676480efa3f6dfa26a971ea0a9c3b",
|
||||
"components/sw-slider/SWSlider.d.ts": "7d9c48299cb5644d859753e1bc56ae43dc7ab282e64432d57d3e29b2960aaad5",
|
||||
"components/sw-theme/SWTheme.d.ts": "cdcc2e7c4b80241afa4d0b7c3da671b7086c16f9b9c231c1927c931757e4244c",
|
||||
"components/sw-tool/SWTool.d.ts": "23b1eb156578cd3f1dee325afbd81988d88c4a3f421f7350a0e8c47c652e804c",
|
||||
"components/sw-viewer/index.d.ts": "e57a9ec0b91ae576b4b09b4851cd8e19b6fb4ea4bbdf62a578b4b94d7da6ffe2",
|
||||
"components/sw-viewer/SWViewerControls.d.ts": "ca32e8a2a7de2cfefec36273ffb473271ad438cfb663f44a25871ee6aed9222d",
|
||||
"components/sw-viewer/SWViewerHeader.d.ts": "586d07e74f5c2673fbe1d4849ed72cdcf80e7f8a391393168f101dc613490d24",
|
||||
"components/sw-viewer/SWViewerControls.d.ts": "a75bc7d54db126d1bd9999cf12dc3b7edfb24cb16f75ba44b2e9dda422623aa2",
|
||||
"components/sw-viewer/SWViewerHeader.d.ts": "0a46e07d7f6b33dc389cf4739e563be045cad12d84585384b1cc4d861c4acdbe",
|
||||
"components/sw-viewer/SWViewerIcon.d.ts": "3c81582860e481b037b395b3584ff93af73500e4040d8423c9da15c2b4dd108e",
|
||||
"components/sw-viewer/SWViewerMenu.d.ts": "15fa615c3472d84037780f3c93387acbc6b4d05f5fb56731b0a99262b3f7f109",
|
||||
"components/sw-viewer/SWViewerPanel.d.ts": "49b040d3f3d00de30e3fc0493fd2ce78e309616ec97a7ce226dfef300b640f92",
|
||||
"components/sw-viewer/types.d.ts": "e46b4605a3fcab78325bff526a374a891fdcf0256dc7824a9414d2336b2f85d7",
|
||||
"index.d.ts": "34f0607f63df53d5a1186a4ad763a8bf7b433e20645d868f6f7368ed0853828c"
|
||||
"components/sw-viewer/types.d.ts": "13b928b6871b9865e30929b37a17e6c820353f18477d96c1cc09a7d69e40cd18",
|
||||
"index.d.ts": "1e156f688534c117f880c7bd32f06110240caff3d02157db0265edb8e3eb41f5"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,15 @@ import { existsSync } from "node:fs"
|
||||
import { readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { approvedMainCommit } from "./storybook-inventory.mjs"
|
||||
// Reviewed main merge the approved surface must belong to (2026-11-10: own baseline, split from approvedMainCommit in
|
||||
// storybook-inventory.mjs, which also anchors the Story inventory / source regressions of overview, storybook-unused-
|
||||
// removal, dropdown-consolidation and storybook-cleanup-stage1; moving that one would accept their unrecorded Story and
|
||||
// source changes). Update it only in a reviewed test-only change that re-snapshots public-api.approved.json from a clean
|
||||
// build of that merge after its .d.ts changes were compared with the previous approved build.
|
||||
// History: approvedMainCommit fc65b2e (snapshot 8cb98b3) -> 3cbe879 (2026-11-10, Merge PR #30; snapshot from the clean
|
||||
// 3cbe879 build: SWProcess* exports of the user-approved Step Header adbb034 / e700825, optional shortLabel? of the SWViewerHeaderItem switch item in
|
||||
// cabd2f6, the internal SWHeaderLogo declaration of 205864b (not exported from the entry) and JSDoc-only changes).
|
||||
const approvedPublicApiMainCommit = "3cbe87991010f08b131e8b80c9f478c3fa721004"
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
||||
const snapshotFile = fileURLToPath(new URL("./public-api.approved.json", import.meta.url))
|
||||
@@ -82,7 +90,7 @@ if (process.argv[2] === "--snapshot") {
|
||||
const results = []
|
||||
const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) }
|
||||
const ancestor = (a, b) => { try { execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: packageRoot, stdio: "ignore" }); return true } catch { return false } }
|
||||
check("approved surface comes from a reviewed commit (ancestor of approvedMainCommit and HEAD)", ancestor(approved.approvedCommit, approvedMainCommit) && ancestor(approved.approvedCommit, "HEAD"), { approvedCommit: approved.approvedCommit, approvedMainCommit })
|
||||
check("approved surface comes from a reviewed commit (ancestor of approvedPublicApiMainCommit and HEAD)", ancestor(approved.approvedCommit, approvedPublicApiMainCommit) && ancestor(approved.approvedCommit, "HEAD"), { approvedCommit: approved.approvedCommit, approvedPublicApiMainCommit })
|
||||
|
||||
const manifest = await manifestOf(manifestFile)
|
||||
const changedFields = ["name", ...manifestFields].filter(field => JSON.stringify(manifest[field]) !== JSON.stringify(approved.manifest[field]))
|
||||
|
||||
@@ -60,10 +60,11 @@ const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).
|
||||
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
|
||||
|
||||
// More (2026-10-21): the header shows More only when its measured room runs out (full → icons → More). The Story's
|
||||
// 1000px header fits as icons, so the header is narrowed for this check (as text-weight does), then restored.
|
||||
// 1000px header fits as icons, so the header is narrowed for this check, then restored. 2026-11-10: 480px, since
|
||||
// cabd2f6 (2026-10-26) the viewer header keeps every menu down to about 520px and shows More only below that.
|
||||
async function openMore(page, area) {
|
||||
const header = page.locator(`${area} .hm-sw-viewer-header`)
|
||||
await header.evaluate(element => { element.style.width = "700px" })
|
||||
await header.evaluate(element => { element.style.width = "480px" })
|
||||
await page.locator(`${area} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
}
|
||||
const restoreWidth = (page, area) => page.locator(`${area} .hm-sw-viewer-header`).evaluate(element => { element.style.width = "" })
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
| ② SW UI — 뷰어 모드 | `@hm-design/sw`(같은 패키지) | `node_modules/@hm-design/sw/ai/` (`AI_USAGE.md` §0 → `VIEWER.md`) | `@hm-design/sw` | `@hm-design/sw/styles.css`(같은 파일) |
|
||||
| ③ OrgChart | `@hm-design/org-chart`(Core) + React면 `@hm-design/org-chart-react` | `node_modules/@hm-design/org-chart/ai/` (React 어댑터 안내는 `node_modules/@hm-design/org-chart-react/ai/AI_USAGE.md`) | `@hm-design/org-chart`, `@hm-design/org-chart-react`, 선택 `@hm-design/org-chart-react/hm-dialog` | `@hm-design/org-chart/engine.css`, `@hm-design/org-chart/style.css`(URL로 전달 — Shadow DOM 안에 로드) |
|
||||
| 기타 | `@hm-design/center-graphic` | AI 지침 없음. 패키지 `README.md`만 | `@hm-design/center-graphic`, 선택 `@hm-design/center-graphic/reference`(Storybook 검토용 참고 데모, 컴포넌트 API 아님) | JS 진입점이 자동 import. 명시가 필요하면 `@hm-design/center-graphic/style.css` |
|
||||
| 기타 — 차트 | `@hm-design/chart`(+ Theme 토큰용 `@hm-design/ui` peer) | AI 지침 폴더 없음. 패키지 `README.md`(`node_modules/@hm-design/chart/README.md`)가 설치·차트 선택·Props·검증 지침 | `@hm-design/chart`, 예시 데이터 `@hm-design/chart/examples`(운영 데이터 아님) | Tailwind 앱에서 `@hm-design/chart/tailwind.css` + `@hm-design/ui/tailwind.css` + `@hm-design/ui/theme.css`(순서는 README). 차트 스타일은 JS 진입점이 자동 import |
|
||||
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer). React·React DOM `^19`는 peer입니다.
|
||||
모든 패키지는 서로 독립입니다(`@hm-design/org-chart-react`만 Core에 의존, `@hm-design/ui`는 `/hm-dialog`를 쓸 때만 선택 peer, `@hm-design/chart`는 `@hm-design/ui`의 Theme CSS 토큰만 peer로 사용). React·React DOM `^19`는 `@hm-design/ui`·`@hm-design/sw`·`@hm-design/center-graphic`·`@hm-design/chart`의 peer이고, `@hm-design/org-chart-react`는 React `^19`만 peer로 요구합니다. `@hm-design/org-chart`(Core)는 peer가 없습니다.
|
||||
|
||||
## 1단계. 사용 목적 확인
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
| 컴포넌트 (Export) | 용도 | HM Props |
|
||||
|---|---|---|
|
||||
| `Calendar` | 달력(react-day-picker) | react-day-picker Props |
|
||||
| `DatePicker` / 타입 `DatePickerSize` | 날짜 입력 | `value`·`defaultValue`(Date), `onValueChange`, `controlled`, `placeholder`, `size`: `medium` · `large`(기본), `format`(기본 `YYYY.MM.DD`), `calendarProps`(disabled·startMonth·endMonth·locale·captionLayout·weekStartsOn·today) |
|
||||
| `DatePicker` / 타입 `DatePickerSize` | 날짜 입력 | `value`·`defaultValue`(Date), `onValueChange`, `controlled`, `placeholder`, `size`: `medium` · `large`(기본), `format`(`(date: Date) => string`, 기본 출력 `YYYY.MM.DD`), `calendarProps`(disabled·startMonth·endMonth·locale·captionLayout·weekStartsOn·today) |
|
||||
| `Combobox` / 타입 `ComboboxOption`, `ComboboxSize` | 검색 가능한 선택 | `options: { value, label, disabled? }[]`(필수), `value`·`defaultValue`, `onValueChange`, `controlled`, `placeholder`, `searchPlaceholder`, `emptyText`, `size`: `medium` · `large`(기본) |
|
||||
| `Command`, `CommandInput`, `CommandList`, `CommandEmpty`, `CommandGroup`, `CommandItem`, `CommandSeparator`, `CommandShortcut` | 명령 검색(cmdk) | 없음 |
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- 요청이 모드를 말하지 않으면 화면 목적(서비스 상단 메뉴 = 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 골격
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
- 토큰 범위: `:root, [data-hm-theme]`. Runtime Theme(`@hm-design/ui/theme`)가 같은 변수를 바꿉니다.
|
||||
- Light/Dark 분기 없음. 컴포넌트 CSS에 `html`·`body`·`*` 규칙을 추가하지 않습니다.
|
||||
- 이름을 추측하지 않습니다. 특히 `--hm-sem-text-*`, `--hm-sem-border-*`, `--hm-sem-stroke-divider`는 **선언되어 있지 않습니다**(아래 표의 이름을 씀). 없는 토큰을 fallback 색으로 감추지 않습니다.
|
||||
- 이름을 추측하지 않습니다. 특히 `--hm-sem-text-*`, `--hm-sem-stroke-divider`는 **선언되어 있지 않고**, `--hm-sem-border-*`는 Button 내부용 `--hm-sem-border-action-primary` 외에는 **선언되어 있지 않습니다**(아래 표의 이름을 씀). 없는 토큰을 fallback 색으로 감추지 않습니다.
|
||||
- Tailwind utility로는 shadcn 매핑 utility(`bg-card`, `text-primary`, `text-muted-foreground` 등)를 쓸 수 있습니다. HM 값과 다를 수 있는 임의 utility 색(`bg-green-700` 등)은 쓰지 않습니다.
|
||||
|
||||
## 1. 기본 팔레트 (primitives → `--hm-sem-palette-*`)
|
||||
|
||||
| 역할 | 값 | 변수 |
|
||||
|---|---|---|
|
||||
| Green(주색) | `#123328` | `--hm-sem-palette-green-default` |
|
||||
| Green 진한 글자 | `#0A221D` | `--hm-sem-palette-green-dark-1` |
|
||||
| Green(주색) | `#164434` | `--hm-sem-palette-green-default` |
|
||||
| Green 진한 글자 | `#11372D` | `--hm-sem-palette-green-dark-1` |
|
||||
| Green Focus·Active | `#266651` | `--hm-sem-palette-green-light-2` |
|
||||
| Green Tint(강조 면) | `#BDDFD4` | `--hm-sem-palette-green-tint` |
|
||||
| Beige(콘텐츠 면) | `#F5F0E5` | `--hm-sem-palette-beige-light` |
|
||||
@@ -68,6 +68,7 @@ Brown·Orange 계열 팔레트(`--hm-sem-palette-brown-*`, `--hm-sem-palette-ora
|
||||
| Select Trigger 높이 | medium 34 · large 44(기본) | — |
|
||||
| Control radius | 4px | `--hm-radius-control-md` → `--hm-radius-button`, `--hm-radius-input`, `--hm-radius-select`, `--hm-radius-container` |
|
||||
| Card | border 1px, padding 24px, radius 4px, 그림자 없음 | `--hm-card-*` |
|
||||
| LNB(Desktop) | 열 240px, 메뉴 14/28/700, 1뎁스 행 48 · 2·3뎁스 행 42, Profile 48, 콘텐츠 접점 18. 모바일 Header 56 · 링크 44 | `--hm-lnb-width`, `--hm-lnb-item-*`, `--hm-lnb-subitem-*`, `--hm-lnb-third-*`, `--hm-lnb-profile-min-height`, `--hm-lnb-join` |
|
||||
| Dialog | radius 8px, padding 24px, Header 아래 16px, Footer 위 16px, 버튼 간격 8px, 그림자 `0 0 12px` 검정 20% | `--hm-dialog-*` |
|
||||
|
||||
## 5. 상태
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
export const runtimeThemeDefaults = {
|
||||
"--hm-sem-bg-content": "#f5f0e5",
|
||||
"--hm-sem-bg-surface": "#ffffff",
|
||||
"--hm-sem-fg-primary": "#0a221d",
|
||||
"--hm-sem-fg-primary": "#11372d",
|
||||
"--hm-sem-fg-secondary": "#6e6759",
|
||||
"--hm-sem-fg-placeholder": "#999999",
|
||||
"--hm-sem-action-primary": "#123328",
|
||||
"--hm-sem-action-primary": "#164434",
|
||||
"--hm-sem-action-cancel": "#6e6759",
|
||||
"--hm-sem-action-destructive": "#d32f2f",
|
||||
"--hm-sem-status-error": "#b3261e",
|
||||
"--hm-sem-stroke-default": "#9a9284",
|
||||
"--hm-sem-stroke-input-default": "#dedbd2",
|
||||
"--hm-sem-stroke-input-focus": "#266651",
|
||||
"--hm-sem-brand-bg-stop-0": "#0a221d",
|
||||
"--hm-sem-brand-bg-stop-12": "#071e18",
|
||||
"--hm-sem-brand-bg-stop-42": "#123328",
|
||||
"--hm-sem-brand-bg-stop-0": "#11372d",
|
||||
"--hm-sem-brand-bg-stop-12": "#0b2b23",
|
||||
"--hm-sem-brand-bg-stop-42": "#164434",
|
||||
"--hm-sem-brand-bg-stop-78": "#266651",
|
||||
"--hm-sem-brand-bg-stop-100": "#205241",
|
||||
"--hm-sem-brown-bg-stop-0": "#352d1d",
|
||||
"--hm-sem-brown-bg-stop-0": "#403726",
|
||||
"--hm-sem-brown-bg-stop-12": "#2f281c",
|
||||
"--hm-sem-brown-bg-stop-42": "#3e3523",
|
||||
"--hm-sem-brown-bg-stop-42": "#4e422d",
|
||||
"--hm-sem-brown-bg-stop-78": "#897445",
|
||||
"--hm-sem-brown-bg-stop-100": "#604f32",
|
||||
"--hm-sem-orange-bg-stop-0": "#bc4c00",
|
||||
@@ -32,7 +32,7 @@ export const runtimeThemeDefaults = {
|
||||
"--hm-sem-action-surface-stop-30": "#165c47",
|
||||
"--hm-sem-action-surface-stop-65": "#114838",
|
||||
"--hm-sem-action-surface-stop-100": "#08241c",
|
||||
"--hm-sem-action-border-stop-0": "#0a221d",
|
||||
"--hm-sem-action-border-stop-0": "#11372d",
|
||||
"--hm-sem-action-border-stop-50": "#34daaa",
|
||||
"--hm-sem-action-border-stop-100": "#08241c",
|
||||
"--hm-radius-input": "4px",
|
||||
@@ -47,13 +47,13 @@ export const runtimeThemeDefaults = {
|
||||
"--hm-sem-palette-green-light-3": "#249473",
|
||||
"--hm-sem-palette-green-light-2": "#266651",
|
||||
"--hm-sem-palette-green-light-1": "#205241",
|
||||
"--hm-sem-palette-green-default": "#123328",
|
||||
"--hm-sem-palette-green-dark-1": "#0a221d",
|
||||
"--hm-sem-palette-green-dark-2": "#071e18",
|
||||
"--hm-sem-palette-green-default": "#164434",
|
||||
"--hm-sem-palette-green-dark-1": "#11372d",
|
||||
"--hm-sem-palette-green-dark-2": "#0b2b23",
|
||||
"--hm-sem-palette-brown-light-2": "#897445",
|
||||
"--hm-sem-palette-brown-light-1": "#604f32",
|
||||
"--hm-sem-palette-brown-default": "#3e3523",
|
||||
"--hm-sem-palette-brown-dark-1": "#352d1d",
|
||||
"--hm-sem-palette-brown-default": "#4e422d",
|
||||
"--hm-sem-palette-brown-dark-1": "#403726",
|
||||
"--hm-sem-palette-brown-light-4": "#ecd4a2",
|
||||
"--hm-sem-palette-orange-light-4": "#feba88",
|
||||
"--hm-sem-palette-orange-default": "#cc5200",
|
||||
@@ -85,10 +85,10 @@ export const runtimeThemeDefaults = {
|
||||
"--hm-sem-palette-gray": "#767676",
|
||||
"--hm-sem-palette-gray-dark": "#5a5a5a",
|
||||
"--hm-sem-palette-indicator": "#2d2d2d",
|
||||
"--hm-sem-palette-green-dark-3": "#020a07",
|
||||
"--hm-sem-palette-green-dark-3": "#030e0a",
|
||||
"--hm-sem-palette-brown-light-3": "#c1a770",
|
||||
"--hm-sem-palette-brown-dark-2": "#2f281c",
|
||||
"--hm-sem-palette-brown-dark-3": "#1d1810",
|
||||
"--hm-sem-palette-brown-dark-3": "#0d0b07",
|
||||
"--hm-sem-palette-orange-light-3": "#ec8f53",
|
||||
"--hm-sem-palette-orange-light-2": "#f87725",
|
||||
"--hm-sem-palette-orange-light-1": "#eb5f00",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Black 10% to the same edge; an open group is a Black 8% face with a 1px green line. Spacing values are pattern-local. */
|
||||
:root, [data-hm-theme] {
|
||||
/* Exposed for the layout: grid column width and the surface that continues behind the content's rounded corners. */
|
||||
--hm-lnb-width: 272px;
|
||||
--hm-lnb-width: 240px;
|
||||
--hm-lnb-join: 18px;
|
||||
--hm-lnb-background: linear-gradient(90deg, var(--hm-sem-action-primary) 0%, var(--hm-sem-palette-green-light-1) 30%, var(--hm-sem-palette-green-light-2) 50%, var(--hm-sem-palette-green-light-3) 90%, var(--hm-sem-palette-green-light-2) 100%);
|
||||
--hm-lnb-background-end: var(--hm-sem-palette-green-light-2);
|
||||
@@ -29,10 +29,12 @@
|
||||
--hm-lnb-brand-name-size: 17px;
|
||||
--hm-lnb-brand-text-gap: 5px;
|
||||
|
||||
--hm-lnb-item-min-height: 56px;
|
||||
--hm-lnb-item-min-height: 48px;
|
||||
/* 1-depth row: 28px line + 2 x 10px = 48px. */
|
||||
--hm-lnb-item-padding-block: 10px;
|
||||
--hm-lnb-item-padding-start: 18px;
|
||||
--hm-lnb-item-gap: 12px;
|
||||
--hm-lnb-item-font-size: 18px;
|
||||
--hm-lnb-item-font-size: 14px;
|
||||
--hm-lnb-item-line-height: 28px;
|
||||
--hm-lnb-item-font-weight: 700;
|
||||
--hm-lnb-item-radius: 4px 0 0 4px;
|
||||
@@ -46,11 +48,38 @@
|
||||
--hm-lnb-current-background: var(--hm-sem-bg-content);
|
||||
--hm-lnb-current-text: var(--hm-sem-action-primary);
|
||||
--hm-lnb-current-radius: 8px 0 0 8px;
|
||||
/* Inset depth of the current face: left edge, top, bottom; the last quarter fades so the content side stays open. */
|
||||
/* Inset depth of the current face (left, top, bottom), matched to the Storybook Manager's selected item: inset box-shadows
|
||||
6px 0 8px -5px Black 30%, 0 3px 5px -3px Black 20% and 0 -2px 4px -3px Black 20%, faded out over the last quarter. A background
|
||||
cannot hold a box-shadow and ::before / ::after already draw the concave corners, so the blurred falloff is sampled as gradient
|
||||
stops (left 10px, top 5px, bottom 4px), and the first layer repaints the face color from 75% to 100% so the depth fades toward
|
||||
the content side. */
|
||||
--hm-lnb-current-depth:
|
||||
linear-gradient(to right, color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent), transparent 6px) 0 0 / 75% 100% no-repeat,
|
||||
linear-gradient(to bottom, color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent), transparent 3px) 0 0 / 75% 100% no-repeat,
|
||||
linear-gradient(to top, color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent), transparent 3px) 0 0 / 75% 100% no-repeat;
|
||||
linear-gradient(to right, transparent, var(--hm-lnb-current-background)) 100% 0 / 25% 100% no-repeat,
|
||||
linear-gradient(to right,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent) 0,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 15.7%, transparent) 1px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 12.2%, transparent) 2px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 9.5%, transparent) 3px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 7.1%, transparent) 4px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 5%, transparent) 5px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 3.3%, transparent) 6px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 2.2%, transparent) 7px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 1.4%, transparent) 8px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 0.8%, transparent) 9px,
|
||||
transparent 10px),
|
||||
linear-gradient(to bottom,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 12%, transparent) 0,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 8.5%, transparent) 1px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 5%, transparent) 2px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 2.3%, transparent) 3px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 0.8%, transparent) 4px,
|
||||
transparent 5px),
|
||||
linear-gradient(to top,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 9%, transparent) 0,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 5.1%, transparent) 1px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 2.4%, transparent) 2px,
|
||||
color-mix(in srgb, var(--hm-sem-palette-black) 0.7%, transparent) 3px,
|
||||
transparent 4px);
|
||||
|
||||
--hm-lnb-group-open-background: color-mix(in srgb, var(--hm-sem-palette-black) 8%, transparent);
|
||||
--hm-lnb-group-open-line: 1px solid var(--hm-sem-palette-green-light-2);
|
||||
@@ -58,14 +87,17 @@
|
||||
--hm-lnb-group-current-text: var(--hm-sem-palette-yellow-accent);
|
||||
--hm-lnb-group-arrow-size: 18px;
|
||||
--hm-lnb-subitem-padding-start: 68px;
|
||||
/* 2-depth row (links directly in a group, nested group triggers): 28px line + 2 x 7px = 42px. */
|
||||
--hm-lnb-subitem-min-height: 42px;
|
||||
--hm-lnb-subitem-padding-block: 7px;
|
||||
/* Align the 2-depth link surface with the 1-depth group text; retain its existing text inset. */
|
||||
--hm-lnb-subitem-surface-inset: calc(var(--hm-lnb-item-padding-start) + var(--hm-lnb-group-arrow-size) + var(--hm-lnb-item-gap));
|
||||
--hm-lnb-group-padding-bottom: 8px;
|
||||
|
||||
/* 3-depth: the items of an `LNBGroup` nested in a group. Same 18px; the hierarchy is the extra 20px indent and the connector
|
||||
(a vertical line at the 2-depth text start, a 12px branch to each row's middle, 8px to the text; it ends at the last row). */
|
||||
--hm-lnb-third-min-height: 48px;
|
||||
--hm-lnb-third-padding-block: 12px;
|
||||
/* 3-depth: the items of an `LNBGroup` nested in a group. Same font size as the other depths; the hierarchy is the extra 20px indent and the connector
|
||||
(a vertical line at the 2-depth text start, a 12px branch to each row's middle, 8px to the text; it ends at the last row). 42px = 24px line + 9px padding x2. */
|
||||
--hm-lnb-third-min-height: 42px;
|
||||
--hm-lnb-third-padding-block: 9px;
|
||||
--hm-lnb-third-padding-start: 88px;
|
||||
--hm-lnb-third-line-height: 24px;
|
||||
--hm-lnb-third-font-weight: 500;
|
||||
@@ -79,6 +111,7 @@
|
||||
--hm-lnb-profile-padding-x: 30px;
|
||||
--hm-lnb-profile-padding-top: 22px;
|
||||
--hm-lnb-profile-gap: 12px;
|
||||
--hm-lnb-profile-min-height: 48px;
|
||||
--hm-lnb-profile-name-size: 16px;
|
||||
--hm-lnb-profile-description-size: 12px;
|
||||
}
|
||||
@@ -150,7 +183,7 @@
|
||||
max-width: calc(100% - 2 * (var(--hm-lnb-profile-padding-x) - 8px));
|
||||
margin-top: auto;
|
||||
margin-inline: calc(var(--hm-lnb-profile-padding-x) - 8px);
|
||||
min-height: var(--hm-lnb-third-min-height);
|
||||
min-height: var(--hm-lnb-profile-min-height);
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -211,7 +244,7 @@
|
||||
gap: var(--hm-lnb-item-gap);
|
||||
width: calc(100% - 1px);
|
||||
min-height: var(--hm-lnb-item-min-height);
|
||||
padding: 14px calc(var(--hm-lnb-inset-end) + 12px) 14px var(--hm-lnb-item-padding-start);
|
||||
padding: var(--hm-lnb-item-padding-block) calc(var(--hm-lnb-inset-end) + 12px) var(--hm-lnb-item-padding-block) var(--hm-lnb-item-padding-start);
|
||||
border: 0;
|
||||
border-radius: var(--hm-lnb-item-radius);
|
||||
background: transparent;
|
||||
@@ -329,6 +362,13 @@
|
||||
[data-slot="lnb-group-content"] [data-slot="lnb-item"]:not([aria-current="page"]):hover {
|
||||
border-radius: var(--hm-lnb-item-radius);
|
||||
}
|
||||
/* 2-depth rows (links directly in a group, nested group triggers): 42px = 28px line + 2 x 7px. */
|
||||
[data-slot="lnb-group-content"] > [data-slot="lnb-item"],
|
||||
[data-slot="lnb-group-content"] [data-slot="lnb-group-trigger"] {
|
||||
min-height: var(--hm-lnb-subitem-min-height);
|
||||
padding-top: var(--hm-lnb-subitem-padding-block);
|
||||
padding-bottom: var(--hm-lnb-subitem-padding-block);
|
||||
}
|
||||
[data-slot="lnb-group-content"] [data-slot="lnb-item"][aria-current="page"] {
|
||||
width: calc(100% + 1px);
|
||||
margin-right: -1px;
|
||||
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user