@t3q-ui/design-system 0.0.0-stage → 1.56.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.
@@ -0,0 +1,873 @@
1
+ import { cn, Skeleton, EmptyBoxIllustration, Button, LucideIcon, ToggleGroup, ToggleGroupItem, PageHeader, Alert, AlertDescription, AgGridTable, getSortedColumnState, TablePagination, Pagination, Panel, IconButton, Form, FormSectionLevelContext, FormFooter, Card, Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, TwoLineCellRenderer, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '../chunk-IPF3K4BS.js';
2
+ import { useRef, useEffect, useMemo, useId, isValidElement } from 'react';
3
+ import { RotateCcw, X, TriangleAlert, Ellipsis } from 'lucide-react';
4
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
+
6
+ // src/patterns/listPageRules.ts
7
+ function resolvePrimaryField(primaryField, hasRowClick) {
8
+ if (primaryField !== void 0) return primaryField;
9
+ return hasRowClick ? "name" : void 0;
10
+ }
11
+ function withPrimaryColumn(columns, primaryField) {
12
+ if (!primaryField) return columns;
13
+ return columns.map(
14
+ (c) => c.field === primaryField && c.type == null ? { ...c, type: "primary" } : c
15
+ );
16
+ }
17
+ function withSecondaryLine(columns, primaryField, secondaryField) {
18
+ if (!primaryField || !secondaryField) return columns;
19
+ return columns.map(
20
+ (c) => c.field === primaryField && c.cellRenderer == null ? {
21
+ ...c,
22
+ cellRenderer: TwoLineCellRenderer,
23
+ cellRendererParams: { ...c.cellRendererParams, secondaryField }
24
+ } : c
25
+ );
26
+ }
27
+ function activeRowClassRules(activeRowId, userRules) {
28
+ if (activeRowId == null) return userRules;
29
+ return {
30
+ ...userRules,
31
+ "ag-row-active": (p) => p.data?.id === activeRowId
32
+ };
33
+ }
34
+ var showEmptyReset = (onResetFilters, filtersActive) => Boolean(onResetFilters) && filtersActive !== false;
35
+ function splitEmptyText(text) {
36
+ const t = text.trim();
37
+ const m = /^(.*?[.!?。])\s+(\S[\s\S]*)$/.exec(t);
38
+ const strip = (s) => s.replace(/[.。]$/, "");
39
+ if (!m) return { title: strip(t) };
40
+ return { title: strip(m[1]), description: m[2] };
41
+ }
42
+ function LoadingState({ className, lines = 3 }) {
43
+ return /* @__PURE__ */ jsxs(
44
+ "div",
45
+ {
46
+ role: "status",
47
+ "aria-label": "\uBD88\uB7EC\uC624\uB294 \uC911",
48
+ className: cn("flex flex-col gap-[12px]", className),
49
+ children: [
50
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-[24px] w-[240px]" }),
51
+ Array.from({ length: lines }, (_, i) => /* @__PURE__ */ jsx(Skeleton, { className: cn("h-[16px]", i % 2 ? "w-3/5" : "w-full") }, i))
52
+ ]
53
+ }
54
+ );
55
+ }
56
+ function StateBox({
57
+ role,
58
+ className,
59
+ icon,
60
+ iconClassName,
61
+ title,
62
+ description,
63
+ action
64
+ }) {
65
+ return /* @__PURE__ */ jsxs(
66
+ "div",
67
+ {
68
+ role,
69
+ className: cn(
70
+ "flex flex-col items-center justify-center gap-[4px] text-center text-sys-text-subtler",
71
+ className
72
+ ),
73
+ children: [
74
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("mb-[4px] flex text-sys-text-subtler", iconClassName), children: icon }),
75
+ /* @__PURE__ */ jsx("span", { className: "text-h5 text-sys-text-main", children: title }),
76
+ description ? /* @__PURE__ */ jsx("span", { className: "break-keep text-base-regular text-sys-text-subtle wrap-break-word", children: description }) : null,
77
+ action ? /* @__PURE__ */ jsx("div", { className: "pointer-events-auto mt-[12px] flex gap-[8px]", children: action }) : null
78
+ ]
79
+ }
80
+ );
81
+ }
82
+ function EmptyState({
83
+ className,
84
+ children,
85
+ icon,
86
+ title,
87
+ description,
88
+ action
89
+ }) {
90
+ return /* @__PURE__ */ jsx(
91
+ StateBox,
92
+ {
93
+ role: "status",
94
+ className,
95
+ icon: icon ?? /* @__PURE__ */ jsx(EmptyBoxIllustration, {}),
96
+ title: title ?? children,
97
+ description,
98
+ action
99
+ }
100
+ );
101
+ }
102
+ function ErrorState({
103
+ className,
104
+ title = "\uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4",
105
+ description,
106
+ onRetry,
107
+ retryLabel = "\uB2E4\uC2DC \uC2DC\uB3C4"
108
+ }) {
109
+ return /* @__PURE__ */ jsx(
110
+ StateBox,
111
+ {
112
+ role: "alert",
113
+ className,
114
+ icon: /* @__PURE__ */ jsx(LucideIcon, { iconComponentName: "CircleAlert", size: 36 }),
115
+ iconClassName: "text-danger30",
116
+ title,
117
+ description,
118
+ action: onRetry ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "primary", onClick: onRetry, children: retryLabel }) : null
119
+ }
120
+ );
121
+ }
122
+ var DETAIL_WIDTH = {
123
+ small: "w-[360px]",
124
+ medium: "w-[480px]"
125
+ };
126
+ var QUICK_ALL = "__all__";
127
+ var isEmptyContent = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && !isValidElement(v) && "title" in v;
128
+ function resolveEmptyContent(emptyState) {
129
+ if (emptyState == null) return splitEmptyText("\uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.");
130
+ if (typeof emptyState === "string") return splitEmptyText(emptyState);
131
+ if (isEmptyContent(emptyState)) return emptyState;
132
+ return { children: emptyState };
133
+ }
134
+ function ListPage({
135
+ title,
136
+ description,
137
+ titleRight,
138
+ actions,
139
+ filters,
140
+ onResetFilters,
141
+ columns,
142
+ rows,
143
+ loading,
144
+ error,
145
+ emptyState,
146
+ pagination,
147
+ filtersActive,
148
+ primaryField,
149
+ secondaryField,
150
+ tableTitle,
151
+ quickFilter,
152
+ activeRowId,
153
+ detail,
154
+ onSortChange,
155
+ onRowClick,
156
+ tableProps
157
+ }) {
158
+ const onCloseRef = useRef(detail?.onClose);
159
+ useEffect(() => {
160
+ onCloseRef.current = detail?.onClose;
161
+ }, [detail?.onClose]);
162
+ const detailOpen = detail != null;
163
+ useEffect(() => {
164
+ if (!detailOpen) return;
165
+ const onKey = (e) => {
166
+ if (e.key !== "Escape" || e.defaultPrevented) return;
167
+ const target = e.target;
168
+ if (target?.closest?.('[role="dialog"],[role="alertdialog"]')) return;
169
+ onCloseRef.current?.();
170
+ };
171
+ document.addEventListener("keydown", onKey);
172
+ return () => document.removeEventListener("keydown", onKey);
173
+ }, [detailOpen]);
174
+ const resetButton = onResetFilters ? /* @__PURE__ */ jsxs(
175
+ Button,
176
+ {
177
+ type: "button",
178
+ variant: "secondaryOutline",
179
+ onClick: onResetFilters,
180
+ className: cn(filtersActive === false && "invisible"),
181
+ "aria-hidden": filtersActive === false || void 0,
182
+ children: [
183
+ /* @__PURE__ */ jsx(RotateCcw, { size: 16, "aria-hidden": true }),
184
+ "\uD544\uD130 \uCD08\uAE30\uD654"
185
+ ]
186
+ }
187
+ ) : null;
188
+ const hasCaption = tableTitle != null || quickFilter != null;
189
+ const filterRow = filters || resetButton && !hasCaption ? /* @__PURE__ */ jsxs("div", { className: "flex w-full items-start gap-[8px]", children: [
190
+ filters,
191
+ !hasCaption ? resetButton : null
192
+ ] }) : void 0;
193
+ const columnDefs = useMemo(() => {
194
+ const primary = resolvePrimaryField(
195
+ primaryField,
196
+ Boolean(onRowClick) || Boolean(secondaryField)
197
+ );
198
+ return withSecondaryLine(withPrimaryColumn(columns, primary), primary, secondaryField);
199
+ }, [columns, primaryField, onRowClick, secondaryField]);
200
+ const total = pagination?.total;
201
+ const quickAllCount = quickFilter?.options.every((o) => o.count !== void 0) ? quickFilter.options.reduce((sum, o) => sum + (o.count ?? 0), 0) : void 0;
202
+ const quickIsAll = (quickFilter?.value.length ?? 0) === 0;
203
+ const quick = quickFilter ? /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-[8px]", children: [
204
+ /* @__PURE__ */ jsx("span", { className: "text-small-regular font-medium text-sys-text-subtle", children: quickFilter.label }),
205
+ /* @__PURE__ */ jsxs(
206
+ ToggleGroup,
207
+ {
208
+ type: "multiple",
209
+ variant: "outline",
210
+ spacing: true,
211
+ "aria-label": quickFilter.label,
212
+ value: quickIsAll ? [QUICK_ALL] : quickFilter.value,
213
+ onValueChange: (next) => {
214
+ const pressedAll = next.includes(QUICK_ALL) && !quickIsAll;
215
+ quickFilter.onChange(pressedAll ? [] : next.filter((v) => v !== QUICK_ALL));
216
+ },
217
+ children: [
218
+ /* @__PURE__ */ jsx(ToggleGroupItem, { value: QUICK_ALL, count: quickAllCount, countVariant: "primary", children: "\uC804\uCCB4" }),
219
+ quickFilter.options.map((o) => /* @__PURE__ */ jsx(
220
+ ToggleGroupItem,
221
+ {
222
+ value: o.value,
223
+ count: o.count,
224
+ countVariant: o.countVariant,
225
+ children: o.label
226
+ },
227
+ o.value
228
+ ))
229
+ ]
230
+ }
231
+ )
232
+ ] }) : null;
233
+ const caption = hasCaption ? /* @__PURE__ */ jsxs("div", { className: "mb-[8px] flex items-center justify-between gap-[8px]", children: [
234
+ tableTitle != null || quick ? /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-[16px]", children: [
235
+ tableTitle != null ? /* @__PURE__ */ jsx("h2", { className: "text-h6 text-sys-text-main", children: tableTitle }) : null,
236
+ quick
237
+ ] }) : /* @__PURE__ */ jsx("span", {}),
238
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-[16px]", children: [
239
+ total != null && quickAllCount === void 0 ? /* @__PURE__ */ jsxs("span", { className: "text-small-regular text-sys-text-subtle", children: [
240
+ "\uCD1D ",
241
+ total.toLocaleString(),
242
+ "\uAC1C"
243
+ ] }) : null,
244
+ resetButton
245
+ ] })
246
+ ] }) : null;
247
+ const resetRef = useRef(onResetFilters);
248
+ useEffect(() => {
249
+ resetRef.current = onResetFilters;
250
+ }, [onResetFilters]);
251
+ const withReset = showEmptyReset(onResetFilters, filtersActive);
252
+ const EmptyOverlay = useMemo(
253
+ () => function ListPageEmptyOverlay() {
254
+ const content = resolveEmptyContent(emptyState);
255
+ return /* @__PURE__ */ jsx(
256
+ EmptyState,
257
+ {
258
+ className: "pt-[32px] pb-[16px]",
259
+ title: content.title,
260
+ description: content.description ?? content.children,
261
+ action: withReset && content.children == null ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "secondaryLink", onClick: () => resetRef.current?.(), children: "\uD544\uD130 \uCD08\uAE30\uD654" }) : null
262
+ }
263
+ );
264
+ },
265
+ [emptyState, withReset]
266
+ );
267
+ const rowClassRules = useMemo(
268
+ () => activeRowClassRules(activeRowId, tableProps?.rowClassRules),
269
+ [activeRowId, tableProps?.rowClassRules]
270
+ );
271
+ return /* @__PURE__ */ jsxs("div", { className: "w-full", children: [
272
+ /* @__PURE__ */ jsx(
273
+ PageHeader,
274
+ {
275
+ title,
276
+ description,
277
+ titleRight,
278
+ actions,
279
+ bottomRight: filterRow
280
+ }
281
+ ),
282
+ /* @__PURE__ */ jsx("section", { "aria-label": title, className: "px-[24px] py-[32px]", children: error ? /* @__PURE__ */ jsx(Alert, { variant: "danger", children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) }) : (
283
+ // 상세 패널(detail)이 있으면 표 쪽이 줄고 패널이 오른쪽에 나란히 선다 — 목록 맥락을 유지한 채 상세를 본다
284
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-[24px]", children: [
285
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
286
+ caption,
287
+ /* @__PURE__ */ jsx(
288
+ AgGridTable,
289
+ {
290
+ rowData: rows,
291
+ columnDefs,
292
+ loading,
293
+ noRowsOverlayComponent: EmptyOverlay,
294
+ onSortChanged: onSortChange ? (e) => onSortChange(getSortedColumnState(e).value) : void 0,
295
+ onRowClicked: onRowClick ? (e) => {
296
+ if (e.data) onRowClick(e.data);
297
+ } : void 0,
298
+ domLayout: "autoHeight",
299
+ ...secondaryField ? { rowHeight: 56 } : {},
300
+ ...tableProps,
301
+ rowClassRules
302
+ }
303
+ ),
304
+ pagination ? /* @__PURE__ */ jsx(TablePagination, { children: /* @__PURE__ */ jsx(
305
+ Pagination,
306
+ {
307
+ total: pagination.total,
308
+ rowsPerPage: pagination.pageSize,
309
+ page: pagination.page,
310
+ onChange: pagination.onChange,
311
+ showLabel: true
312
+ }
313
+ ) }) : null
314
+ ] }),
315
+ detail ? (
316
+ // 나란한 상세 패널: 제목·설명·닫기 → 본문 → (안내) → 버튼 줄. 스크롤해도 따라오게 sticky. Card 와 같은 테두리·라운드(그림자 없음).
317
+ // <aside>(complementary) 는 다른 landmark 안에 둘 수 없으므로(axe landmark-complementary-is-top-level) 이름 있는 section — DetailPage 의 aside 와 같다
318
+ /* @__PURE__ */ jsx(
319
+ Panel,
320
+ {
321
+ title: detail.title,
322
+ description: detail.description,
323
+ padding: "large",
324
+ titleRight: /* @__PURE__ */ jsx(
325
+ IconButton,
326
+ {
327
+ type: "button",
328
+ variant: "secondary",
329
+ "aria-label": "\uB2EB\uAE30",
330
+ onClick: detail.onClose,
331
+ children: /* @__PURE__ */ jsx(X, { size: 20 })
332
+ }
333
+ ),
334
+ note: detail.note,
335
+ footer: detail.footer,
336
+ className: cn("sticky top-[24px] shrink-0", DETAIL_WIDTH[detail.width ?? "medium"]),
337
+ children: detail.children
338
+ }
339
+ )
340
+ ) : null
341
+ ] })
342
+ ) })
343
+ ] });
344
+ }
345
+ function MoreMenu({
346
+ items,
347
+ deleteAction
348
+ }) {
349
+ const triggerRef = useRef(null);
350
+ const pendingRef = useRef(void 0);
351
+ const select = (fn) => () => {
352
+ pendingRef.current = fn;
353
+ };
354
+ return /* @__PURE__ */ jsxs(DropdownMenu, { children: [
355
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(IconButton, { ref: triggerRef, variant: "secondary", "aria-label": "\uB354\uBCF4\uAE30", children: /* @__PURE__ */ jsx(Ellipsis, { size: 24 }) }) }),
356
+ /* @__PURE__ */ jsxs(
357
+ DropdownMenuContent,
358
+ {
359
+ align: "end",
360
+ onCloseAutoFocus: (e) => {
361
+ e.preventDefault();
362
+ triggerRef.current?.focus();
363
+ const fn = pendingRef.current;
364
+ pendingRef.current = void 0;
365
+ fn?.();
366
+ },
367
+ children: [
368
+ items.map(({ children, onClick, disabled }, i) => /* @__PURE__ */ jsx(DropdownMenuItem, { disabled, onSelect: select(onClick), children }, i)),
369
+ deleteAction && items.length ? /* @__PURE__ */ jsx(DropdownMenuSeparator, {}) : null,
370
+ deleteAction ? /* @__PURE__ */ jsx(
371
+ DropdownMenuItem,
372
+ {
373
+ variant: "danger",
374
+ disabled: deleteAction.disabled,
375
+ onSelect: select(deleteAction.onClick),
376
+ children: deleteAction.children
377
+ }
378
+ ) : null
379
+ ]
380
+ }
381
+ )
382
+ ] });
383
+ }
384
+ function DetailPage({
385
+ primaryAction,
386
+ secondaryActions,
387
+ deleteAction,
388
+ actions,
389
+ actionsNote,
390
+ banner,
391
+ loading,
392
+ error,
393
+ onRetry,
394
+ notFound,
395
+ aside,
396
+ asideTitle = "\uAE30\uBCF8 \uC815\uBCF4",
397
+ width = "full",
398
+ children,
399
+ ...header
400
+ }) {
401
+ let body;
402
+ if (loading) {
403
+ body = /* @__PURE__ */ jsx(LoadingState, {});
404
+ } else if (error) {
405
+ body = /* @__PURE__ */ jsx(ErrorState, { className: "py-[32px]", description: error, onRetry });
406
+ } else if (notFound) {
407
+ body = /* @__PURE__ */ jsx(
408
+ EmptyState,
409
+ {
410
+ className: "py-[32px]",
411
+ title: notFound === true ? "\uD56D\uBAA9\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4." : notFound,
412
+ description: notFound === true ? "\uC0AD\uC81C\uB418\uC5C8\uAC70\uB098 \uC8FC\uC18C\uAC00 \uC798\uBABB\uB418\uC5C8\uC744 \uC218 \uC788\uC2B5\uB2C8\uB2E4." : void 0
413
+ }
414
+ );
415
+ } else if (aside) {
416
+ body = // 넓은 화면: 본문 2 : 패널 1 + 세로선. 좁은 화면: 세로로 쌓이고 패널은 제목 + 가로선으로 시작한다
417
+ /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 gap-[24px] xl:grid-cols-[2fr_1fr] xl:items-start", children: [
418
+ /* @__PURE__ */ jsx("div", { className: "min-w-0", children }),
419
+ /* @__PURE__ */ jsx(Panel, { title: asideTitle, children: aside })
420
+ ] });
421
+ } else {
422
+ body = children;
423
+ }
424
+ const VISIBLE_SECONDARY = 2;
425
+ const visibleSecondary = secondaryActions?.slice(0, VISIBLE_SECONDARY) ?? [];
426
+ const overflowSecondary = secondaryActions?.slice(VISIBLE_SECONDARY) ?? [];
427
+ const primaryButton = primaryAction ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "primaryBold", ...primaryAction }) : null;
428
+ const moreMenu = (items) => items.length || deleteAction ? /* @__PURE__ */ jsx(MoreMenu, { items, deleteAction }) : null;
429
+ const fullRow = /* @__PURE__ */ jsxs(Fragment, { children: [
430
+ moreMenu(overflowSecondary),
431
+ visibleSecondary.map((action, i) => /* @__PURE__ */ jsx(Button, { type: "button", variant: "secondary", ...action }, i)),
432
+ primaryButton
433
+ ] });
434
+ const collapsedRow = /* @__PURE__ */ jsxs(Fragment, { children: [
435
+ moreMenu(secondaryActions ?? []),
436
+ primaryButton
437
+ ] });
438
+ const collapsible = Boolean(deleteAction || secondaryActions?.length);
439
+ const note = actionsNote != null ? /* @__PURE__ */ jsx(
440
+ "p",
441
+ {
442
+ "data-slot": "detail-actions-note",
443
+ className: "mb-[8px] break-keep text-right text-small-regular text-sys-text-subtle wrap-break-word",
444
+ children: actionsNote
445
+ }
446
+ ) : null;
447
+ const actionRow = actions ? actions : collapsible ? /* @__PURE__ */ jsxs(Fragment, { children: [
448
+ /* @__PURE__ */ jsx("div", { "data-slot": "detail-actions-full", className: "hidden items-center gap-[8px] @3xl:flex", children: fullRow }),
449
+ /* @__PURE__ */ jsx("div", { "data-slot": "detail-actions-collapsed", className: "flex items-center gap-[8px] @3xl:hidden", children: collapsedRow })
450
+ ] }) : primaryButton ? primaryButton : void 0;
451
+ return /* @__PURE__ */ jsxs("div", { className: "w-full", children: [
452
+ /* @__PURE__ */ jsx(PageHeader, { ...header, actions: actionRow }),
453
+ /* @__PURE__ */ jsxs(
454
+ "section",
455
+ {
456
+ "aria-label": header.title || "\uBCF8\uBB38",
457
+ className: "flex flex-col gap-[24px] px-[24px] py-[24px]",
458
+ children: [
459
+ banner,
460
+ /* @__PURE__ */ jsxs("div", { className: cn("w-full", width === "panel" && "mx-auto max-w-[900px]"), children: [
461
+ note,
462
+ body
463
+ ] })
464
+ ]
465
+ }
466
+ )
467
+ ] });
468
+ }
469
+ function useFormShell({
470
+ form,
471
+ onSubmit,
472
+ submitting,
473
+ submitMode = "always"
474
+ }) {
475
+ const { isValid, isSubmitting } = form.formState;
476
+ const busy = Boolean(submitting) || isSubmitting;
477
+ const handleSubmit = form.handleSubmit((values) => onSubmit(values));
478
+ return { busy, canSubmit: !busy && (submitMode === "always" || isValid), handleSubmit };
479
+ }
480
+ function FormError({ error, className }) {
481
+ if (!error) return null;
482
+ return /* @__PURE__ */ jsx(Alert, { variant: "danger", className, children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) });
483
+ }
484
+ function FormActions({
485
+ busy,
486
+ canSubmit,
487
+ onCancel,
488
+ submitLabel = "\uC800\uC7A5",
489
+ cancelLabel = "\uCDE8\uC18C",
490
+ formId
491
+ }) {
492
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
493
+ onCancel ? /* @__PURE__ */ jsx(
494
+ Button,
495
+ {
496
+ type: "button",
497
+ variant: "secondaryOutline",
498
+ onClick: onCancel,
499
+ disabled: busy,
500
+ form: formId,
501
+ children: cancelLabel
502
+ }
503
+ ) : null,
504
+ /* @__PURE__ */ jsx(
505
+ Button,
506
+ {
507
+ type: "submit",
508
+ variant: "primaryBold",
509
+ loading: busy,
510
+ disabled: !canSubmit,
511
+ form: formId,
512
+ children: submitLabel
513
+ }
514
+ )
515
+ ] });
516
+ }
517
+ function FormPage({
518
+ title,
519
+ description,
520
+ breadcrumb,
521
+ titleRight,
522
+ form,
523
+ onSubmit,
524
+ onCancel,
525
+ submitLabel,
526
+ cancelLabel,
527
+ submitting,
528
+ submitMode,
529
+ error,
530
+ requiredLegend,
531
+ canvas = "plain",
532
+ align = "start",
533
+ aside,
534
+ asideTitle = "\uAC80\uD1A0",
535
+ children
536
+ }) {
537
+ const { busy, canSubmit, handleSubmit } = useFormShell({ form, onSubmit, submitting, submitMode });
538
+ const formId = useId();
539
+ const contentMaxWidth = aside ? "var(--max-w-page-content)" : "var(--max-w-form-control)";
540
+ const actions = /* @__PURE__ */ jsx(
541
+ FormActions,
542
+ {
543
+ busy,
544
+ canSubmit,
545
+ onCancel,
546
+ submitLabel,
547
+ cancelLabel,
548
+ formId
549
+ }
550
+ );
551
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
552
+ requiredLegend ? /* @__PURE__ */ jsxs("p", { className: "text-small-regular text-sys-text-subtle", children: [
553
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "text-danger30", children: "*" }),
554
+ " ",
555
+ "\uD45C\uC2DC\uB294 \uD544\uC218 \uC785\uB825 \uD56D\uBAA9\uC785\uB2C8\uB2E4"
556
+ ] }) : null,
557
+ /* @__PURE__ */ jsx(Form, { ...form, children: /* @__PURE__ */ jsxs("form", { id: formId, noValidate: true, onSubmit: handleSubmit, children: [
558
+ /* @__PURE__ */ jsx(FormSectionLevelContext.Provider, { value: 2, children }),
559
+ /* @__PURE__ */ jsx(FormError, { error, className: "mt-[24px]" }),
560
+ aside ? null : /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(
561
+ FormFooter,
562
+ {
563
+ align: "end",
564
+ className: "mt-[24px] border-t border-sys-divider-subtle pt-[16px]",
565
+ children: /* @__PURE__ */ jsx("div", { className: "flex gap-[8px]", children: actions })
566
+ }
567
+ ) })
568
+ ] }) })
569
+ ] });
570
+ const column = canvas === "subtle" ? (
571
+ // 폼 전체를 흰 Card 하나로(섹션 사이 구분선은 그대로). 위 8px 는 첫 FormSection 의 pt-[16px] 과 더해 24
572
+ /* @__PURE__ */ jsx(
573
+ Card,
574
+ {
575
+ className: cn(
576
+ "w-full max-w-[var(--max-w-form-control)] px-[24px] pb-[24px] pt-[8px]",
577
+ align === "center" && !aside && "mx-auto"
578
+ ),
579
+ children: body
580
+ }
581
+ )
582
+ ) : /* @__PURE__ */ jsx(
583
+ "div",
584
+ {
585
+ className: cn(
586
+ "w-full max-w-[var(--max-w-form-control)]",
587
+ align === "center" && !aside && "mx-auto"
588
+ ),
589
+ children: body
590
+ }
591
+ );
592
+ return (
593
+ // subtle 의 캔버스 회색은 기존 토큰(grayscale20, 진단 18-A 의 f7 변형)을 쓴다 — 채택되면 전용 sys 토큰으로 올린다
594
+ /* @__PURE__ */ jsxs("div", { className: cn("w-full", canvas === "subtle" && "bg-sys-background-hover"), children: [
595
+ /* @__PURE__ */ jsx(
596
+ PageHeader,
597
+ {
598
+ title,
599
+ description,
600
+ breadcrumb,
601
+ titleRight,
602
+ contentMaxWidth
603
+ }
604
+ ),
605
+ /* @__PURE__ */ jsx("section", { "aria-label": title || "\uBCF8\uBB38", className: "px-[24px] py-[32px]", children: /* @__PURE__ */ jsx("div", { className: "mx-auto w-full", style: { maxWidth: contentMaxWidth }, children: aside ? (
606
+ // 폼 열(최대 720px) + 검토 패널. 넓은 화면: 옆에 세로선, sticky. 좁은 화면: 폼 아래에 가로선으로 시작.
607
+ // <aside>(complementary) 는 다른 landmark 안에 둘 수 없으므로(axe) 이름 있는 section (DetailPage aside 와 같은 꼴)
608
+ /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 gap-[24px] xl:grid-cols-[minmax(0,var(--max-w-form-control))_minmax(280px,1fr)] xl:items-start", children: [
609
+ column,
610
+ /* @__PURE__ */ jsxs(Panel, { title: asideTitle, className: "xl:sticky xl:top-[24px]", children: [
611
+ aside,
612
+ /* @__PURE__ */ jsx(
613
+ "div",
614
+ {
615
+ "data-slot": "form-page-aside-actions",
616
+ className: "mt-[16px] flex justify-end gap-[8px] border-t border-sys-divider-subtle pt-[16px]",
617
+ children: actions
618
+ }
619
+ )
620
+ ] })
621
+ ] })
622
+ ) : column }) })
623
+ ] })
624
+ );
625
+ }
626
+ function FormDrawer({
627
+ open,
628
+ onOpenChange,
629
+ side,
630
+ size,
631
+ title,
632
+ description,
633
+ form,
634
+ onSubmit,
635
+ submitLabel,
636
+ cancelLabel,
637
+ submitting,
638
+ error,
639
+ submitMode,
640
+ children
641
+ }) {
642
+ const { busy, canSubmit, handleSubmit } = useFormShell({ form, onSubmit, submitting, submitMode });
643
+ const guardedOpenChange = (next) => {
644
+ if (!next && busy) return;
645
+ onOpenChange(next);
646
+ };
647
+ return /* @__PURE__ */ jsx(Drawer, { open, onOpenChange: guardedOpenChange, children: /* @__PURE__ */ jsxs(
648
+ DrawerContent,
649
+ {
650
+ side,
651
+ size,
652
+ ...description ? {} : { "aria-describedby": void 0 },
653
+ children: [
654
+ /* @__PURE__ */ jsxs(DrawerHeader, { children: [
655
+ /* @__PURE__ */ jsx(DrawerTitle, { children: title }),
656
+ description ? /* @__PURE__ */ jsx(DrawerDescription, { children: description }) : null
657
+ ] }),
658
+ /* @__PURE__ */ jsx(Form, { ...form, children: /* @__PURE__ */ jsxs("form", { noValidate: true, onSubmit: handleSubmit, className: "flex min-h-0 flex-1 flex-col", children: [
659
+ /* @__PURE__ */ jsxs(DrawerBody, { className: "px-[24px]", children: [
660
+ children,
661
+ /* @__PURE__ */ jsx(FormError, { error, className: "mt-[24px]" })
662
+ ] }),
663
+ /* @__PURE__ */ jsx(DrawerFooter, { children: /* @__PURE__ */ jsx(
664
+ FormActions,
665
+ {
666
+ busy,
667
+ canSubmit,
668
+ onCancel: () => guardedOpenChange(false),
669
+ submitLabel,
670
+ cancelLabel
671
+ }
672
+ ) })
673
+ ] }) })
674
+ ]
675
+ }
676
+ ) });
677
+ }
678
+ function FormDialog({
679
+ open,
680
+ onOpenChange,
681
+ width,
682
+ title,
683
+ description,
684
+ form,
685
+ onSubmit,
686
+ submitLabel,
687
+ cancelLabel,
688
+ submitting,
689
+ error,
690
+ submitMode,
691
+ children
692
+ }) {
693
+ const { busy, canSubmit, handleSubmit } = useFormShell({ form, onSubmit, submitting, submitMode });
694
+ const guardedOpenChange = (next) => {
695
+ if (!next && busy) return;
696
+ onOpenChange(next);
697
+ };
698
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: guardedOpenChange, children: /* @__PURE__ */ jsxs(
699
+ DialogContent,
700
+ {
701
+ width,
702
+ hideCloseButton: busy,
703
+ ...description ? {} : { "aria-describedby": void 0 },
704
+ children: [
705
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
706
+ /* @__PURE__ */ jsx(DialogTitle, { children: title }),
707
+ description ? /* @__PURE__ */ jsx(DialogDescription, { children: description }) : null
708
+ ] }),
709
+ /* @__PURE__ */ jsx(Form, { ...form, children: /* @__PURE__ */ jsxs("form", { noValidate: true, onSubmit: handleSubmit, className: "flex flex-col gap-[16px]", children: [
710
+ children,
711
+ /* @__PURE__ */ jsx(FormError, { error }),
712
+ /* @__PURE__ */ jsx(DialogFooter, { children: /* @__PURE__ */ jsx(
713
+ FormActions,
714
+ {
715
+ busy,
716
+ canSubmit,
717
+ onCancel: () => guardedOpenChange(false),
718
+ submitLabel,
719
+ cancelLabel
720
+ }
721
+ ) })
722
+ ] }) })
723
+ ]
724
+ }
725
+ ) });
726
+ }
727
+ var TONE_TEXT = {
728
+ default: "text-sys-text-main",
729
+ warning: "text-warning40",
730
+ danger: "text-danger30"
731
+ };
732
+ var TONE_BAR = { default: "bg-primary30", warning: "bg-warning30", danger: "bg-danger30" };
733
+ function StatTile({
734
+ label,
735
+ value,
736
+ caption,
737
+ href,
738
+ ratio,
739
+ tone = "default",
740
+ loading
741
+ }) {
742
+ const labelId = useId();
743
+ const percent = ratio == null ? null : Math.round(Math.min(Math.max(ratio, 0), 1) * 100);
744
+ return /* @__PURE__ */ jsxs(Card, { className: "relative gap-[4px] p-[16px]", children: [
745
+ /* @__PURE__ */ jsx("span", { id: labelId, className: "text-h6 text-sys-text-subtle", children: href ? (
746
+ // 타일 전체를 클릭 영역으로: 링크의 ::after 를 Card 크기로 늘린다
747
+ /* @__PURE__ */ jsx(
748
+ "a",
749
+ {
750
+ href,
751
+ className: "after:absolute after:inset-0 after:rounded-medium focus-visible:outline-[2px] focus-visible:outline-sys-focus-ring-main",
752
+ children: label
753
+ }
754
+ )
755
+ ) : label }),
756
+ loading ? /* @__PURE__ */ jsx(Skeleton, { role: "status", "aria-label": "\uBD88\uB7EC\uC624\uB294 \uC911", className: "h-[32px] w-[80px]" }) : /* @__PURE__ */ jsxs("span", { className: cn("flex items-center gap-[6px] text-h2", TONE_TEXT[tone]), children: [
757
+ tone !== "default" ? /* @__PURE__ */ jsx(TriangleAlert, { "aria-hidden": true, size: 20, className: "shrink-0" }) : null,
758
+ value
759
+ ] }),
760
+ percent != null && !loading ? /* @__PURE__ */ jsx(
761
+ "div",
762
+ {
763
+ role: "progressbar",
764
+ "aria-labelledby": labelId,
765
+ "aria-valuemin": 0,
766
+ "aria-valuemax": 100,
767
+ "aria-valuenow": percent,
768
+ className: "mt-[4px] h-[4px] w-full overflow-hidden rounded-full bg-secondary-o10",
769
+ children: /* @__PURE__ */ jsx(
770
+ "div",
771
+ {
772
+ className: cn("h-full rounded-full", TONE_BAR[tone]),
773
+ style: { width: `${percent}%` }
774
+ }
775
+ )
776
+ }
777
+ ) : null,
778
+ caption ? /* @__PURE__ */ jsx("span", { className: "text-small-regular text-sys-text-subtler", children: caption }) : null
779
+ ] });
780
+ }
781
+ function DashboardLayout({
782
+ actions,
783
+ stats,
784
+ loading,
785
+ error,
786
+ onRetry,
787
+ empty,
788
+ children,
789
+ ...header
790
+ }) {
791
+ let body;
792
+ if (error) body = /* @__PURE__ */ jsx(ErrorState, { className: "py-[32px]", description: error, onRetry });
793
+ else if (empty)
794
+ body = /* @__PURE__ */ jsx(EmptyState, { className: "py-[32px]", children: empty === true ? "\uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." : empty });
795
+ else
796
+ body = /* @__PURE__ */ jsxs(Fragment, { children: [
797
+ stats?.length ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-[12px] xl:grid-cols-4", children: stats.map((stat, i) => /* @__PURE__ */ jsx(StatTile, { ...stat, loading }, i)) }) : null,
798
+ children
799
+ ] });
800
+ return /* @__PURE__ */ jsxs("div", { className: "w-full", children: [
801
+ /* @__PURE__ */ jsx(PageHeader, { ...header, actions }),
802
+ /* @__PURE__ */ jsx(
803
+ "section",
804
+ {
805
+ "aria-label": header.title || "\uBCF8\uBB38",
806
+ className: "flex flex-col gap-[32px] px-[24px] py-[24px]",
807
+ children: body
808
+ }
809
+ )
810
+ ] });
811
+ }
812
+ function DashboardSection({ title, action, columns, children }) {
813
+ const id = useId();
814
+ return /* @__PURE__ */ jsxs("section", { "aria-labelledby": id, className: "flex flex-col gap-[16px]", children: [
815
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-[12px]", children: [
816
+ /* @__PURE__ */ jsx("h2", { id, className: "text-h4 text-sys-text-main", children: title }),
817
+ action
818
+ ] }),
819
+ columns ? (
820
+ // 1px 간격 + 구분선 색 배경 = 칸 사이 가로·세로 구분선. 칸(DashboardPanel)은 자기 배경을 가진다
821
+ // 2열에서 칸 수가 홀수면 마지막 칸이 두 열을 차지해 빈 칸이 생기지 않게 한다
822
+ /* @__PURE__ */ jsx(
823
+ Card,
824
+ {
825
+ className: cn(
826
+ "grid grid-cols-1 gap-px overflow-hidden bg-sys-divider-subtler",
827
+ columns === 2 && "xl:grid-cols-2 xl:[&>:last-child:nth-child(odd)]:col-span-2"
828
+ ),
829
+ children
830
+ }
831
+ )
832
+ ) : children
833
+ ] });
834
+ }
835
+ function DashboardPanel({
836
+ title,
837
+ value,
838
+ unit,
839
+ stats,
840
+ loading,
841
+ empty,
842
+ children
843
+ }) {
844
+ let body = children;
845
+ if (loading) body = /* @__PURE__ */ jsx(LoadingState, { lines: 2 });
846
+ else if (empty)
847
+ body = /* @__PURE__ */ jsx(EmptyState, { className: "py-[32px]", children: empty === true ? "\uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." : empty });
848
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-[16px] bg-sys-background p-[24px]", children: [
849
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-end gap-[12px]", children: [
850
+ /* @__PURE__ */ jsx("h3", { className: "text-h6 text-sys-text-subtle", children: title }),
851
+ value !== void 0 && value !== null ? /* @__PURE__ */ jsxs("span", { className: "flex items-end gap-[2px]", children: [
852
+ /* @__PURE__ */ jsx("span", { className: "text-h3 text-sys-text-main", children: value }),
853
+ unit ? /* @__PURE__ */ jsx("span", { className: "text-base-regular text-sys-text-subtle", children: unit }) : null
854
+ ] }) : null,
855
+ stats?.map((stat, i) => /* @__PURE__ */ jsxs(
856
+ "span",
857
+ {
858
+ className: "flex items-end gap-[4px] border-l border-sys-divider-subtler pl-[12px]",
859
+ children: [
860
+ /* @__PURE__ */ jsx("span", { className: "text-base-regular text-sys-text-subtle", children: stat.label }),
861
+ /* @__PURE__ */ jsx("span", { className: "text-h5 text-sys-text-main", children: stat.value })
862
+ ]
863
+ },
864
+ i
865
+ ))
866
+ ] }),
867
+ body
868
+ ] });
869
+ }
870
+
871
+ export { DashboardLayout, DashboardPanel, DashboardSection, DetailPage, EmptyState, ErrorState, FormDialog, FormDrawer, FormPage, ListPage, LoadingState };
872
+ //# sourceMappingURL=index.js.map
873
+ //# sourceMappingURL=index.js.map