@iamfiro/design 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +81 -0
- package/README.md +15 -2
- package/agents/INDEX.md +156 -0
- package/agents/accordion/AGENTS.md +279 -0
- package/agents/action-bar/AGENTS.md +90 -0
- package/agents/alert/AGENTS.md +212 -0
- package/agents/alert-dialog/AGENTS.md +193 -0
- package/agents/app-shell/AGENTS.md +409 -0
- package/agents/area-chart/AGENTS.md +183 -0
- package/agents/aspect-ratio/AGENTS.md +143 -0
- package/agents/avatar/AGENTS.md +130 -0
- package/agents/badge/AGENTS.md +217 -0
- package/agents/bar-chart/AGENTS.md +386 -0
- package/agents/bar-list/AGENTS.md +223 -0
- package/agents/brand-icon/AGENTS.md +82 -0
- package/agents/breadcrumb/AGENTS.md +133 -0
- package/agents/button/AGENTS.md +195 -0
- package/agents/card/AGENTS.md +141 -0
- package/agents/card-frame/AGENTS.md +227 -0
- package/agents/chat-bubble/AGENTS.md +266 -0
- package/agents/chat-composer/AGENTS.md +251 -0
- package/agents/checkbox/AGENTS.md +113 -0
- package/agents/code-block/AGENTS.md +144 -0
- package/agents/color-dot/AGENTS.md +120 -0
- package/agents/color-picker/AGENTS.md +224 -0
- package/agents/colors/AGENTS.md +168 -0
- package/agents/combobox/AGENTS.md +218 -0
- package/agents/command-menu/AGENTS.md +169 -0
- package/agents/comment/AGENTS.md +206 -0
- package/agents/context-menu/AGENTS.md +303 -0
- package/agents/copy-button/AGENTS.md +84 -0
- package/agents/count-badge/AGENTS.md +115 -0
- package/agents/countdown/AGENTS.md +101 -0
- package/agents/data-grid/AGENTS.md +460 -0
- package/agents/date-picker/AGENTS.md +197 -0
- package/agents/delta/AGENTS.md +114 -0
- package/agents/description-list/AGENTS.md +251 -0
- package/agents/dialog/AGENTS.md +391 -0
- package/agents/drawer/AGENTS.md +471 -0
- package/agents/dropdown-menu/AGENTS.md +322 -0
- package/agents/dropzone/AGENTS.md +116 -0
- package/agents/elapsed/AGENTS.md +160 -0
- package/agents/empty-state/AGENTS.md +203 -0
- package/agents/file-chip/AGENTS.md +79 -0
- package/agents/file-icon/AGENTS.md +66 -0
- package/agents/file-upload/AGENTS.md +168 -0
- package/agents/filter-chips/AGENTS.md +167 -0
- package/agents/flag/AGENTS.md +67 -0
- package/agents/funnel-chart/AGENTS.md +131 -0
- package/agents/highlight/AGENTS.md +119 -0
- package/agents/hover-card/AGENTS.md +271 -0
- package/agents/icon-stack/AGENTS.md +120 -0
- package/agents/icon-tile/AGENTS.md +94 -0
- package/agents/inline-diff/AGENTS.md +211 -0
- package/agents/input/AGENTS.md +261 -0
- package/agents/kanban/AGENTS.md +352 -0
- package/agents/kbd/AGENTS.md +160 -0
- package/agents/layout/AGENTS.md +351 -0
- package/agents/line-chart/AGENTS.md +393 -0
- package/agents/link/AGENTS.md +53 -0
- package/agents/list/AGENTS.md +392 -0
- package/agents/marker/AGENTS.md +142 -0
- package/agents/masked-text/AGENTS.md +127 -0
- package/agents/menubar/AGENTS.md +276 -0
- package/agents/multi-select/AGENTS.md +159 -0
- package/agents/navigation-menu/AGENTS.md +315 -0
- package/agents/notice-stack/AGENTS.md +101 -0
- package/agents/number-stepper/AGENTS.md +96 -0
- package/agents/otp-input/AGENTS.md +162 -0
- package/agents/pagination/AGENTS.md +189 -0
- package/agents/phone-input/AGENTS.md +131 -0
- package/agents/pie-chart/AGENTS.md +243 -0
- package/agents/popover/AGENTS.md +190 -0
- package/agents/progress/AGENTS.md +273 -0
- package/agents/progress-tracker/AGENTS.md +305 -0
- package/agents/rating/AGENTS.md +165 -0
- package/agents/relative-time/AGENTS.md +103 -0
- package/agents/resizable/AGENTS.md +390 -0
- package/agents/scaled-preview/AGENTS.md +111 -0
- package/agents/scatter-chart/AGENTS.md +160 -0
- package/agents/scroll-area/AGENTS.md +271 -0
- package/agents/search-input/AGENTS.md +105 -0
- package/agents/segmented-control/AGENTS.md +153 -0
- package/agents/select/AGENTS.md +159 -0
- package/agents/selector-button/AGENTS.md +122 -0
- package/agents/sidebar/AGENTS.md +201 -0
- package/agents/skeleton/AGENTS.md +166 -0
- package/agents/slider/AGENTS.md +181 -0
- package/agents/spark-chart/AGENTS.md +257 -0
- package/agents/sparkbar/AGENTS.md +92 -0
- package/agents/spinner/AGENTS.md +123 -0
- package/agents/split-view/AGENTS.md +290 -0
- package/agents/stat/AGENTS.md +138 -0
- package/agents/status-icon/AGENTS.md +243 -0
- package/agents/step-list/AGENTS.md +313 -0
- package/agents/table/AGENTS.md +286 -0
- package/agents/tabs/AGENTS.md +305 -0
- package/agents/tag-input/AGENTS.md +246 -0
- package/agents/time-picker/AGENTS.md +228 -0
- package/agents/timeline/AGENTS.md +1018 -0
- package/agents/toast/AGENTS.md +266 -0
- package/agents/toggle-group/AGENTS.md +216 -0
- package/agents/toolbar/AGENTS.md +277 -0
- package/agents/tooltip/AGENTS.md +154 -0
- package/agents/tracker/AGENTS.md +158 -0
- package/agents/tree-diagram/AGENTS.md +260 -0
- package/agents/tree-view/AGENTS.md +329 -0
- package/agents/typography/AGENTS.md +396 -0
- package/lib/index.js +17914 -0
- package/lib/style.css +2 -0
- package/lib/types/components/Accordion/Accordion.d.ts +57 -0
- package/lib/types/components/ActionBar/ActionBar.d.ts +24 -0
- package/lib/types/components/Alert/Alert.d.ts +36 -0
- package/lib/types/components/AppShell/AppShell.d.ts +38 -0
- package/lib/types/components/AreaChart/AreaChart.d.ts +17 -0
- package/lib/types/components/BarChart/BarChart.d.ts +57 -0
- package/lib/types/components/BarList/BarList.d.ts +56 -0
- package/lib/types/components/Breadcrumb/Breadcrumb.d.ts +15 -0
- package/lib/types/components/Calendar/Calendar.d.ts +34 -0
- package/lib/types/components/Calendar/DatePicker.d.ts +45 -0
- package/lib/types/components/Calendar/date.d.ts +32 -0
- package/lib/types/components/Card/Card.d.ts +64 -0
- package/lib/types/components/CardFrame/CardFrame.d.ts +15 -0
- package/lib/types/components/Chart/ChartParts.d.ts +38 -0
- package/lib/types/components/Chart/chart.d.ts +46 -0
- package/lib/types/components/Chart/useChartInteraction.d.ts +39 -0
- package/lib/types/components/ChatBubble/ChatBubble.d.ts +61 -0
- package/lib/types/components/ChatBubble/formatChatTime.d.ts +2 -0
- package/lib/types/components/ChatComposer/ChatComposer.d.ts +42 -0
- package/lib/types/components/CodeBlock/CodeBlock.d.ts +18 -0
- package/lib/types/components/CodeBlock/highlight.d.ts +10 -0
- package/lib/types/components/ColorPicker/ColorPicker.d.ts +61 -0
- package/lib/types/components/ColorPicker/color.d.ts +48 -0
- package/lib/types/components/ColorPicker/presets.d.ts +7 -0
- package/lib/types/components/Combobox/Combobox.d.ts +48 -0
- package/lib/types/components/CommandMenu/CommandMenu.d.ts +51 -0
- package/lib/types/components/Comment/Comment.d.ts +45 -0
- package/lib/types/components/ContextMenu/ContextMenu.d.ts +40 -0
- package/lib/types/components/DataGrid/DataGrid.d.ts +43 -0
- package/lib/types/components/DataGrid/GridPopover.d.ts +43 -0
- package/lib/types/components/DataGrid/anchor.d.ts +5 -0
- package/lib/types/components/DataGrid/model.d.ts +61 -0
- package/lib/types/components/DataGrid/panels.d.ts +63 -0
- package/lib/types/components/DataGrid/typeMeta.d.ts +6 -0
- package/lib/types/components/DataGrid/types.d.ts +77 -0
- package/lib/types/components/DescriptionList/DescriptionList.d.ts +77 -0
- package/lib/types/components/Dialog/AlertDialog.d.ts +26 -0
- package/lib/types/components/Dialog/Dialog.d.ts +42 -0
- package/lib/types/components/Dialog/modal.d.ts +38 -0
- package/lib/types/components/Drawer/Drawer.d.ts +62 -0
- package/lib/types/components/DropdownMenu/DropdownMenu.d.ts +97 -0
- package/lib/types/components/DropdownMenu/menuCore.d.ts +23 -0
- package/lib/types/components/Dropzone/Dropzone.d.ts +30 -0
- package/lib/types/components/EmptyState/EmptyState.d.ts +26 -0
- package/lib/types/components/FileChip/FileChip.d.ts +32 -0
- package/lib/types/components/FileUpload/FileUpload.d.ts +52 -0
- package/lib/types/components/FilterChips/FilterChips.d.ts +37 -0
- package/lib/types/components/FunnelChart/FunnelChart.d.ts +38 -0
- package/lib/types/components/FunnelChart/funnel.d.ts +25 -0
- package/lib/types/components/Highlight/Highlight.d.ts +35 -0
- package/lib/types/components/HoverCard/HoverCard.d.ts +47 -0
- package/lib/types/components/InlineDiff/InlineDiff.d.ts +36 -0
- package/lib/types/components/InlineDiff/diff.d.ts +42 -0
- package/lib/types/components/InlineDiff/useDiffReview.d.ts +57 -0
- package/lib/types/components/Kanban/Kanban.d.ts +64 -0
- package/lib/types/components/Kanban/KanbanCard.d.ts +35 -0
- package/lib/types/components/Kanban/move.d.ts +25 -0
- package/lib/types/components/LineChart/LineChart.d.ts +85 -0
- package/lib/types/components/List/List.d.ts +62 -0
- package/lib/types/components/Listbox/Listbox.d.ts +60 -0
- package/lib/types/components/Marker/Marker.d.ts +28 -0
- package/lib/types/components/Menubar/Menubar.d.ts +50 -0
- package/lib/types/components/MultiSelect/MultiSelect.d.ts +38 -0
- package/lib/types/components/NavigationMenu/NavigationMenu.d.ts +101 -0
- package/lib/types/components/NoticeStack/NoticeStack.d.ts +41 -0
- package/lib/types/components/NumberStepper/NumberStepper.d.ts +32 -0
- package/lib/types/components/OtpInput/OtpInput.d.ts +31 -0
- package/lib/types/components/Pagination/Pagination.d.ts +54 -0
- package/lib/types/components/Pagination/paginationRange.d.ts +7 -0
- package/lib/types/components/PhoneInput/PhoneInput.d.ts +47 -0
- package/lib/types/components/PieChart/PieChart.d.ts +67 -0
- package/lib/types/components/PieChart/arc.d.ts +26 -0
- package/lib/types/components/PieChart/useArcTween.d.ts +15 -0
- package/lib/types/components/Popover/Popover.d.ts +28 -0
- package/lib/types/components/ProgressTracker/ProgressTracker.d.ts +64 -0
- package/lib/types/components/Rating/Rating.d.ts +42 -0
- package/lib/types/components/Resizable/Resizable.d.ts +50 -0
- package/lib/types/components/Resizable/layout.d.ts +31 -0
- package/lib/types/components/ScaledPreview/ScaledPreview.d.ts +38 -0
- package/lib/types/components/ScatterChart/ScatterChart.d.ts +81 -0
- package/lib/types/components/ScatterChart/scatter.d.ts +14 -0
- package/lib/types/components/ScrollArea/ScrollArea.d.ts +34 -0
- package/lib/types/components/Select/Select.d.ts +39 -0
- package/lib/types/components/Sidebar/Sidebar.d.ts +39 -0
- package/lib/types/components/Skeleton/Skeleton.d.ts +47 -0
- package/lib/types/components/Slider/Slider.d.ts +36 -0
- package/lib/types/components/SparkChart/SparkChart.d.ts +55 -0
- package/lib/types/components/SparkChart/spark.d.ts +16 -0
- package/lib/types/components/Sparkbar/Sparkbar.d.ts +39 -0
- package/lib/types/components/SplitView/SplitView.d.ts +31 -0
- package/lib/types/components/Stat/Stat.d.ts +58 -0
- package/lib/types/components/StepList/StepList.d.ts +61 -0
- package/lib/types/components/Table/Table.d.ts +39 -0
- package/lib/types/components/Tabs/Tabs.d.ts +47 -0
- package/lib/types/components/TagInput/TagInput.d.ts +46 -0
- package/lib/types/components/TimePicker/TimePicker.d.ts +43 -0
- package/lib/types/components/TimePicker/time.d.ts +15 -0
- package/lib/types/components/Timeline/Timeline.d.ts +154 -0
- package/lib/types/components/Timeline/scale.d.ts +22 -0
- package/lib/types/components/Toast/Toaster.d.ts +19 -0
- package/lib/types/components/Toast/index.d.ts +4 -0
- package/lib/types/components/Toast/store.d.ts +75 -0
- package/lib/types/components/ToggleGroup/ToggleGroup.d.ts +28 -0
- package/lib/types/components/Toolbar/Toolbar.d.ts +70 -0
- package/lib/types/components/Tracker/Tracker.d.ts +43 -0
- package/lib/types/components/TreeDiagram/TreeDiagram.d.ts +68 -0
- package/lib/types/components/TreeView/TreeView.d.ts +107 -0
- package/lib/types/components/index.d.ts +186 -0
- package/lib/types/hooks/index.d.ts +17 -0
- package/lib/types/hooks/timing.d.ts +29 -0
- package/lib/types/hooks/useControllableState.d.ts +5 -0
- package/lib/types/hooks/useCountdown.d.ts +19 -0
- package/lib/types/hooks/useDigitInput.d.ts +24 -0
- package/lib/types/hooks/useDismiss.d.ts +3 -0
- package/lib/types/hooks/useElapsed.d.ts +28 -0
- package/lib/types/hooks/useFloating.d.ts +21 -0
- package/lib/types/hooks/useFocusTrap.d.ts +7 -0
- package/lib/types/hooks/usePresence.d.ts +8 -0
- package/lib/types/hooks/usePressToOpen.d.ts +20 -0
- package/lib/types/hooks/useScrollLock.d.ts +2 -0
- package/lib/types/hooks/useSelection.d.ts +29 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/primitives/AspectRatio.d.ts +14 -0
- package/lib/types/primitives/BrandIcon.d.ts +12 -0
- package/lib/types/primitives/ColorDot.d.ts +20 -0
- package/lib/types/primitives/CopyButton.d.ts +12 -0
- package/lib/types/primitives/CountBadge.d.ts +28 -0
- package/lib/types/primitives/Countdown.d.ts +21 -0
- package/lib/types/primitives/Delta.d.ts +33 -0
- package/lib/types/primitives/Elapsed.d.ts +23 -0
- package/lib/types/primitives/FileIcon.d.ts +17 -0
- package/lib/types/primitives/Flag.d.ts +15 -0
- package/lib/types/primitives/IconStack.d.ts +27 -0
- package/lib/types/primitives/IconTile.d.ts +24 -0
- package/lib/types/primitives/Kbd.d.ts +19 -0
- package/lib/types/primitives/Layout.d.ts +64 -0
- package/lib/types/primitives/Link.d.ts +12 -0
- package/lib/types/primitives/MaskedText.d.ts +28 -0
- package/lib/types/primitives/Progress.d.ts +52 -0
- package/lib/types/primitives/RelativeTime.d.ts +22 -0
- package/lib/types/primitives/SearchInput.d.ts +8 -0
- package/lib/types/primitives/SelectorButton.d.ts +23 -0
- package/lib/types/primitives/Spinner.d.ts +15 -0
- package/lib/types/primitives/StatusIcon.d.ts +40 -0
- package/lib/types/primitives/Swatch.d.ts +11 -0
- package/lib/types/primitives/Tooltip.d.ts +17 -0
- package/lib/types/primitives/Typography.d.ts +48 -0
- package/lib/types/primitives/formatCountdown.d.ts +2 -0
- package/lib/types/primitives/formatElapsed.d.ts +8 -0
- package/lib/types/primitives/formatRelativeTime.d.ts +16 -0
- package/lib/types/primitives/hotkey.d.ts +10 -0
- package/lib/types/primitives/index.d.ts +188 -0
- package/lib/types/primitives/maskValue.d.ts +24 -0
- package/package.json +54 -4
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
# Timeline
|
|
2
|
+
|
|
3
|
+
> 대화 & 활동 · `@iamfiro/design` · 직접 만들지 말고 이 컴포넌트를 쓴다.
|
|
4
|
+
|
|
5
|
+
숫자 구간(초, 분, 시각)을 가로 위치로 옮겨 그리는 시간 축. 위에는 눈금자와 아주 옅은 세로 그리드, 왼쪽에는 이름 칸, 오른쪽 트랙에는 구간 막대가 놓여요. 지금 위치(now)와 세로 표시선(markers)이 모든 행을 가로지르고, 막대는 처음에 시작점부터 쓸려 나오며 값이 바뀌면 새 위치·길이로 미끄러져요. 계획 대비 실제 일정표, 단계별 소요 시간(워터폴), 하루 일정에 써요.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { Timeline, TimelineRow, TimelineBar, formatClock, formatOffset } from '@iamfiro/design'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 디자인 규칙
|
|
12
|
+
|
|
13
|
+
- 시간 축(계획 대비 실제, 워터폴, 하루 일정)은 `Timeline` + `TimelineRow` + `TimelineBar`로. 계획 = 점선 틀(`outline`), 실제 = 옅은 색 막대(`fill`), 계획 초과 = 주황(`limit`), 아직 = `faded` 행. 지금은 파란 `now` 선, 목표·예상은 `markers`(점선/실선 + 아래 Badge 칩 + 옅은 `band`). 진행 중 막대는 `running`(앞 가장자리 + shimmer). 범례 색 표시는 같은 옅기의 `ColorDot variant="soft" | "dashed" | "hatched"`.
|
|
14
|
+
|
|
15
|
+
## API
|
|
16
|
+
|
|
17
|
+
### Timeline
|
|
18
|
+
|
|
19
|
+
| prop | 타입 | 기본값 | 설명 |
|
|
20
|
+
| --- | --- | --- | --- |
|
|
21
|
+
| `max` (필수) | `number` | | 축의 끝. 가장 긴 막대 뒤 label이 들어갈 여유를 두세요. |
|
|
22
|
+
| `min` | `number` | `0` | 축의 시작. |
|
|
23
|
+
| `step` | `number` | | 눈금 간격. 생략하면 눈금이 6개쯤 되는 둥근 값(1·2·5×10ⁿ). |
|
|
24
|
+
| `ticks` | `number[]` | | step 대신 눈금 위치를 직접 지정. |
|
|
25
|
+
| `formatTick` | `(value) => string` | `String` | 눈금·툴팁·스크린 리더 문구의 값 형식. m:ss는 formatClock. |
|
|
26
|
+
| `grid` | `boolean` | `true` | 눈금마다 아주 옅은 세로선. |
|
|
27
|
+
| `now` | `number` | | 눈금자 위 점 + 모든 행을 지나는 파란 선. |
|
|
28
|
+
| `nowLabel` | `ReactNode` | | 점 대신 눈금자에 놓는 작은 파란 칩("지금"). 같은 위치의 눈금 글자는 비켜 줘요. |
|
|
29
|
+
| `nowLine` | `boolean` | `true` | now 선을 행 위로 그릴지. false면 눈금자 칩만. |
|
|
30
|
+
| `markers` | `TimelineMarker[]` | | 세로 표시선. { value, tone?, line?, label?, labelSide?, band? } |
|
|
31
|
+
| `links` | `TimelineLink[]` | | 막대 사이 점선 곡선. { from, to, tone? } — TimelineBar의 id. 행과 막대가 직계 자식일 때 위치를 읽어요. |
|
|
32
|
+
| `labelWidth` | `number` | `160` | 왼쪽 이름 칸 폭(px). 0이면 이름 칸 없음. |
|
|
33
|
+
| `labelHeader` | `ReactNode` | | 이름 칸 위, 눈금자와 같은 줄의 제목. |
|
|
34
|
+
| `aside` | `ReactNode` | | 행마다 이름 대신 이름 칸 전체를 채우는 내용(보통 같은 행 높이의 TreeView). 선택 배경은 그 뒤에 깔려요. |
|
|
35
|
+
| `density` | `'compact' \| 'default' \| 'comfortable'` | `'default'` | 행 높이 28 / 36 / 56px. comfortable은 두 줄 이름 · 내용 막대 레인용. |
|
|
36
|
+
| `rowHeight` | `number` | | density 대신 행 높이(px)를 직접. TreeView와 맞출 땐 TREE_ROW_HEIGHT. |
|
|
37
|
+
| `aria-label` | `string` | | 전체 그룹의 이름. |
|
|
38
|
+
|
|
39
|
+
### TimelineMarker
|
|
40
|
+
|
|
41
|
+
| prop | 타입 | 기본값 | 설명 |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| `value` (필수) | `number` | | 선 위치. |
|
|
44
|
+
| `tone` | `'neutral' \| 'accent' \| 'success' \| 'warning' \| 'danger'` | `'neutral'` | 선·띠·칩 색. |
|
|
45
|
+
| `line` | `'solid' \| 'dashed'` | `'dashed'` | dashed는 목표·계획, solid는 예상·사건. |
|
|
46
|
+
| `label` | `ReactNode` | | 행 아래 칩(Badge) 문구. |
|
|
47
|
+
| `labelSide` | `'center' \| 'left' \| 'right'` | `'center'` | 칩이 선에서 어느 쪽으로 붙을지. |
|
|
48
|
+
| `band` | `number` | | value와 이 값 사이를 옅게 칠해요. |
|
|
49
|
+
|
|
50
|
+
### TimelineRow
|
|
51
|
+
|
|
52
|
+
| prop | 타입 | 기본값 | 설명 |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| `label` | `ReactNode` | | 왼쪽 칸. 문자열이면 한 줄 label-sm으로 줄여 보여요. |
|
|
55
|
+
| `leading` | `ReactNode` | | 이름 앞 타일(IconTile · Avatar). 주면 두 줄 이름이 돼요. |
|
|
56
|
+
| `description` | `ReactNode` | | 이름 아래 옅은 둘째 줄. 길면 말줄임. |
|
|
57
|
+
| `badge` | `ReactNode` | | 이름 바로 뒤 상태 칸(Badge size="sm"). |
|
|
58
|
+
| `count` | `number \| ReactNode` | | 이름 칸 오른쪽 끝 회색 칩. 숫자면 "+5". |
|
|
59
|
+
| `depth` | `number` | `0` | 이름 들여쓰기 단계(16px씩). 하위 구간에. |
|
|
60
|
+
| `faded` | `boolean` | | 앞으로 할·건너뛴 행: 회색으로 흐리게. |
|
|
61
|
+
| `selected` | `boolean` | | 이름 칸까지 행 전체에 옅은 파란 배경. |
|
|
62
|
+
| `onSelect` | `() => void` | | 행을 선택할 수 있게: 행 어디든(막대 포함) 클릭, 포커스 후 Enter · Space. |
|
|
63
|
+
| `children` | `ReactNode` | | TimelineBar들. 한 행에 겹쳐 그려요. |
|
|
64
|
+
|
|
65
|
+
### TimelineBar
|
|
66
|
+
|
|
67
|
+
| prop | 타입 | 기본값 | 설명 |
|
|
68
|
+
| --- | --- | --- | --- |
|
|
69
|
+
| `start / end` (필수) | `number` | | 구간. |
|
|
70
|
+
| `id` | `string` | | Timeline links에서 이 막대를 가리키는 이름. |
|
|
71
|
+
| `variant` | `'fill' \| 'outline' \| 'pending'` | `'fill'` | 옅은 색 막대 / 점선 틀(같은 크기 fill을 감싸요) / 회색 자리. |
|
|
72
|
+
| `tone` | `'neutral' \| 'accent' \| 'purple' \| 'teal' \| 'success' \| 'warning' \| 'danger'` | `outline이면 'neutral', 아니면 'accent'` | 막대 색. |
|
|
73
|
+
| `size` | `'sm' \| 'md' \| 'lg' \| 'xl'` | `'md', 내용 막대는 'xl'` | 두께 10 / 16 / 20 / 32px. |
|
|
74
|
+
| `children` | `ReactNode` | | 막대 안 글자. 주면 8px 모서리의 큰 막대가 되고 톤 색 글자, 넘치면 말줄임. |
|
|
75
|
+
| `leading` | `ReactNode` | | 막대 안 글자 앞(보통 IconStack). |
|
|
76
|
+
| `action` | `ReactNode` | | 막대 안 오른쪽 칸(Badge as="button", 흰 바탕으로). 따로 눌려요. |
|
|
77
|
+
| `segments` | `TimelineBarSegment[]` | | 이어지는 색 구간 { start, end, tone? }. variant="outline"이면 실선 틀 안에 얇게. |
|
|
78
|
+
| `onClick` | `(event) => void` | | 막대 전체를 버튼으로. 선택 가능한 행이면 클릭이 행 선택으로도 이어져요. |
|
|
79
|
+
| `renderTarget` | `(target) => ReactNode` | | 막대의 포커스 대상을 감싸요: (t) => <Popover trigger={t}>…</Popover>. 감싼 요소가 막대 위로 늘어나 팝오버가 막대에 붙어요. |
|
|
80
|
+
| `ref` | `Ref<HTMLSpanElement>` | | 막대 요소(트랙 위 위치) — 직접 띄우는 UI의 기준점으로. |
|
|
81
|
+
| `limit` | `number` | | 이 값을 넘긴 부분을 overTone으로 칠해요(계획 초과). |
|
|
82
|
+
| `overTone` | `TimelineBarTone` | `'warning'` | 초과 부분 색. |
|
|
83
|
+
| `running` | `boolean` | | 진행 중: 앞쪽 가장자리가 빛나고 shimmer가 지나가요. 내용 막대는 왼쪽에서 옅게 시작해 진한 오른쪽 끝선으로. |
|
|
84
|
+
| `label` | `ReactNode` | | 막대 뒤 내용(소요 시간, Badge, Spinner). |
|
|
85
|
+
| `labelAt` | `number` | | label 시작 위치. 기본은 end, 계획 틀이 더 길면 그 끝을 넘겨요. |
|
|
86
|
+
| `tooltip` | `ReactNode` | | hover·포커스 말풍선(Tooltip). 주면 막대가 포커스를 받아요. |
|
|
87
|
+
| `aria-label` | `string` | | 스크린 리더 문구. 기본은 "시작 – 끝". |
|
|
88
|
+
|
|
89
|
+
### formatClock / formatOffset / useTimelineScale
|
|
90
|
+
|
|
91
|
+
| prop | 타입 | 기본값 | 설명 |
|
|
92
|
+
| --- | --- | --- | --- |
|
|
93
|
+
| `formatClock(seconds)` | `(number) => string` | | 초를 시계 형식으로: 52 → "0:52", 3725 → "1:02:05". |
|
|
94
|
+
| `formatOffset(unit?, zero?)` | `(unit, zero) => (number) => string` | | 상대 축 눈금: formatOffset('분')이면 −25 → "−25분", 5 → "+5분", 0 → zero(기본 "0"). |
|
|
95
|
+
| `useTimelineScale()` | `{ min, max, pct, formatValue }` | | Timeline 안에서 값 → 0–100 위치를 직접 계산할 때(맞춤 표시). |
|
|
96
|
+
|
|
97
|
+
## 예제
|
|
98
|
+
|
|
99
|
+
예제를 감싼 `style={{ ... }}` div는 미리보기 배치용이다. 실제 화면 배치는 `Stack` · `Inline` · `Grid`로 한다.
|
|
100
|
+
|
|
101
|
+
### 기본
|
|
102
|
+
|
|
103
|
+
max와 step으로 눈금을, formatTick으로 표시 형식을 정해요. 막대 뒤 label 칸에 소요 시간을 적어요.
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
import { Timeline, TimelineBar, TimelineRow } from '@iamfiro/design'
|
|
107
|
+
|
|
108
|
+
const STEPS = [
|
|
109
|
+
{ name: '코드 검사', start: 0, end: 4 },
|
|
110
|
+
{ name: '단위 테스트', start: 4, end: 11 },
|
|
111
|
+
{ name: '이미지 빌드', start: 4, end: 16 },
|
|
112
|
+
{ name: '스테이징 배포', start: 16, end: 21 },
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
export default function Example() {
|
|
116
|
+
return (
|
|
117
|
+
<Timeline max={30} step={5} formatTick={(v) => `${v}s`} now={21} labelWidth={112} aria-label="배포 파이프라인 단계별 시간" style={{ width: '100%', maxWidth: 560 }}>
|
|
118
|
+
{STEPS.map((s) => (
|
|
119
|
+
<TimelineRow key={s.name} label={s.name}>
|
|
120
|
+
<TimelineBar start={s.start} end={s.end} tone="accent" label={`${s.end - s.start}s`} />
|
|
121
|
+
</TimelineRow>
|
|
122
|
+
))}
|
|
123
|
+
</Timeline>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 막대 종류
|
|
129
|
+
|
|
130
|
+
fill은 옅은 색 막대(실제), outline은 점선 틀(계획), pending은 아직 시작하지 않은 회색 자리. limit을 넘긴 부분은 주황(overTone)으로, running이면 앞쪽 가장자리가 빛나고 shimmer가 지나가요. tooltip을 주면 마우스·키보드 포커스로 말풍선이 떠요.
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import { Timeline, TimelineBar, TimelineRow } from '@iamfiro/design'
|
|
134
|
+
|
|
135
|
+
export default function Example() {
|
|
136
|
+
return (
|
|
137
|
+
<Timeline max={100} step={20} formatTick={(v) => `${v}%`} labelWidth={120} density="compact" style={{ width: '100%', maxWidth: 560 }}>
|
|
138
|
+
<TimelineRow label="fill · accent">
|
|
139
|
+
<TimelineBar start={0} end={48} tone="accent" label="48%" />
|
|
140
|
+
</TimelineRow>
|
|
141
|
+
<TimelineRow label="fill · purple">
|
|
142
|
+
<TimelineBar start={10} end={62} tone="purple" label="52%" />
|
|
143
|
+
</TimelineRow>
|
|
144
|
+
<TimelineRow label="fill · neutral">
|
|
145
|
+
<TimelineBar start={0} end={80} tone="neutral" label="80%" />
|
|
146
|
+
</TimelineRow>
|
|
147
|
+
<TimelineRow label="outline (계획)">
|
|
148
|
+
<TimelineBar start={20} end={70} variant="outline" label="50%" />
|
|
149
|
+
</TimelineRow>
|
|
150
|
+
<TimelineRow label="계획 + 실제">
|
|
151
|
+
<TimelineBar start={20} end={60} variant="outline" />
|
|
152
|
+
<TimelineBar start={20} end={72} tone="purple" limit={60} label="초과 12%" />
|
|
153
|
+
</TimelineRow>
|
|
154
|
+
<TimelineRow label="pending">
|
|
155
|
+
<TimelineBar start={60} end={90} variant="pending" label="대기" />
|
|
156
|
+
</TimelineRow>
|
|
157
|
+
<TimelineRow label="running">
|
|
158
|
+
<TimelineBar start={0} end={56} tone="accent" running label="진행 중" />
|
|
159
|
+
</TimelineRow>
|
|
160
|
+
<TimelineRow label="size sm · md · lg">
|
|
161
|
+
<TimelineBar start={0} end={20} size="sm" tone="accent" />
|
|
162
|
+
<TimelineBar start={30} end={50} size="md" tone="accent" />
|
|
163
|
+
<TimelineBar start={60} end={80} size="lg" tone="accent" />
|
|
164
|
+
</TimelineRow>
|
|
165
|
+
<TimelineRow label="툴팁">
|
|
166
|
+
<TimelineBar start={10} end={45} tone="neutral" tooltip="10% – 45% · 마우스를 올리거나 Tab으로 포커스" label="올려 보세요" />
|
|
167
|
+
</TimelineRow>
|
|
168
|
+
</Timeline>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 계획 대비 실제
|
|
174
|
+
|
|
175
|
+
점선 틀(계획) 위에 실제 막대를 겹치고, 계획 시간을 넘긴 부분은 주황으로 보여 줘요. 앞으로 할 단계는 faded로 흐리게. 점선 표시선은 계획 종료, 주황 띠와 선은 예상 종료예요. "15초 진행"을 누르면 막대와 표시선이 새 위치로 미끄러져요. 범례는 ColorDot의 dashed · soft 변형으로 만들어요.
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
import { useState } from 'react'
|
|
179
|
+
import { Badge, Button, ColorDot, Inline, Spinner, Stack, Text, Card, CardBody, CardHeader, formatClock, Timeline, TimelineBar, TimelineRow } from '@iamfiro/design'
|
|
180
|
+
|
|
181
|
+
// 단계마다 계획한 시간과 실제로 걸린(걸릴) 시간, 초 단위.
|
|
182
|
+
const STEPS = [
|
|
183
|
+
{ name: '요구사항 정리', plan: 40, actual: 52 },
|
|
184
|
+
{ name: '디자인 검토', plan: 45, actual: 41 },
|
|
185
|
+
{ name: '기능 개발', plan: 55, actual: 63 },
|
|
186
|
+
{ name: 'QA 테스트', plan: 20, actual: 24 },
|
|
187
|
+
{ name: '릴리스 노트 작성', plan: 15, actual: 18 },
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
const PLAN_TOTAL = STEPS.reduce((sum, s) => sum + s.plan, 0)
|
|
191
|
+
|
|
192
|
+
function schedule(now: number) {
|
|
193
|
+
let planAt = 0
|
|
194
|
+
let actualAt = 0
|
|
195
|
+
let eta = 0
|
|
196
|
+
const rows = STEPS.map((s) => {
|
|
197
|
+
const row = { ...s, planStart: planAt, start: actualAt }
|
|
198
|
+
planAt += s.plan
|
|
199
|
+
const status = actualAt + s.actual <= now ? 'done' : actualAt <= now ? 'running' : 'next'
|
|
200
|
+
const end = status === 'done' ? actualAt + s.actual : status === 'running' ? now : actualAt
|
|
201
|
+
// 끝나지 않은 단계는 계획만큼(이미 넘겼으면 지금까지) 걸린다고 보고 예상 종료를 계산해요.
|
|
202
|
+
eta = status === 'done' ? end : Math.max(eta, actualAt) + (status === 'running' ? Math.max(s.plan, now - actualAt) : s.plan)
|
|
203
|
+
actualAt = status === 'next' ? actualAt : status === 'running' ? Math.max(now, actualAt + s.plan) : end
|
|
204
|
+
return { ...row, status, end }
|
|
205
|
+
})
|
|
206
|
+
return { rows, eta }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function delta(seconds: number) {
|
|
210
|
+
return seconds > 0 ? `+${seconds}초` : seconds < 0 ? `−${Math.abs(seconds)}초` : '±0초'
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default function Example() {
|
|
214
|
+
const [now, setNow] = useState(150)
|
|
215
|
+
const { rows, eta } = schedule(now)
|
|
216
|
+
const late = eta > PLAN_TOTAL
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Card style={{ width: '100%', maxWidth: 640 }}>
|
|
220
|
+
<CardHeader
|
|
221
|
+
title="릴리스 준비"
|
|
222
|
+
description={`계획 대비 실제 · ${STEPS.length}단계`}
|
|
223
|
+
actions={
|
|
224
|
+
<Inline gap={2}>
|
|
225
|
+
<Button size="sm" variant="ghost" onClick={() => setNow(0)}>
|
|
226
|
+
처음으로
|
|
227
|
+
</Button>
|
|
228
|
+
<Button size="sm" onClick={() => setNow((n) => Math.min(n + 15, 240))}>
|
|
229
|
+
15초 진행
|
|
230
|
+
</Button>
|
|
231
|
+
</Inline>
|
|
232
|
+
}
|
|
233
|
+
/>
|
|
234
|
+
<CardBody>
|
|
235
|
+
<Stack gap={4}>
|
|
236
|
+
<Timeline
|
|
237
|
+
max={240}
|
|
238
|
+
step={30}
|
|
239
|
+
formatTick={formatClock}
|
|
240
|
+
now={now}
|
|
241
|
+
labelWidth={128}
|
|
242
|
+
aria-label="단계별 계획과 실제 소요 시간"
|
|
243
|
+
markers={[
|
|
244
|
+
{ value: PLAN_TOTAL, line: 'dashed', label: `계획 ${formatClock(PLAN_TOTAL)}`, labelSide: late ? 'left' : 'center' },
|
|
245
|
+
...(late ? [{ value: eta, tone: 'warning' as const, line: 'solid' as const, band: PLAN_TOTAL, label: `예상 ${formatClock(eta)}`, labelSide: 'right' as const }] : []),
|
|
246
|
+
]}
|
|
247
|
+
>
|
|
248
|
+
{rows.map((r) => (
|
|
249
|
+
<TimelineRow key={r.name} label={r.name} faded={r.status === 'next'}>
|
|
250
|
+
<TimelineBar
|
|
251
|
+
start={r.planStart}
|
|
252
|
+
end={r.planStart + r.plan}
|
|
253
|
+
variant="outline"
|
|
254
|
+
label={
|
|
255
|
+
r.status === 'next' ? (
|
|
256
|
+
<Text variant="label-xs" tone="tertiary" numeric>
|
|
257
|
+
{formatClock(r.plan)}
|
|
258
|
+
</Text>
|
|
259
|
+
) : undefined
|
|
260
|
+
}
|
|
261
|
+
/>
|
|
262
|
+
{r.status !== 'next' && (
|
|
263
|
+
<TimelineBar
|
|
264
|
+
start={r.start}
|
|
265
|
+
end={r.end}
|
|
266
|
+
tone="purple"
|
|
267
|
+
limit={r.start + r.plan}
|
|
268
|
+
running={r.status === 'running'}
|
|
269
|
+
labelAt={r.planStart + r.plan}
|
|
270
|
+
tooltip={`실제 ${formatClock(r.end - r.start)} · 계획 ${formatClock(r.plan)}`}
|
|
271
|
+
label={
|
|
272
|
+
<>
|
|
273
|
+
<Text variant="label-xs" numeric>
|
|
274
|
+
{formatClock(r.end - r.start)}
|
|
275
|
+
</Text>
|
|
276
|
+
{r.status === 'running' ? (
|
|
277
|
+
<Spinner size="xs" tone="muted" label="진행 중" />
|
|
278
|
+
) : (
|
|
279
|
+
<Badge tone={r.actual > r.plan ? 'warning' : 'success'} square>
|
|
280
|
+
{delta(r.actual - r.plan)}
|
|
281
|
+
</Badge>
|
|
282
|
+
)}
|
|
283
|
+
</>
|
|
284
|
+
}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
</TimelineRow>
|
|
288
|
+
))}
|
|
289
|
+
</Timeline>
|
|
290
|
+
|
|
291
|
+
<Inline gap={4}>
|
|
292
|
+
<Inline gap={2} wrap={false}>
|
|
293
|
+
<ColorDot color="--n8" variant="dashed" size={12} />
|
|
294
|
+
<Text variant="body-sm" tone="secondary">
|
|
295
|
+
계획
|
|
296
|
+
</Text>
|
|
297
|
+
</Inline>
|
|
298
|
+
<Inline gap={2} wrap={false}>
|
|
299
|
+
<ColorDot color="--chart-4" variant="soft" size={12} />
|
|
300
|
+
<Text variant="body-sm" tone="secondary">
|
|
301
|
+
실제
|
|
302
|
+
</Text>
|
|
303
|
+
</Inline>
|
|
304
|
+
<Inline gap={2} wrap={false}>
|
|
305
|
+
<ColorDot color="--chart-6" variant="soft" size={12} />
|
|
306
|
+
<Text variant="body-sm" tone="secondary">
|
|
307
|
+
계획 초과
|
|
308
|
+
</Text>
|
|
309
|
+
</Inline>
|
|
310
|
+
</Inline>
|
|
311
|
+
</Stack>
|
|
312
|
+
</CardBody>
|
|
313
|
+
</Card>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### 워터폴
|
|
319
|
+
|
|
320
|
+
부모 구간은 회색, 하위 구간은 depth로 들여 써요. 진행 중인 구간은 now까지 자라며 Spinner를 함께 보여 주고, 가장 오래 걸린 구간엔 Badge를 붙여요.
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
import { useEffect, useState } from 'react'
|
|
324
|
+
import { Badge, Button, Spinner, Text, Card, CardBody, CardHeader, Timeline, TimelineBar, TimelineRow, type TimelineBarTone } from '@iamfiro/design'
|
|
325
|
+
|
|
326
|
+
interface Span {
|
|
327
|
+
name: string
|
|
328
|
+
start: number
|
|
329
|
+
/** 끝나지 않은 구간은 생략해요. */
|
|
330
|
+
end?: number
|
|
331
|
+
depth: number
|
|
332
|
+
tone: TimelineBarTone
|
|
333
|
+
detail?: string
|
|
334
|
+
slowest?: boolean
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const SPANS: Span[] = [
|
|
338
|
+
{ name: '배포 파이프라인', start: 0, depth: 0, tone: 'neutral' },
|
|
339
|
+
{ name: '소스 가져오기', start: 0, end: 1.2, depth: 1, tone: 'accent', detail: '48MB' },
|
|
340
|
+
{ name: '의존성 설치', start: 1.2, end: 8.1, depth: 1, tone: 'purple', detail: '312MB', slowest: true },
|
|
341
|
+
{ name: '단위 테스트', start: 8.1, end: 14.6, depth: 1, tone: 'accent', detail: '1,284개' },
|
|
342
|
+
{ name: '이미지 빌드', start: 8.4, end: 13.0, depth: 1, tone: 'purple', detail: '186MB' },
|
|
343
|
+
{ name: '레지스트리 업로드', start: 14.6, depth: 1, tone: 'accent' },
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
const DONE_AT = 21.4
|
|
347
|
+
const seconds = (v: number) => `${v.toFixed(1)}s`
|
|
348
|
+
|
|
349
|
+
export default function Example() {
|
|
350
|
+
const [elapsed, setElapsed] = useState(16.2)
|
|
351
|
+
const running = elapsed < DONE_AT
|
|
352
|
+
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (!running) return
|
|
355
|
+
const timer = setTimeout(() => setElapsed((t) => Math.min(DONE_AT, Number((t + 0.4).toFixed(1)))), 400)
|
|
356
|
+
return () => clearTimeout(timer)
|
|
357
|
+
}, [elapsed, running])
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
<Card style={{ width: '100%', maxWidth: 640 }}>
|
|
361
|
+
<CardHeader
|
|
362
|
+
title="배포 #482"
|
|
363
|
+
description={running ? '진행 중' : `완료 · ${seconds(DONE_AT)}`}
|
|
364
|
+
actions={
|
|
365
|
+
<Button size="sm" onClick={() => setElapsed(14.6)} disabled={running}>
|
|
366
|
+
다시 실행
|
|
367
|
+
</Button>
|
|
368
|
+
}
|
|
369
|
+
/>
|
|
370
|
+
<CardBody>
|
|
371
|
+
<Timeline max={25} step={5} formatTick={(v) => `${v}s`} now={elapsed} labelWidth={150} density="compact" aria-label="배포 단계별 소요 시간">
|
|
372
|
+
{SPANS.map((s) => {
|
|
373
|
+
const open = s.end === undefined
|
|
374
|
+
const end = s.end ?? elapsed
|
|
375
|
+
return (
|
|
376
|
+
<TimelineRow key={s.name} depth={s.depth} label={<Text variant={s.depth === 0 ? 'label-sm' : 'body-sm'} tone={s.depth === 0 ? 'default' : 'secondary'} truncate>{s.name}</Text>}>
|
|
377
|
+
<TimelineBar
|
|
378
|
+
start={s.start}
|
|
379
|
+
end={end}
|
|
380
|
+
tone={s.tone}
|
|
381
|
+
size={s.depth === 0 ? 'md' : 'sm'}
|
|
382
|
+
running={open && running}
|
|
383
|
+
tooltip={`${s.name} · ${seconds(end - s.start)}`}
|
|
384
|
+
label={
|
|
385
|
+
<>
|
|
386
|
+
<Text variant="caption" tone="secondary" numeric>
|
|
387
|
+
{seconds(end - s.start)}
|
|
388
|
+
{s.detail && ` · ${s.detail}`}
|
|
389
|
+
</Text>
|
|
390
|
+
{open && running && <Spinner size="xs" tone="muted" label="진행 중" />}
|
|
391
|
+
{s.slowest && (
|
|
392
|
+
<Badge tone="warning" square>
|
|
393
|
+
가장 느림
|
|
394
|
+
</Badge>
|
|
395
|
+
)}
|
|
396
|
+
</>
|
|
397
|
+
}
|
|
398
|
+
/>
|
|
399
|
+
</TimelineRow>
|
|
400
|
+
)
|
|
401
|
+
})}
|
|
402
|
+
</Timeline>
|
|
403
|
+
</CardBody>
|
|
404
|
+
</Card>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### 표시선 · 띠
|
|
410
|
+
|
|
411
|
+
markers의 line은 solid / dashed, band는 띠의 다른 쪽 끝, labelSide는 칩이 선의 어느 쪽으로 붙을지예요('left' · 'right'로 이웃한 칩이 겹치지 않게).
|
|
412
|
+
|
|
413
|
+
```tsx
|
|
414
|
+
import { Timeline, TimelineBar, TimelineRow } from '@iamfiro/design'
|
|
415
|
+
|
|
416
|
+
const hour = (v: number) => `${String(Math.floor(v)).padStart(2, '0')}:${v % 1 ? '30' : '00'}`
|
|
417
|
+
|
|
418
|
+
export default function Example() {
|
|
419
|
+
return (
|
|
420
|
+
<Timeline
|
|
421
|
+
min={9}
|
|
422
|
+
max={18}
|
|
423
|
+
step={1}
|
|
424
|
+
formatTick={hour}
|
|
425
|
+
now={14.5}
|
|
426
|
+
labelWidth={96}
|
|
427
|
+
aria-label="10월 25일 팀 일정"
|
|
428
|
+
markers={[
|
|
429
|
+
{ value: 12, band: 13, line: 'solid', tone: 'neutral', label: '점심', labelSide: 'right' },
|
|
430
|
+
{ value: 17, tone: 'danger', line: 'dashed', label: '마감 17:00', labelSide: 'left' },
|
|
431
|
+
]}
|
|
432
|
+
style={{ width: '100%', maxWidth: 600 }}
|
|
433
|
+
>
|
|
434
|
+
<TimelineRow label="김민지">
|
|
435
|
+
<TimelineBar start={9.5} end={11} tone="accent" tooltip="주간 계획 회의" />
|
|
436
|
+
<TimelineBar start={14} end={16} tone="purple" tooltip="디자인 리뷰" />
|
|
437
|
+
</TimelineRow>
|
|
438
|
+
<TimelineRow label="이도현">
|
|
439
|
+
<TimelineBar start={10} end={12} tone="purple" tooltip="API 설계" />
|
|
440
|
+
<TimelineBar start={13} end={14.5} tone="accent" running tooltip="QA 대응 (진행 중)" />
|
|
441
|
+
</TimelineRow>
|
|
442
|
+
<TimelineRow label="박서연" faded>
|
|
443
|
+
<TimelineBar start={15} end={17} variant="outline" tooltip="릴리스 점검 (예정)" />
|
|
444
|
+
</TimelineRow>
|
|
445
|
+
</Timeline>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### 팀원별 작업 레인
|
|
451
|
+
|
|
452
|
+
최근 30분을 상대 축(formatOffset)으로 보여 주고, 눈금자 끝에는 nowLabel 칩 "지금"이 놓여요(nowLine={false}로 선은 생략). density="comfortable"(56px) 행에 Avatar · 이름 · 설명 · 상태 Badge를 두 줄로, 막대 안에는 IconStack과 작업 이름을 넣어요. 진행 중 막대는 왼쪽에서 옅게 시작해 오른쪽 끝이 진한 선으로 끝나요. 점선 곡선은 links로 이은 작업 의존 관계예요.
|
|
453
|
+
|
|
454
|
+
```tsx
|
|
455
|
+
import { TriangleAlert } from 'lucide-react'
|
|
456
|
+
import { Avatar, Badge, BrandIcon, IconStack, Inline, Card, CardBody, CardHeader, Timeline, TimelineBar, TimelineRow, formatOffset, toast } from '@iamfiro/design'
|
|
457
|
+
|
|
458
|
+
// Minutes before now: −30 … 0.
|
|
459
|
+
const minutes = formatOffset('분')
|
|
460
|
+
const icons = (...names: Array<'gmail' | 'slack' | 'figma' | 'github' | 'clickup'>) => (
|
|
461
|
+
<IconStack icons={names.map((n) => <BrandIcon key={n} name={n} size={16} />)} size={16} overlap={-4} shape="square" />
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
export default function Example() {
|
|
465
|
+
return (
|
|
466
|
+
<Card style={{ width: '100%', maxWidth: 760 }}>
|
|
467
|
+
<CardHeader
|
|
468
|
+
title={
|
|
469
|
+
<Inline gap={3}>
|
|
470
|
+
팀원별 작업
|
|
471
|
+
<Badge tone="accent" size="sm" dot>
|
|
472
|
+
진행 중 2
|
|
473
|
+
</Badge>
|
|
474
|
+
<Badge tone="warning" size="sm" dot>
|
|
475
|
+
확인 필요 1
|
|
476
|
+
</Badge>
|
|
477
|
+
</Inline>
|
|
478
|
+
}
|
|
479
|
+
actions={<Badge>최근 30분</Badge>}
|
|
480
|
+
/>
|
|
481
|
+
<CardBody>
|
|
482
|
+
<Timeline
|
|
483
|
+
min={-30}
|
|
484
|
+
max={0}
|
|
485
|
+
ticks={[-25, -20, -15, -10, -5, 0]}
|
|
486
|
+
formatTick={minutes}
|
|
487
|
+
now={0}
|
|
488
|
+
nowLabel="지금"
|
|
489
|
+
nowLine={false}
|
|
490
|
+
density="comfortable"
|
|
491
|
+
labelWidth={200}
|
|
492
|
+
links={[{ from: 'research', to: 'pricing-page' }]}
|
|
493
|
+
aria-label="최근 30분 팀원별 작업 레인"
|
|
494
|
+
>
|
|
495
|
+
<TimelineRow label="김민지" description="프로덕트 디자인" leading={<Avatar name="김민지" size={36} />}>
|
|
496
|
+
<TimelineBar id="research" start={-26} end={-17} tone="neutral" leading={icons('gmail', 'clickup')}>
|
|
497
|
+
경쟁사 가격 조사
|
|
498
|
+
</TimelineBar>
|
|
499
|
+
<TimelineBar start={-10} end={0} running leading={icons('clickup', 'slack')}>
|
|
500
|
+
사용자 인터뷰 정리
|
|
501
|
+
</TimelineBar>
|
|
502
|
+
</TimelineRow>
|
|
503
|
+
<TimelineRow label="이도현" description="프론트엔드" leading={<Avatar name="이도현" size={36} />}>
|
|
504
|
+
<TimelineBar id="pricing-page" start={-16} end={0} running leading={icons('figma', 'github')}>
|
|
505
|
+
요금제 페이지 초안
|
|
506
|
+
</TimelineBar>
|
|
507
|
+
</TimelineRow>
|
|
508
|
+
<TimelineRow
|
|
509
|
+
label="박서연"
|
|
510
|
+
description="코드 리뷰"
|
|
511
|
+
leading={<Avatar name="박서연" size={36} />}
|
|
512
|
+
badge={<Badge size="sm">쉬는 중</Badge>}
|
|
513
|
+
>
|
|
514
|
+
<TimelineBar start={-30} end={-20} tone="neutral" leading={icons('github')}>
|
|
515
|
+
결제 모듈 PR #482 리뷰
|
|
516
|
+
</TimelineBar>
|
|
517
|
+
</TimelineRow>
|
|
518
|
+
<TimelineRow
|
|
519
|
+
label="최준호"
|
|
520
|
+
description="운영"
|
|
521
|
+
leading={<Avatar name="최준호" size={36} />}
|
|
522
|
+
badge={
|
|
523
|
+
<Badge size="sm" tone="warning">
|
|
524
|
+
확인 필요
|
|
525
|
+
</Badge>
|
|
526
|
+
}
|
|
527
|
+
>
|
|
528
|
+
<TimelineBar start={-26} end={-20.5} tone="danger" leading={icons('slack')}>
|
|
529
|
+
고객 데이터 동기화
|
|
530
|
+
</TimelineBar>
|
|
531
|
+
<TimelineBar
|
|
532
|
+
start={-13}
|
|
533
|
+
end={0}
|
|
534
|
+
tone="warning"
|
|
535
|
+
leading={icons('github')}
|
|
536
|
+
action={
|
|
537
|
+
<Badge as="button" tone="warning" icon={<TriangleAlert size={12} />} onClick={() => toast('최준호 님에게 확인 요청을 보냈어요')}>
|
|
538
|
+
확인 필요
|
|
539
|
+
</Badge>
|
|
540
|
+
}
|
|
541
|
+
>
|
|
542
|
+
배포 키 교체
|
|
543
|
+
</TimelineBar>
|
|
544
|
+
</TimelineRow>
|
|
545
|
+
</Timeline>
|
|
546
|
+
</CardBody>
|
|
547
|
+
</Card>
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### 내용이 있는 막대
|
|
553
|
+
|
|
554
|
+
children을 주면 8px 모서리의 큰 막대(xl, 32px)가 되고 글자는 톤 색으로 칠해져요. neutral · accent · danger · warning, 진행 중(running), 막대 안 오른쪽의 action 칸(흰 Badge 버튼), 말줄임. onClick을 주면 막대 전체가 버튼이 돼요.
|
|
555
|
+
|
|
556
|
+
```tsx
|
|
557
|
+
import { TriangleAlert } from 'lucide-react'
|
|
558
|
+
import { Badge, BrandIcon, IconStack, Timeline, TimelineBar, TimelineRow, toast } from '@iamfiro/design'
|
|
559
|
+
|
|
560
|
+
const logos = (...names: Array<'gmail' | 'slack' | 'figma' | 'github' | 'clickup'>) => (
|
|
561
|
+
<IconStack icons={names.map((n) => <BrandIcon key={n} name={n} size={16} />)} size={16} overlap={-4} shape="square" />
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
export default function Example() {
|
|
565
|
+
return (
|
|
566
|
+
<Timeline max={100} grid={false} labelWidth={96} density="comfortable" aria-label="내용이 있는 막대" style={{ width: '100%', maxWidth: 620 }}>
|
|
567
|
+
<TimelineRow label="neutral">
|
|
568
|
+
<TimelineBar start={0} end={46} tone="neutral" leading={logos('github')} onClick={() => toast('PR #482를 열었어요')}>
|
|
569
|
+
PR #482 리뷰
|
|
570
|
+
</TimelineBar>
|
|
571
|
+
</TimelineRow>
|
|
572
|
+
<TimelineRow label="accent">
|
|
573
|
+
<TimelineBar start={20} end={100} running leading={logos('figma', 'slack')}>
|
|
574
|
+
요금제 페이지 초안 (진행 중)
|
|
575
|
+
</TimelineBar>
|
|
576
|
+
</TimelineRow>
|
|
577
|
+
<TimelineRow label="danger">
|
|
578
|
+
<TimelineBar start={6} end={40} tone="danger" leading={logos('gmail')}>
|
|
579
|
+
고객 데이터 동기화 실패
|
|
580
|
+
</TimelineBar>
|
|
581
|
+
</TimelineRow>
|
|
582
|
+
<TimelineRow label="warning">
|
|
583
|
+
<TimelineBar
|
|
584
|
+
start={36}
|
|
585
|
+
end={100}
|
|
586
|
+
tone="warning"
|
|
587
|
+
leading={logos('github', 'clickup')}
|
|
588
|
+
action={
|
|
589
|
+
<Badge as="button" tone="warning" icon={<TriangleAlert size={12} />} onClick={() => toast('확인 요청을 열었어요')}>
|
|
590
|
+
확인 필요
|
|
591
|
+
</Badge>
|
|
592
|
+
}
|
|
593
|
+
>
|
|
594
|
+
배포 키 교체
|
|
595
|
+
</TimelineBar>
|
|
596
|
+
</TimelineRow>
|
|
597
|
+
<TimelineRow label="잘림">
|
|
598
|
+
<TimelineBar start={0} end={22} tone="neutral" leading={logos('slack')}>
|
|
599
|
+
아주 긴 작업 이름은 막대 폭에 맞춰 말줄임표로 잘려요
|
|
600
|
+
</TimelineBar>
|
|
601
|
+
</TimelineRow>
|
|
602
|
+
</Timeline>
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### 두 줄 이름 · 행 선택
|
|
608
|
+
|
|
609
|
+
leading(IconTile) · description · badge · count("+5" 칩)로 두 줄 이름을 만들어요. onSelect를 주면 행 어디를 눌러도(Enter · Space 포함) 선택되고, selected 행은 이름 칸까지 옅은 파란 배경이 깔려요.
|
|
610
|
+
|
|
611
|
+
```tsx
|
|
612
|
+
import { useState } from 'react'
|
|
613
|
+
import { Database, FileText, Rocket, Server } from 'lucide-react'
|
|
614
|
+
import { Badge, IconTile, Text, Timeline, TimelineBar, TimelineRow } from '@iamfiro/design'
|
|
615
|
+
|
|
616
|
+
const DEPLOYS = [
|
|
617
|
+
{ id: 'web', name: '웹 프론트', description: 'main · 김민지', icon: <Rocket size={16} />, start: 2, end: 9, steps: 5 },
|
|
618
|
+
{ id: 'api', name: 'API 서버', description: 'release/2.4 · 이도현', icon: <Server size={16} />, start: 4, end: 15, steps: 7, status: <Badge size="sm" spinner>진행 중</Badge> },
|
|
619
|
+
{ id: 'db', name: '데이터베이스 이전', description: '스키마 v12 · 이도현', icon: <Database size={16} />, start: 9, end: 13, steps: 3, status: <Badge size="sm" tone="warning">확인 필요</Badge> },
|
|
620
|
+
{ id: 'docs', name: '도움말 문서', description: '자동 배포', icon: <FileText size={16} />, start: 0, end: 3, steps: 0 },
|
|
621
|
+
]
|
|
622
|
+
|
|
623
|
+
export default function Example() {
|
|
624
|
+
const [selected, setSelected] = useState('api')
|
|
625
|
+
const current = DEPLOYS.find((d) => d.id === selected)
|
|
626
|
+
return (
|
|
627
|
+
<Timeline
|
|
628
|
+
max={20}
|
|
629
|
+
step={5}
|
|
630
|
+
formatTick={(v) => `${v}분`}
|
|
631
|
+
now={15}
|
|
632
|
+
density="comfortable"
|
|
633
|
+
labelWidth={260}
|
|
634
|
+
labelHeader={`선택: ${current?.name ?? '없음'}`}
|
|
635
|
+
aria-label="배포별 진행 시간"
|
|
636
|
+
style={{ width: '100%', maxWidth: 720 }}
|
|
637
|
+
>
|
|
638
|
+
{DEPLOYS.map((d) => (
|
|
639
|
+
<TimelineRow
|
|
640
|
+
key={d.id}
|
|
641
|
+
label={d.name}
|
|
642
|
+
description={d.description}
|
|
643
|
+
leading={<IconTile size={36}>{d.icon}</IconTile>}
|
|
644
|
+
badge={d.status}
|
|
645
|
+
count={d.steps > 0 ? d.steps : undefined}
|
|
646
|
+
selected={selected === d.id}
|
|
647
|
+
onSelect={() => setSelected(d.id)}
|
|
648
|
+
>
|
|
649
|
+
<TimelineBar
|
|
650
|
+
start={d.start}
|
|
651
|
+
end={d.end}
|
|
652
|
+
tone={d.id === 'db' ? 'warning' : d.id === 'docs' ? 'neutral' : 'accent'}
|
|
653
|
+
running={d.id === 'api'}
|
|
654
|
+
label={
|
|
655
|
+
<Text variant="caption" tone="secondary" numeric>
|
|
656
|
+
{d.end - d.start}분
|
|
657
|
+
</Text>
|
|
658
|
+
}
|
|
659
|
+
/>
|
|
660
|
+
</TimelineRow>
|
|
661
|
+
))}
|
|
662
|
+
</Timeline>
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### 의존 관계 연결선
|
|
668
|
+
|
|
669
|
+
TimelineBar에 id를 주고 links={[{ from, to }]}로 앞 작업의 끝과 뒤 작업의 시작을 점선 곡선으로 이어요. 퍼센트 위치로 그려서 재지 않고, 값이 바뀌면 막대와 함께 곡선이 미끄러져요.
|
|
670
|
+
|
|
671
|
+
```tsx
|
|
672
|
+
import { useState } from 'react'
|
|
673
|
+
import { Button, Stack, Timeline, TimelineBar, TimelineRow, type TimelineLink } from '@iamfiro/design'
|
|
674
|
+
|
|
675
|
+
const LINKS: TimelineLink[] = [
|
|
676
|
+
{ from: 'design', to: 'build' },
|
|
677
|
+
{ from: 'build', to: 'qa' },
|
|
678
|
+
{ from: 'design', to: 'docs', tone: 'neutral' },
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
export default function Example() {
|
|
682
|
+
// Pushing the design task back moves every bar after it; the curves slide along.
|
|
683
|
+
const [delay, setDelay] = useState(0)
|
|
684
|
+
return (
|
|
685
|
+
<Stack gap={4} align="start" style={{ width: '100%', maxWidth: 620 }}>
|
|
686
|
+
<Timeline max={20} step={5} formatTick={(v) => `${v}일`} labelWidth={110} links={LINKS} aria-label="출시 작업 의존 관계" style={{ width: '100%' }}>
|
|
687
|
+
<TimelineRow label="화면 디자인">
|
|
688
|
+
<TimelineBar id="design" start={0} end={5 + delay} tone="purple" label={`${5 + delay}일`} />
|
|
689
|
+
</TimelineRow>
|
|
690
|
+
<TimelineRow label="기능 개발">
|
|
691
|
+
<TimelineBar id="build" start={6 + delay} end={13 + delay} tone="accent" label="7일" />
|
|
692
|
+
</TimelineRow>
|
|
693
|
+
<TimelineRow label="도움말 문서">
|
|
694
|
+
<TimelineBar id="docs" start={7 + delay} end={10 + delay} tone="neutral" label="3일" />
|
|
695
|
+
</TimelineRow>
|
|
696
|
+
<TimelineRow label="품질 검사">
|
|
697
|
+
<TimelineBar id="qa" start={13.5 + delay} end={16 + delay} tone="teal" label="2.5일" />
|
|
698
|
+
</TimelineRow>
|
|
699
|
+
</Timeline>
|
|
700
|
+
<Button size="sm" onClick={() => setDelay((d) => (d === 0 ? 2 : 0))}>
|
|
701
|
+
{delay === 0 ? '디자인 2일 늦추기' : '원래 일정으로'}
|
|
702
|
+
</Button>
|
|
703
|
+
</Stack>
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
### 구간으로 나뉜 막대
|
|
709
|
+
|
|
710
|
+
segments로 한 막대를 이어지는 색 구간(준비 · 작업 · 검토)으로 나눠요. variant="outline"과 함께 쓰면 옅은 실선 틀 안에 얇은 구간들이 놓이고, fill이면 막대 자체가 나뉘어요.
|
|
711
|
+
|
|
712
|
+
```tsx
|
|
713
|
+
import { ColorDot, Inline, Stack, Text, Timeline, TimelineBar, TimelineRow, type TimelineBarSegment } from '@iamfiro/design'
|
|
714
|
+
|
|
715
|
+
// Each task split into consecutive phases: 준비 → 작업 → 검토.
|
|
716
|
+
const TASKS: Array<{ name: string; segments: TimelineBarSegment[] }> = [
|
|
717
|
+
{
|
|
718
|
+
name: '요금제 문구 작성',
|
|
719
|
+
segments: [
|
|
720
|
+
{ start: 11, end: 15, tone: 'teal' },
|
|
721
|
+
{ start: 15, end: 42, tone: 'purple' },
|
|
722
|
+
{ start: 42, end: 48, tone: 'accent' },
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
name: '경쟁사 조사',
|
|
727
|
+
segments: [
|
|
728
|
+
{ start: 6, end: 11, tone: 'teal' },
|
|
729
|
+
{ start: 11, end: 24, tone: 'accent' },
|
|
730
|
+
{ start: 24, end: 36, tone: 'purple' },
|
|
731
|
+
],
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: '배포 점검',
|
|
735
|
+
segments: [
|
|
736
|
+
{ start: 30, end: 33, tone: 'teal' },
|
|
737
|
+
{ start: 33, end: 58, tone: 'accent' },
|
|
738
|
+
],
|
|
739
|
+
},
|
|
740
|
+
]
|
|
741
|
+
|
|
742
|
+
const LEGEND = [
|
|
743
|
+
{ label: '준비', color: 'var(--chart-3)' },
|
|
744
|
+
{ label: '작업', color: 'var(--chart-4)' },
|
|
745
|
+
{ label: '검토', color: 'var(--accent)' },
|
|
746
|
+
]
|
|
747
|
+
|
|
748
|
+
const seconds = (v: number) => `${v}s`
|
|
749
|
+
|
|
750
|
+
export default function Example() {
|
|
751
|
+
return (
|
|
752
|
+
<Stack gap={3} style={{ width: '100%', maxWidth: 620 }}>
|
|
753
|
+
<Timeline max={90} step={30} formatTick={seconds} labelWidth={130} aria-label="작업 단계별 소요 시간">
|
|
754
|
+
{TASKS.map((t) => {
|
|
755
|
+
const start = t.segments[0].start
|
|
756
|
+
const end = t.segments[t.segments.length - 1].end
|
|
757
|
+
return (
|
|
758
|
+
<TimelineRow key={t.name} label={t.name}>
|
|
759
|
+
<TimelineBar start={start} end={end} variant="outline" segments={t.segments} label={seconds(end - start)} />
|
|
760
|
+
</TimelineRow>
|
|
761
|
+
)
|
|
762
|
+
})}
|
|
763
|
+
<TimelineRow label="테두리 없이">
|
|
764
|
+
<TimelineBar
|
|
765
|
+
start={6}
|
|
766
|
+
end={40}
|
|
767
|
+
size="sm"
|
|
768
|
+
segments={[
|
|
769
|
+
{ start: 6, end: 14, tone: 'teal' },
|
|
770
|
+
{ start: 14, end: 30, tone: 'accent' },
|
|
771
|
+
{ start: 30, end: 40, tone: 'purple' },
|
|
772
|
+
]}
|
|
773
|
+
label="34s"
|
|
774
|
+
/>
|
|
775
|
+
</TimelineRow>
|
|
776
|
+
</Timeline>
|
|
777
|
+
<Inline gap={4}>
|
|
778
|
+
{LEGEND.map((l) => (
|
|
779
|
+
<Inline key={l.label} gap={2}>
|
|
780
|
+
<ColorDot color={l.color} />
|
|
781
|
+
<Text variant="caption" tone="secondary">
|
|
782
|
+
{l.label}
|
|
783
|
+
</Text>
|
|
784
|
+
</Inline>
|
|
785
|
+
))}
|
|
786
|
+
</Inline>
|
|
787
|
+
</Stack>
|
|
788
|
+
)
|
|
789
|
+
}
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### 트리 + 타임라인
|
|
793
|
+
|
|
794
|
+
왼쪽 칸(aside)에 TreeView를 두고 flattenTree로 같은 보이는 행을 Timeline 행으로 그려요(rowHeight = TREE_ROW_HEIGHT, animated={false}). 접힌 부모는 "+N" 칩으로 숨은 단계 수를 보여 주고, 막대를 누르면 renderTarget으로 감싼 Popover가 막대에 붙어 상세(Stat + Grid)를 띄워요. 행을 고르면 이름 칸까지 파란 배경이 깔려요.
|
|
795
|
+
|
|
796
|
+
```tsx
|
|
797
|
+
import { useMemo, useState, type ReactNode } from 'react'
|
|
798
|
+
import { FileText, FlaskConical, GitBranch, GitPullRequest, Package, PenLine, Rocket, Search, X } from 'lucide-react'
|
|
799
|
+
import { Badge, Box, BrandIcon, Button, Grid, IconTile, Inline, Stack, Text, Popover, Stat, TREE_ROW_HEIGHT, Timeline, TimelineBar, TimelineRow, TreeView, flattenTree, type TimelineBarSegment, type TimelineBarTone, type TreeNode } from '@iamfiro/design'
|
|
800
|
+
|
|
801
|
+
interface Span {
|
|
802
|
+
start: number
|
|
803
|
+
end: number
|
|
804
|
+
owner: string
|
|
805
|
+
tone: TimelineBarTone
|
|
806
|
+
icon: ReactNode
|
|
807
|
+
segments?: TimelineBarSegment[]
|
|
808
|
+
failed?: boolean
|
|
809
|
+
request?: string
|
|
810
|
+
result?: string
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const tile = (icon: ReactNode, tone: 'neutral' | 'danger' = 'neutral') => (
|
|
814
|
+
<IconTile size={26} tone={tone}>
|
|
815
|
+
{icon}
|
|
816
|
+
</IconTile>
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
const node = (id: string, label: string, span: Span, children?: TreeNode<Span>[]): TreeNode<Span> => ({
|
|
820
|
+
id,
|
|
821
|
+
label,
|
|
822
|
+
description: span.owner,
|
|
823
|
+
leading: tile(span.icon, span.failed ? 'danger' : 'neutral'),
|
|
824
|
+
data: span,
|
|
825
|
+
children,
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
const ITEMS: TreeNode<Span>[] = [
|
|
829
|
+
node('release', '출시 준비', { start: 0, end: 82, owner: '김민지', tone: 'neutral', icon: <Rocket size={14} />, request: '금요일까지 새 요금제 페이지 출시', result: '요금제 페이지 배포 완료 · 공지 발송' }, [
|
|
830
|
+
node('plan', '일정 계획', { start: 0.5, end: 7.4, owner: '김민지', tone: 'purple', icon: <PenLine size={14} />, request: '출시까지 할 일과 담당자 정리', result: '작업 6개 · 담당자 3명 배정' }),
|
|
831
|
+
node(
|
|
832
|
+
'research',
|
|
833
|
+
'시장 조사',
|
|
834
|
+
{
|
|
835
|
+
start: 6,
|
|
836
|
+
end: 36,
|
|
837
|
+
owner: '이도현',
|
|
838
|
+
tone: 'accent',
|
|
839
|
+
icon: <Search size={14} />,
|
|
840
|
+
segments: [
|
|
841
|
+
{ start: 6, end: 11, tone: 'teal' },
|
|
842
|
+
{ start: 11, end: 24, tone: 'accent' },
|
|
843
|
+
{ start: 24, end: 36, tone: 'purple' },
|
|
844
|
+
],
|
|
845
|
+
request: '경쟁사 요금제 다섯 곳 비교',
|
|
846
|
+
result: '비교표를 문서에 정리',
|
|
847
|
+
},
|
|
848
|
+
[
|
|
849
|
+
node('research/a', '경쟁사 A 조사', { start: 6, end: 14, owner: '이도현', tone: 'accent', icon: <FileText size={14} /> }),
|
|
850
|
+
node('research/b', '경쟁사 B 조사', { start: 14, end: 24, owner: '이도현', tone: 'accent', icon: <FileText size={14} /> }),
|
|
851
|
+
node('research/sheet', '비교표 정리', { start: 24, end: 36, owner: '이도현', tone: 'purple', icon: <FileText size={14} /> }),
|
|
852
|
+
],
|
|
853
|
+
),
|
|
854
|
+
node(
|
|
855
|
+
'copy',
|
|
856
|
+
'요금제 문구 작성',
|
|
857
|
+
{
|
|
858
|
+
start: 11,
|
|
859
|
+
end: 48,
|
|
860
|
+
owner: '이도현',
|
|
861
|
+
tone: 'accent',
|
|
862
|
+
icon: <PenLine size={14} />,
|
|
863
|
+
segments: [
|
|
864
|
+
{ start: 11, end: 15, tone: 'teal' },
|
|
865
|
+
{ start: 15, end: 42, tone: 'purple' },
|
|
866
|
+
{ start: 42, end: 48, tone: 'teal' },
|
|
867
|
+
],
|
|
868
|
+
request: '세 가지 요금제의 소개 문구 작성',
|
|
869
|
+
result: '무료 · 프로 · 팀 문구를 문서에 저장',
|
|
870
|
+
},
|
|
871
|
+
[
|
|
872
|
+
node('copy/draft', '초안 작성', { start: 11, end: 30, owner: '이도현', tone: 'purple', icon: <PenLine size={14} /> }),
|
|
873
|
+
node('copy/review', '문구 검토', { start: 30, end: 48, owner: '김민지', tone: 'teal', icon: <PenLine size={14} /> }),
|
|
874
|
+
],
|
|
875
|
+
),
|
|
876
|
+
node('build', '페이지 개발', { start: 20, end: 74, owner: '박서연', tone: 'accent', icon: <Package size={14} />, request: '요금제 페이지 구현과 결제 연동', result: 'PR #482 병합' }, [
|
|
877
|
+
node('build/branch', '브랜치 만들기', { start: 20, end: 21.5, owner: 'GitHub', tone: 'neutral', icon: <GitBranch size={14} /> }),
|
|
878
|
+
node('build/page', '페이지 작성', { start: 21.5, end: 50, owner: '박서연', tone: 'accent', icon: <BrandIcon name="figma" size={14} /> }),
|
|
879
|
+
node('build/test', '결제 테스트', { start: 50, end: 56, owner: 'GitHub', tone: 'danger', icon: <FlaskConical size={14} />, failed: true, request: '결제 흐름 테스트 실행', result: '쿠폰 적용 테스트 1건 실패' }),
|
|
880
|
+
node('build/retry', '테스트 다시 실행', { start: 62, end: 68, owner: 'GitHub', tone: 'neutral', icon: <FlaskConical size={14} /> }),
|
|
881
|
+
node('build/pr', '풀 리퀘스트 #482', { start: 68, end: 74, owner: 'GitHub', tone: 'neutral', icon: <GitPullRequest size={14} /> }),
|
|
882
|
+
]),
|
|
883
|
+
node('notify', '출시 공지', { start: 76, end: 81, owner: 'Slack', tone: 'teal', icon: <BrandIcon name="slack" size={14} /> }),
|
|
884
|
+
]),
|
|
885
|
+
]
|
|
886
|
+
|
|
887
|
+
const ROW = TREE_ROW_HEIGHT.default
|
|
888
|
+
const secs = (v: number) => `${Number(v.toFixed(1))}초`
|
|
889
|
+
const tick = (v: number) => (v < 60 ? `${v}초` : `${Math.floor(v / 60)}분${v % 60 ? ` ${v % 60}초` : ''}`)
|
|
890
|
+
const countAll = (n: TreeNode<Span>): number => (n.children ?? []).reduce((sum, c) => sum + 1 + countAll(c), 0)
|
|
891
|
+
|
|
892
|
+
function SpanDetail({ item, close }: { item: TreeNode<Span>; close: () => void }) {
|
|
893
|
+
const span = item.data as Span
|
|
894
|
+
return (
|
|
895
|
+
<Stack gap={3}>
|
|
896
|
+
<Inline gap={3} wrap={false} align="start">
|
|
897
|
+
{item.leading}
|
|
898
|
+
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
|
899
|
+
<Text variant="label-md" truncate>
|
|
900
|
+
{item.label}
|
|
901
|
+
</Text>
|
|
902
|
+
<Text variant="caption" tone="tertiary" truncate>
|
|
903
|
+
{span.owner}
|
|
904
|
+
</Text>
|
|
905
|
+
</Stack>
|
|
906
|
+
<Badge size="sm" tone={span.failed ? 'danger' : 'success'}>
|
|
907
|
+
{span.failed ? '실패' : '완료'}
|
|
908
|
+
</Badge>
|
|
909
|
+
<Button variant="ghost" size="sm" iconOnly icon={<X size={16} />} aria-label="닫기" onClick={close} />
|
|
910
|
+
</Inline>
|
|
911
|
+
<Box background="subtle" radius="lg" padding={3}>
|
|
912
|
+
<Grid columns={3} gap={3}>
|
|
913
|
+
<Stat size="sm" label="소요 시간" value={secs(span.end - span.start)} />
|
|
914
|
+
<Stat size="sm" label="시작" value={`+${secs(span.start)}`} />
|
|
915
|
+
<Stat size="sm" label="하위 단계" value={countAll(item)} />
|
|
916
|
+
</Grid>
|
|
917
|
+
</Box>
|
|
918
|
+
{span.request && (
|
|
919
|
+
<Stack gap={1}>
|
|
920
|
+
<Text variant="overline" tone="tertiary">
|
|
921
|
+
요청
|
|
922
|
+
</Text>
|
|
923
|
+
<Text variant="body-sm">{span.request}</Text>
|
|
924
|
+
</Stack>
|
|
925
|
+
)}
|
|
926
|
+
{span.result && (
|
|
927
|
+
<Stack gap={1}>
|
|
928
|
+
<Text variant="overline" tone="tertiary">
|
|
929
|
+
결과
|
|
930
|
+
</Text>
|
|
931
|
+
<Text variant="body-sm">{span.result}</Text>
|
|
932
|
+
</Stack>
|
|
933
|
+
)}
|
|
934
|
+
</Stack>
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export default function Example() {
|
|
939
|
+
const [expanded, setExpanded] = useState(['release', 'build'])
|
|
940
|
+
const [selected, setSelected] = useState<string | null>('copy')
|
|
941
|
+
|
|
942
|
+
// Collapsed parents show how many steps they hide.
|
|
943
|
+
const items = useMemo(() => {
|
|
944
|
+
const withCounts = (list: TreeNode<Span>[]): TreeNode<Span>[] =>
|
|
945
|
+
list.map((n) => ({
|
|
946
|
+
...n,
|
|
947
|
+
trailing:
|
|
948
|
+
n.children && !expanded.includes(n.id) ? (
|
|
949
|
+
<Badge size="sm" square>
|
|
950
|
+
+{countAll(n)}
|
|
951
|
+
</Badge>
|
|
952
|
+
) : n.data?.failed ? (
|
|
953
|
+
<Badge size="sm" tone="danger">
|
|
954
|
+
실패
|
|
955
|
+
</Badge>
|
|
956
|
+
) : undefined,
|
|
957
|
+
children: n.children && withCounts(n.children),
|
|
958
|
+
}))
|
|
959
|
+
return withCounts(ITEMS)
|
|
960
|
+
}, [expanded])
|
|
961
|
+
|
|
962
|
+
// The same visible rows the tree draws, in the same order: row N sits at N × ROW in both.
|
|
963
|
+
const rows = useMemo(() => flattenTree(items, expanded), [items, expanded])
|
|
964
|
+
|
|
965
|
+
return (
|
|
966
|
+
<Timeline
|
|
967
|
+
max={100}
|
|
968
|
+
step={30}
|
|
969
|
+
formatTick={tick}
|
|
970
|
+
labelWidth={300}
|
|
971
|
+
labelHeader="단계"
|
|
972
|
+
rowHeight={ROW}
|
|
973
|
+
aria-label="출시 준비 단계별 소요 시간"
|
|
974
|
+
style={{ width: '100%', maxWidth: 820 }}
|
|
975
|
+
aside={
|
|
976
|
+
<TreeView
|
|
977
|
+
aria-label="출시 준비 단계"
|
|
978
|
+
items={items}
|
|
979
|
+
expanded={expanded}
|
|
980
|
+
onExpandedChange={setExpanded}
|
|
981
|
+
selectionMode="none"
|
|
982
|
+
expandOnClick={false}
|
|
983
|
+
onAction={(n) => setSelected(n.id)}
|
|
984
|
+
animated={false}
|
|
985
|
+
/>
|
|
986
|
+
}
|
|
987
|
+
>
|
|
988
|
+
{rows.map(({ id, node: item, depth }) => {
|
|
989
|
+
const span = item.data as Span
|
|
990
|
+
return (
|
|
991
|
+
<TimelineRow key={id} selected={selected === id}>
|
|
992
|
+
<TimelineBar
|
|
993
|
+
start={span.start}
|
|
994
|
+
end={span.end}
|
|
995
|
+
tone={span.tone}
|
|
996
|
+
variant={span.segments ? 'outline' : 'fill'}
|
|
997
|
+
segments={span.segments}
|
|
998
|
+
size={depth === 0 ? 'lg' : span.segments ? 'sm' : 'md'}
|
|
999
|
+
aria-label={`${item.label as string}, ${secs(span.end - span.start)}`}
|
|
1000
|
+
onClick={() => setSelected(id)}
|
|
1001
|
+
renderTarget={(target) => (
|
|
1002
|
+
<Popover trigger={target} width={340} aria-label={`${item.label as string} 상세`}>
|
|
1003
|
+
{(close) => <SpanDetail item={item} close={close} />}
|
|
1004
|
+
</Popover>
|
|
1005
|
+
)}
|
|
1006
|
+
label={
|
|
1007
|
+
<Text variant="caption" tone="secondary" numeric>
|
|
1008
|
+
{secs(span.end - span.start)}
|
|
1009
|
+
</Text>
|
|
1010
|
+
}
|
|
1011
|
+
/>
|
|
1012
|
+
</TimelineRow>
|
|
1013
|
+
)
|
|
1014
|
+
})}
|
|
1015
|
+
</Timeline>
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
1018
|
+
```
|