@reopt-ai/opt-ui 1.13.0 → 1.15.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/COMPONENT_CATALOG.md +644 -123
  3. package/README.md +35 -7
  4. package/dist/core/index.cjs +4 -4
  5. package/dist/core/index.d.cts +2 -2
  6. package/dist/core/index.d.ts +2 -2
  7. package/dist/core/index.js +2 -2
  8. package/dist/docs/02-components/01-core.md +261 -58
  9. package/dist/docs/02-components/02-visuals.md +1 -1
  10. package/dist/docs/02-components/03-shells.md +391 -65
  11. package/dist/docs/02-components/04-surfaces.md +1 -1
  12. package/dist/docs/02-components/index.md +4 -4
  13. package/dist/id-registry.cjs +32 -0
  14. package/dist/id-registry.js +32 -0
  15. package/dist/id-registry.json +68 -0
  16. package/dist/index.cjs +1196 -427
  17. package/dist/index.d.cts +208 -19
  18. package/dist/index.d.ts +208 -19
  19. package/dist/index.js +1050 -295
  20. package/dist/{key-pad-menu-DMmbQ1jF.js → key-pad-menu-BCOeYvsH.js} +59 -67
  21. package/dist/{key-pad-menu-BOVu97bj.d.cts → key-pad-menu-CaIsTHB2.d.cts} +22 -9
  22. package/dist/{key-pad-menu-0KWOnELe.cjs → key-pad-menu-Jaqxhx9P.cjs} +57 -77
  23. package/dist/{key-pad-menu-NlZ9zNF8.d.ts → key-pad-menu-odAEycIX.d.ts} +22 -9
  24. package/dist/meta.cjs +877 -26
  25. package/dist/meta.js +877 -26
  26. package/dist/{field-sidebar-CRdToHRO.js → replay-player-layout-BVUNsUS1.js} +904 -203
  27. package/dist/{field-sidebar-CxxSK4JO.cjs → replay-player-layout-Cmzptq89.cjs} +944 -201
  28. package/dist/{field-sidebar-D-rwuN_w.d.cts → replay-player-layout-DpqdfdQ6.d.ts} +246 -3
  29. package/dist/{field-sidebar-Dz7J4ett.d.ts → replay-player-layout-DuZW-gFF.d.cts} +246 -3
  30. package/dist/shells/index.cjs +58 -52
  31. package/dist/shells/index.d.cts +2 -2
  32. package/dist/shells/index.d.ts +2 -2
  33. package/dist/shells/index.js +2 -2
  34. package/dist/{text-truncate-Bd-0WkSP.js → text-truncate-B-8nJgnu.js} +200 -101
  35. package/dist/{text-truncate-eMzvU_7k.d.cts → text-truncate-DOxUijpB.d.cts} +3 -30
  36. package/dist/{text-truncate-eMzvU_7k.d.ts → text-truncate-DOxUijpB.d.ts} +3 -30
  37. package/dist/{text-truncate-BeYW6XZu.cjs → text-truncate-gQhpMy-Z.cjs} +214 -97
  38. package/dist/theme/server.d.cts +2 -2
  39. package/dist/theme/server.d.ts +2 -2
  40. package/package.json +2 -2
package/dist/meta.js CHANGED
@@ -531,7 +531,7 @@ const coreMetas = [
531
531
  name: "onSave",
532
532
  type: "(next: string) => boolean | void | Promise<boolean | void>",
533
533
  required: true,
534
- description: "저장. false 반환하면 거절하고 편집을 유지"
534
+ description: "저장. false 반환 또는 Promise 실패 시 입력을 유지하고 오류를 알림"
535
535
  },
536
536
  {
537
537
  name: "as",
@@ -575,7 +575,7 @@ const coreMetas = [
575
575
  {
576
576
  name: "labels",
577
577
  type: "InlineEditLabels",
578
- description: "지역화 문자열"
578
+ description: "입력 이름(input), 편집·저장·취소·오류 안내 문자열"
579
579
  },
580
580
  {
581
581
  name: "className",
@@ -1221,7 +1221,7 @@ const coreMetas = [
1221
1221
  {
1222
1222
  name: "setOpen",
1223
1223
  deprecated: {
1224
- since: "1.12.5",
1224
+ since: "1.13.0",
1225
1225
  replacement: "onOpenChange"
1226
1226
  },
1227
1227
  type: "(open: boolean) => void",
@@ -1258,6 +1258,26 @@ const coreMetas = [
1258
1258
  testDescribe: "Disclosure"
1259
1259
  },
1260
1260
  {
1261
+ usage: {
1262
+ anatomy: ["TabsRoot 안에 TabList와 TabPanel을 두고 Tab.id와 TabPanel.tabId를 맞춥니다."],
1263
+ bestPractices: ["TabList에 접근 가능한 이름을 지정하고, 제어 모드는 selectedId와 onSelectedIdChange를 함께 연결합니다."],
1264
+ avoid: ["다른 URL로 이동하는 주 내비게이션을 탭 패널로 구현하지 않습니다."]
1265
+ },
1266
+ examples: [{
1267
+ validation: "typechecked",
1268
+ title: "이름 있는 두 패널",
1269
+ code: `import { TabsRoot, TabList, Tab, TabPanel } from "@reopt-ai/opt-ui";
1270
+
1271
+ export function Example() {
1272
+ return <TabsRoot defaultSelectedId="overview">
1273
+ <TabList aria-label="프로젝트 정보">
1274
+ <Tab id="overview">개요</Tab><Tab id="activity">활동</Tab>
1275
+ </TabList>
1276
+ <TabPanel tabId="overview">프로젝트 요약</TabPanel>
1277
+ <TabPanel tabId="activity">최근 활동</TabPanel>
1278
+ </TabsRoot>;
1279
+ }`
1280
+ }],
1261
1281
  name: "Tabs",
1262
1282
  category: "core",
1263
1283
  description: "탭 패널 시스템. 4-part anatomy: Root, TabList, Tab, TabPanel.",
@@ -1302,7 +1322,7 @@ const coreMetas = [
1302
1322
  {
1303
1323
  name: "setSelectedId",
1304
1324
  deprecated: {
1305
- since: "1.12.5",
1325
+ since: "1.13.0",
1306
1326
  replacement: "onSelectedIdChange"
1307
1327
  },
1308
1328
  type: "(id: string | null) => void",
@@ -1385,6 +1405,11 @@ const coreMetas = [
1385
1405
  testDescribe: "Tabs"
1386
1406
  },
1387
1407
  {
1408
+ usage: {
1409
+ anatomy: ["SelectRoot 안에 Label, Trigger, Popover, Item을 조합합니다."],
1410
+ bestPractices: ["옵션 value는 표시 문구와 분리된 안정적인 키로 유지합니다. multiple 모드에서는 배열 값을 사용합니다."],
1411
+ avoid: ["선택 목록에 명령 실행 버튼을 섞지 않습니다. 명령은 DropdownMenu로 분리합니다."]
1412
+ },
1388
1413
  name: "Select",
1389
1414
  category: "core",
1390
1415
  description: "드롭다운 선택 컴포넌트. 5-part anatomy: Root, Label, Trigger, Popover, Item.",
@@ -1474,7 +1499,7 @@ const coreMetas = [
1474
1499
  {
1475
1500
  name: "gutter",
1476
1501
  deprecated: {
1477
- since: "1.12.5",
1502
+ since: "1.13.0",
1478
1503
  replacement: "sideOffset"
1479
1504
  },
1480
1505
  type: "number",
@@ -1525,6 +1550,11 @@ const coreMetas = [
1525
1550
  }]
1526
1551
  },
1527
1552
  {
1553
+ usage: {
1554
+ anatomy: ["DialogRoot에서 Disclosure와 Panel을 연결하고 Panel 안에 Heading과 Description을 둡니다."],
1555
+ bestPractices: ["열림을 제어할 때 open과 onOpenChange를 함께 제공합니다. 닫기 동작은 DialogDismiss로 노출합니다."],
1556
+ avoid: ["제목 없는 모달이나 종료 방법 없는 모달을 만들지 않습니다. 파괴적 확인은 AlertDialogPanel의 계약을 사용합니다."]
1557
+ },
1528
1558
  name: "Dialog",
1529
1559
  category: "core",
1530
1560
  description: "모달 다이얼로그. 9-part anatomy: Root, Disclosure, Panel, Heading, Description, Dismiss, Header, Body, Footer.",
@@ -1556,7 +1586,7 @@ const coreMetas = [
1556
1586
  }, {
1557
1587
  name: "setOpen",
1558
1588
  deprecated: {
1559
- since: "1.12.5",
1589
+ since: "1.13.0",
1560
1590
  replacement: "onOpenChange"
1561
1591
  },
1562
1592
  type: "(open: boolean) => void",
@@ -1683,7 +1713,7 @@ const coreMetas = [
1683
1713
  ComboboxRoot: [{
1684
1714
  name: "setValue",
1685
1715
  deprecated: {
1686
- since: "1.12.5",
1716
+ since: "1.13.0",
1687
1717
  replacement: "onValueChange"
1688
1718
  },
1689
1719
  type: "(value: string) => void",
@@ -1728,7 +1758,7 @@ const coreMetas = [
1728
1758
  {
1729
1759
  name: "gutter",
1730
1760
  deprecated: {
1731
- since: "1.12.5",
1761
+ since: "1.13.0",
1732
1762
  replacement: "sideOffset"
1733
1763
  },
1734
1764
  type: "number",
@@ -1863,7 +1893,7 @@ const coreMetas = [
1863
1893
  {
1864
1894
  name: "gutter",
1865
1895
  deprecated: {
1866
- since: "1.12.5",
1896
+ since: "1.13.0",
1867
1897
  replacement: "sideOffset"
1868
1898
  },
1869
1899
  type: "number",
@@ -2181,6 +2211,21 @@ const coreMetas = [
2181
2211
  }
2182
2212
  ],
2183
2213
  FormSelect: [
2214
+ {
2215
+ name: "id",
2216
+ type: "string",
2217
+ description: "트리거 ID. 외부 FormLabel의 htmlFor와 연결"
2218
+ },
2219
+ {
2220
+ name: "aria-label",
2221
+ type: "string",
2222
+ description: "입력의 접근 가능한 이름"
2223
+ },
2224
+ {
2225
+ name: "aria-describedby",
2226
+ type: "string",
2227
+ description: "설명·오류 요소의 ID 목록"
2228
+ },
2184
2229
  {
2185
2230
  name: "name",
2186
2231
  type: "string",
@@ -2220,7 +2265,7 @@ const coreMetas = [
2220
2265
  {
2221
2266
  name: "gutter",
2222
2267
  deprecated: {
2223
- since: "1.12.5",
2268
+ since: "1.13.0",
2224
2269
  replacement: "sideOffset"
2225
2270
  },
2226
2271
  type: "number",
@@ -2545,6 +2590,10 @@ const store = useFormStore({
2545
2590
  ]
2546
2591
  },
2547
2592
  {
2593
+ usage: {
2594
+ bestPractices: ["주요 실행에는 primary, 파괴적 실행에는 danger를 사용하고 동작을 설명하는 텍스트를 유지합니다.", "폼 제출은 type=submit을 명시합니다. loading은 버튼을 비활성화하고 aria-busy를 설정합니다."],
2595
+ avoid: ["페이지 이동을 버튼 클릭으로만 구현하지 않습니다. 아이콘만 표시할 때 접근 가능한 이름을 생략하지 않습니다."]
2596
+ },
2548
2597
  name: "Button",
2549
2598
  category: "core",
2550
2599
  description: "접근성 버튼 컴포넌트. 4가지 변형(primary, secondary, ghost, danger), 3가지 크기, 로딩/아이콘과 aria-busy 상태를 지원합니다.",
@@ -2599,6 +2648,16 @@ const store = useFormStore({
2599
2648
  }]
2600
2649
  },
2601
2650
  {
2651
+ usage: {
2652
+ bestPractices: ["variant 색상과 함께 상태를 설명하는 텍스트를 표시합니다. progress를 지정하면 dot 대신 진행률 링이 표시됩니다."],
2653
+ avoid: ["Badge를 클릭 가능한 버튼처럼 사용하거나 색상만으로 성공·실패를 구분하지 않습니다."]
2654
+ },
2655
+ examples: [{
2656
+ validation: "typechecked",
2657
+ title: "텍스트가 있는 상태",
2658
+ code: `import { Badge } from "@reopt-ai/opt-ui";
2659
+ export function Example() { return <Badge variant="success" dot>동기화 완료</Badge>; }`
2660
+ }],
2602
2661
  name: "Badge",
2603
2662
  category: "core",
2604
2663
  description: "인라인 상태 뱃지. 5가지 색상 변형, 상태 점(dot) 옵션 지원.",
@@ -2638,6 +2697,16 @@ const store = useFormStore({
2638
2697
  testDescribe: "Badge"
2639
2698
  },
2640
2699
  {
2700
+ usage: {
2701
+ bestPractices: ["단독 로딩 표시는 aria-label로 작업을 설명합니다. 주변 status가 이미 상태를 읽으면 aria-hidden으로 중복 낭독을 막습니다."],
2702
+ avoid: ["Spinner 자체가 요청 진행 상태나 오류 처리를 관리한다고 가정하지 않습니다."]
2703
+ },
2704
+ examples: [{
2705
+ validation: "typechecked",
2706
+ title: "이름 있는 로딩 표시",
2707
+ code: `import { Spinner } from "@reopt-ai/opt-ui";
2708
+ export function Example() { return <Spinner size="sm" aria-label="검색 결과 불러오는 중" />; }`
2709
+ }],
2641
2710
  name: "Spinner",
2642
2711
  category: "core",
2643
2712
  description: "로딩 상태 표시 스피너. 3가지 크기와 장식용 aria-hidden 모드를 지원합니다.",
@@ -2653,6 +2722,16 @@ const store = useFormStore({
2653
2722
  testDescribe: "Spinner"
2654
2723
  },
2655
2724
  {
2725
+ usage: {
2726
+ bestPractices: ["title에는 문제 요약을, 본문에는 사용자가 할 수 있는 다음 행동을 적습니다. 닫을 수 있다면 onDismiss로 상태를 갱신합니다."],
2727
+ avoid: ["일반 설명 문구를 모두 긴급 알림으로 노출하거나 오류 원인을 색상으로만 전달하지 않습니다."]
2728
+ },
2729
+ examples: [{
2730
+ validation: "typechecked",
2731
+ title: "재시도 가능한 오류 안내",
2732
+ code: `import { Alert } from "@reopt-ai/opt-ui";
2733
+ export function Example() { return <Alert variant="error" title="저장하지 못했습니다">연결 상태를 확인한 뒤 다시 저장하세요.</Alert>; }`
2734
+ }],
2656
2735
  name: "Alert",
2657
2736
  category: "core",
2658
2737
  description: "알림 메시지 컴포넌트. 4가지 변형(info, success, warning, error), 제목/본문/닫기 버튼 지원.",
@@ -2691,6 +2770,10 @@ const store = useFormStore({
2691
2770
  testDescribe: "Alert"
2692
2771
  },
2693
2772
  {
2773
+ usage: {
2774
+ bestPractices: ["label로 입력 목적을, hint로 형식을 안내합니다. error는 오류 문자열이며 표시될 때 hint보다 우선합니다.", "제어 값은 문자열로 유지하고 onChange의 event.target.value를 저장합니다. FormControl 조합에서는 레이블을 중복하지 않습니다."],
2775
+ avoid: ["placeholder를 유일한 레이블로 사용하거나 제어/비제어 상태를 렌더 중 전환하지 않습니다."]
2776
+ },
2694
2777
  name: "Input",
2695
2778
  category: "core",
2696
2779
  description: "독립적인 텍스트 입력 컴포넌트. 레이블, 에러, 힌트, 아이콘 지원.",
@@ -2757,6 +2840,21 @@ const store = useFormStore({ defaultValues: { name: "" } });
2757
2840
  }]
2758
2841
  },
2759
2842
  {
2843
+ usage: {
2844
+ bestPractices: ["즉시 적용되는 켜짐/꺼짐 설정에 사용합니다. onChange는 DOM 이벤트가 아니라 boolean을 전달합니다."],
2845
+ avoid: ["제출 시 한꺼번에 적용되는 동의 목록을 즉시 적용 스위치로 표현하지 않습니다. label을 상태마다 바꾸지 않습니다."]
2846
+ },
2847
+ examples: [{
2848
+ validation: "typechecked",
2849
+ title: "제어되는 알림 설정",
2850
+ code: `"use client";
2851
+ import { useState } from "react";
2852
+ import { Switch } from "@reopt-ai/opt-ui";
2853
+ export function Example() {
2854
+ const [enabled, setEnabled] = useState(false);
2855
+ return <Switch label="이메일 알림" hint="새 댓글을 이메일로 받습니다" checked={enabled} onChange={setEnabled} />;
2856
+ }`
2857
+ }],
2760
2858
  name: "Switch",
2761
2859
  category: "core",
2762
2860
  description: "토글 스위치 컴포넌트. 3가지 크기와 레이블/설명의 명시적 접근성 연결을 지원합니다.",
@@ -2813,6 +2911,16 @@ const store = useFormStore({ defaultValues: { name: "" } });
2813
2911
  testDescribe: "Switch"
2814
2912
  },
2815
2913
  {
2914
+ usage: {
2915
+ bestPractices: ["로드될 콘텐츠와 비슷한 크기·행 수를 사용해 레이아웃 이동을 줄입니다. 컨테이너에서 로딩 상태를 설명합니다."],
2916
+ avoid: ["빈 결과나 요청 오류를 끝나지 않는 스켈레톤으로 대신하지 않습니다."]
2917
+ },
2918
+ examples: [{
2919
+ validation: "typechecked",
2920
+ title: "목록 로딩 자리 확보",
2921
+ code: `import { SkeletonTable } from "@reopt-ai/opt-ui";
2922
+ export function Example() { return <section aria-label="목록 불러오는 중" aria-busy="true"><SkeletonTable rows={3} columns={4} /></section>; }`
2923
+ }],
2816
2924
  name: "Skeleton",
2817
2925
  category: "core",
2818
2926
  description: "로딩 플레이스홀더. text/circular/rectangular 변형. 프리셋 컴포넌트 포함.",
@@ -2883,6 +2991,16 @@ const store = useFormStore({ defaultValues: { name: "" } });
2883
2991
  testDescribe: "Skeleton"
2884
2992
  },
2885
2993
  {
2994
+ usage: {
2995
+ bestPractices: ["여러 줄의 자유 입력에 사용하고 label과 hint로 목적·분량을 안내합니다. rows로 초기 높이를 정합니다."],
2996
+ avoid: ["짧은 단일 값 입력에 과도한 편집 영역을 쓰거나 사용자의 세로 크기 조절을 불필요하게 막지 않습니다."]
2997
+ },
2998
+ examples: [{
2999
+ validation: "typechecked",
3000
+ title: "설명 입력",
3001
+ code: `import { Textarea } from "@reopt-ai/opt-ui";
3002
+ export function Example() { return <Textarea label="변경 이유" name="reason" hint="검토자가 알아야 할 내용을 적어주세요" rows={4} resize="vertical" />; }`
3003
+ }],
2886
3004
  name: "Textarea",
2887
3005
  category: "core",
2888
3006
  description: "멀티라인 텍스트 입력. Input과 동일한 API (label, error, hint).",
@@ -2927,6 +3045,16 @@ const store = useFormStore({ defaultValues: { name: "" } });
2927
3045
  testDescribe: "Textarea"
2928
3046
  },
2929
3047
  {
3048
+ usage: {
3049
+ bestPractices: ["name으로 이미지 실패 시 이니셜을 제공하고, alt는 이미지의 실제 의미를 설명합니다. AvatarGroup의 max로 표시 개수를 제한합니다."],
3050
+ avoid: ["이미지나 온라인 상태 점만으로 사용자 신원·상태를 전달하지 않습니다."]
3051
+ },
3052
+ examples: [{
3053
+ validation: "typechecked",
3054
+ title: "이미지 없이 사용자 표시",
3055
+ code: `import { Avatar, AvatarGroup } from "@reopt-ai/opt-ui";
3056
+ export function Example() { return <AvatarGroup max={3}><Avatar name="김민수" /><Avatar name="이지수" /></AvatarGroup>; }`
3057
+ }],
2930
3058
  name: "Avatar",
2931
3059
  category: "core",
2932
3060
  description: "사용자 아바타. 이미지, 이니셜 자동 생성, 스크린 리더에 노출되는 상태 인디케이터, 그룹을 지원합니다.",
@@ -2990,6 +3118,16 @@ const store = useFormStore({ defaultValues: { name: "" } });
2990
3118
  testDescribe: "Avatar"
2991
3119
  },
2992
3120
  {
3121
+ usage: {
3122
+ bestPractices: ["완료량을 알면 value와 max를 같은 단위로 제공합니다. 진행률에 작업을 설명하는 접근 가능한 이름을 붙입니다."],
3123
+ avoid: ["측정할 수 없는 진행률을 임의 숫자로 표시하지 않습니다. 완료·실패 후에도 진행 중 표시를 남기지 않습니다."]
3124
+ },
3125
+ examples: [{
3126
+ validation: "typechecked",
3127
+ title: "업로드 진행률",
3128
+ code: `import { Progress } from "@reopt-ai/opt-ui";
3129
+ export function Example() { return <Progress value={35} max={100} aria-label="파일 업로드" />; }`
3130
+ }],
2993
3131
  name: "Progress",
2994
3132
  category: "core",
2995
3133
  description: "진행률 표시. Linear/Circular, determinate/indeterminate 변형과 안전하게 보정된 ARIA 값을 지원합니다.",
@@ -3442,6 +3580,16 @@ const store = useFormStore({ defaultValues: { name: "" } });
3442
3580
  testDescribe: "Card"
3443
3581
  },
3444
3582
  {
3583
+ usage: {
3584
+ bestPractices: ["CheckboxGroup에는 각 Checkbox의 고유 value를 제공합니다. 단일 onChange는 boolean, 그룹 onChange는 문자열 배열을 전달합니다.", "일부 항목만 선택한 전체 선택 컨트롤에는 indeterminate를 사용합니다."],
3585
+ avoid: ["서로 배타적인 선택지를 체크박스로 구성하거나 label 없이 체크 상태만 노출하지 않습니다."]
3586
+ },
3587
+ examples: [{
3588
+ validation: "typechecked",
3589
+ title: "복수 선택 그룹",
3590
+ code: `import { Checkbox, CheckboxGroup } from "@reopt-ai/opt-ui";
3591
+ export function Example() { return <CheckboxGroup label="알림 채널" defaultValue={["email"]}><Checkbox value="email" label="이메일" /><Checkbox value="push" label="푸시" /></CheckboxGroup>; }`
3592
+ }],
3445
3593
  name: "Checkbox",
3446
3594
  category: "core",
3447
3595
  description: "독립적인 체크박스 컴포넌트. 레이블, 설명, 그룹과 DOM property까지 동기화되는 indeterminate 상태를 지원합니다.",
@@ -3951,6 +4099,11 @@ const store = useFormStore({ defaultValues: { name: "" } });
3951
4099
  type: "number",
3952
4100
  description: "매치 카운트 표시 (toLocaleString 포맷)"
3953
4101
  },
4102
+ {
4103
+ name: "labels",
4104
+ type: "ConditionBuilderLabels",
4105
+ description: "미리보기 문구 오버라이드 (previewSuffix, formatPreview). 기본은 영어 \"matches\""
4106
+ },
3954
4107
  {
3955
4108
  name: "addNestedGroupLabel",
3956
4109
  type: "string",
@@ -3961,6 +4114,17 @@ const store = useFormStore({ defaultValues: { name: "" } });
3961
4114
  testDescribe: "ConditionBuilder"
3962
4115
  },
3963
4116
  {
4117
+ usage: {
4118
+ anatomy: ["PopoverRoot 안에 Trigger와 Content를 두고 Content 안에 Close를 제공할 수 있습니다."],
4119
+ bestPractices: ["트리거와 관련된 짧은 보조 작업을 배치합니다. 위치는 PopoverRoot의 placement로 지정합니다."],
4120
+ avoid: ["복잡한 필수 입력 작업을 작은 팝오버에 가두거나 트리거의 접근 가능한 이름을 생략하지 않습니다."]
4121
+ },
4122
+ examples: [{
4123
+ validation: "typechecked",
4124
+ title: "보조 설명 열고 닫기",
4125
+ code: `import { PopoverRoot, PopoverTrigger, PopoverContent, PopoverClose } from "@reopt-ai/opt-ui";
4126
+ export function Example() { return <PopoverRoot><PopoverTrigger>공유 안내</PopoverTrigger><PopoverContent><p>초대받은 사용자만 문서를 볼 수 있습니다.</p><PopoverClose>닫기</PopoverClose></PopoverContent></PopoverRoot>; }`
4127
+ }],
3964
4128
  name: "Popover",
3965
4129
  category: "core",
3966
4130
  description: "Popover 프리미티브 래퍼. 트리거 요소에 연결된 플로팅 콘텐츠를 표시합니다.",
@@ -3979,15 +4143,6 @@ const store = useFormStore({ defaultValues: { name: "" } });
3979
4143
  action: "트리거 활성화"
3980
4144
  }],
3981
4145
  props: { PopoverContent: [{
3982
- name: "gutter",
3983
- deprecated: {
3984
- since: "1.12.5",
3985
- replacement: "sideOffset"
3986
- },
3987
- type: "number",
3988
- default: "8",
3989
- description: "트리거와의 간격 (px)"
3990
- }, {
3991
4146
  name: "className",
3992
4147
  type: "string",
3993
4148
  description: "커스텀 CSS 클래스"
@@ -4025,7 +4180,7 @@ const store = useFormStore({ defaultValues: { name: "" } });
4025
4180
  DropdownContent: [{
4026
4181
  name: "gutter",
4027
4182
  deprecated: {
4028
- since: "1.12.5",
4183
+ since: "1.13.0",
4029
4184
  replacement: "sideOffset"
4030
4185
  },
4031
4186
  type: "number",
@@ -4162,6 +4317,11 @@ const store = useFormStore({ defaultValues: { name: "" } });
4162
4317
  type: "(value: number) => void",
4163
4318
  description: "값 변경 핸들러"
4164
4319
  },
4320
+ {
4321
+ name: "onValueCommit",
4322
+ type: "(value: number) => void",
4323
+ description: "포인터·키보드 편집 완료 시 한 번 호출. 취소와 외부 값 갱신은 제외"
4324
+ },
4165
4325
  {
4166
4326
  name: "min",
4167
4327
  type: "number",
@@ -4776,6 +4936,16 @@ const MyDashboard = createBlock<MyDashboardProps>(
4776
4936
  testDescribe: "VirtualList"
4777
4937
  },
4778
4938
  {
4939
+ usage: {
4940
+ bestPractices: ["기존 콘텐츠를 유지한 채 갱신할 때 loading을 연결하고 label로 진행 중인 작업을 설명합니다."],
4941
+ avoid: ["오버레이가 키보드 접근까지 차단한다고 가정하지 않습니다. 중복 실행 방지가 필요하면 내부 컨트롤도 비활성화합니다."]
4942
+ },
4943
+ examples: [{
4944
+ validation: "typechecked",
4945
+ title: "기존 결과를 유지하는 갱신 표시",
4946
+ code: `import { LoadingOverlay } from "@reopt-ai/opt-ui";
4947
+ export function Example() { return <LoadingOverlay loading label="목록 새로고침 중"><p>이전 조회 결과</p></LoadingOverlay>; }`
4948
+ }],
4779
4949
  name: "LoadingOverlay",
4780
4950
  category: "core",
4781
4951
  description: "로딩 오버레이. 중복 status 없이 하나의 aria-busy 라이브 영역으로 콘텐츠 위 로딩 상태를 표시합니다.",
@@ -5514,7 +5684,7 @@ const shellMetas = [
5514
5684
  description: "폼 필드 정의 배열"
5515
5685
  }, {
5516
5686
  name: "onSubmit",
5517
- type: "(values: Record<string, unknown>) => void",
5687
+ type: "(values: Record<string, unknown>) => void | Promise<void>",
5518
5688
  description: "제출 핸들러"
5519
5689
  }] },
5520
5690
  dependencies: [
@@ -5821,6 +5991,10 @@ const fields = [
5821
5991
  testDescribe: "DomainTable"
5822
5992
  },
5823
5993
  {
5994
+ usage: {
5995
+ bestPractices: ["keyExtractor에는 정렬·필터 후에도 유지되는 행 ID를 사용합니다. 보이는 title이 없으면 ariaLabel로 grid 이름을 지정합니다.", "컬럼 accessor는 행 타입에 맞춰 선언하고, 서버 조회·편집·대규모 가상화가 필요하면 opt-datagrid의 계약을 검토합니다."],
5996
+ avoid: ["배열 인덱스를 행 식별자로 사용하거나 비어 있는 헤더로 액션 컬럼을 익명화하지 않습니다. headerHidden을 사용합니다."]
5997
+ },
5824
5998
  name: "DataTable",
5825
5999
  category: "shell",
5826
6000
  description: "제네릭 데이터 테이블. 제목은 opt-in이고 ariaLabel로 grid를 이름 붙이며, 동적 컬럼·안전 정렬·키보드 리사이즈·Composite 탐색을 지원합니다.",
@@ -5967,6 +6141,17 @@ const data = [
5967
6141
  }]
5968
6142
  },
5969
6143
  {
6144
+ usage: {
6145
+ bestPractices: ["앱 경계에 ToastProvider를 한 번 마운트한 뒤 이벤트 핸들러에서 toast를 호출합니다. message는 결과를, action은 다음 행동을 설명합니다."],
6146
+ avoid: ["렌더 함수에서 toast를 호출하거나 반드시 읽어야 하는 오류·동의를 자동으로 사라지는 토스트에만 표시하지 않습니다."]
6147
+ },
6148
+ examples: [{
6149
+ validation: "typechecked",
6150
+ title: "사용자 동작의 결과 알림",
6151
+ code: `"use client";
6152
+ import { Button, ToastProvider, toast } from "@reopt-ai/opt-ui";
6153
+ export function Example() { return <ToastProvider><Button onClick={() => toast.success("알림 동작을 확인했습니다")}>알림 테스트</Button></ToastProvider>; }`
6154
+ }],
5970
6155
  name: "NotificationToast",
5971
6156
  category: "shell",
5972
6157
  description: "토스트 알림 시스템. 포인터/키보드 상호작용 중 자동 닫힘을 일시 정지하고, 중요도별 live region으로 전역 알림을 관리합니다.",
@@ -6161,6 +6346,10 @@ const data = [
6161
6346
  testDescribe: "ActivityFeed"
6162
6347
  },
6163
6348
  {
6349
+ usage: {
6350
+ bestPractices: ["filters의 id를 안정적으로 유지하고 onFilterChange에서 해당 필터의 value를 갱신합니다. onClearAll에서는 적용된 값과 조회 조건을 함께 초기화합니다."],
6351
+ avoid: ["필터 UI 변경만으로 서버 데이터까지 자동 조회된다고 가정하지 않습니다. 비동기 조회와 오류 처리는 호출자가 연결합니다."]
6352
+ },
6164
6353
  name: "FilterBar",
6165
6354
  category: "shell",
6166
6355
  description: "필터링 바 컴포넌트. 검색, 선택, 다중 선택 필터 지원.",
@@ -6366,7 +6555,7 @@ const filters = [
6366
6555
  },
6367
6556
  {
6368
6557
  name: "onSubmit",
6369
- type: "(values: { email: string; password: string; confirmPassword?: string; rememberMe?: boolean; mode: \"login\" | \"signup\" }) => void",
6558
+ type: "(values: { email: string; password: string; confirmPassword?: string; rememberMe?: boolean; mode: \"login\" | \"signup\" }) => void | Promise<void>",
6370
6559
  description: "제출 핸들러"
6371
6560
  },
6372
6561
  {
@@ -6409,7 +6598,7 @@ const filters = [
6409
6598
  },
6410
6599
  {
6411
6600
  name: "onSubmit",
6412
- type: "(values: Record<string, unknown>) => void",
6601
+ type: "(values: Record<string, unknown>) => void | Promise<void>",
6413
6602
  description: "제출 핸들러"
6414
6603
  },
6415
6604
  {
@@ -6477,7 +6666,7 @@ const filters = [
6477
6666
  },
6478
6667
  {
6479
6668
  name: "onSubmit",
6480
- type: "(values: string[]) => void",
6669
+ type: "(values: string[]) => void | Promise<void>",
6481
6670
  description: "제출 핸들러"
6482
6671
  },
6483
6672
  {
@@ -6531,7 +6720,7 @@ const filters = [
6531
6720
  },
6532
6721
  {
6533
6722
  name: "onSubmit",
6534
- type: "(values: { title: string; description: string; file: File | null }) => void",
6723
+ type: "(values: { title: string; description: string; file: File | null }) => void | Promise<void>",
6535
6724
  description: "제출 핸들러"
6536
6725
  },
6537
6726
  {
@@ -7375,6 +7564,221 @@ const filters = [
7375
7564
  }],
7376
7565
  testDescribe: "SegmentBuilder"
7377
7566
  },
7567
+ {
7568
+ name: "CriteriaBuilder",
7569
+ category: "shell",
7570
+ description: "도메인 무관 2단계 조건 트리 편집기. 그룹 AND/OR, 순서·복제·부정·오류를 셸이 맡고 조건 문장은 소비자가 Expression 칩으로 렌더링합니다.",
7571
+ slug: "criteria-builder",
7572
+ exports: [
7573
+ "CriteriaBuilder",
7574
+ "criteriaBuilderMove",
7575
+ "criteriaBuilderDuplicate",
7576
+ "criteriaBuilderRemove",
7577
+ "criteriaBuilderInsert",
7578
+ "criteriaBuilderRemoveGroup",
7579
+ "criteriaBuilderDuplicateGroup",
7580
+ "criteriaGroupLetter"
7581
+ ],
7582
+ props: { CriteriaBuilder: [
7583
+ {
7584
+ name: "value",
7585
+ type: "CriteriaBuilderValue<TCriterion>",
7586
+ required: true,
7587
+ description: "조건 트리. { logic, groups: [{ id, logic, criteria }] }"
7588
+ },
7589
+ {
7590
+ name: "onChange",
7591
+ type: "(next: CriteriaBuilderValue<TCriterion>) => void",
7592
+ required: true,
7593
+ description: "트리 변경 핸들러"
7594
+ },
7595
+ {
7596
+ name: "kinds",
7597
+ type: "CriteriaBuilderKindOption[]",
7598
+ required: true,
7599
+ description: "조건 추가 메뉴 항목. group으로 섹션을 나누고 negatedLabel이 있으면 부정 항목을 따로 냅니다"
7600
+ },
7601
+ {
7602
+ name: "createCriterion",
7603
+ type: "(kind: string, negate: boolean) => TCriterion",
7604
+ required: true,
7605
+ description: "메뉴 선택으로 만들 초기 조건. 고유 id를 돌려줘야 합니다"
7606
+ },
7607
+ {
7608
+ name: "renderCriterion",
7609
+ type: "(criterion: TCriterion, api: CriteriaRenderApi<TCriterion>) => React.ReactNode",
7610
+ required: true,
7611
+ description: "조건 한 줄의 편집 가능한 문장 렌더러"
7612
+ },
7613
+ {
7614
+ name: "cloneCriterion",
7615
+ type: "(criterion: TCriterion) => TCriterion",
7616
+ description: "복제 시 만들 사본. 기본은 새 id를 붙인 얕은 복사"
7617
+ },
7618
+ {
7619
+ name: "createGroupId",
7620
+ type: "() => string",
7621
+ description: "셸이 만드는 그룹의 id. 기본 group-N"
7622
+ },
7623
+ {
7624
+ name: "errors",
7625
+ type: "Record<string, string>",
7626
+ description: "조건 id 또는 그룹 id별 오류 메시지"
7627
+ },
7628
+ {
7629
+ name: "maxGroups",
7630
+ type: "number",
7631
+ default: "10",
7632
+ description: "그룹 최대 수"
7633
+ },
7634
+ {
7635
+ name: "maxCriteriaPerGroup",
7636
+ type: "number",
7637
+ default: "20",
7638
+ description: "그룹당 조건 최대 수"
7639
+ },
7640
+ {
7641
+ name: "allowNegation",
7642
+ type: "boolean",
7643
+ default: "true",
7644
+ description: "조건별 부정 토글 표시"
7645
+ },
7646
+ {
7647
+ name: "disabled",
7648
+ type: "boolean",
7649
+ default: "false",
7650
+ description: "전체 비활성화"
7651
+ },
7652
+ {
7653
+ name: "aside",
7654
+ type: "React.ReactNode",
7655
+ description: "넓은 화면에서 트리 옆, 좁은 화면에서 아래에 놓이는 미리보기 슬롯"
7656
+ },
7657
+ {
7658
+ name: "labels",
7659
+ type: "CriteriaBuilderLabels",
7660
+ description: "i18n용 라벨 오버라이드 (기본 한국어)"
7661
+ },
7662
+ {
7663
+ name: "className",
7664
+ type: "string",
7665
+ description: "루트 CSS 클래스"
7666
+ },
7667
+ {
7668
+ name: "aria-label",
7669
+ type: "string",
7670
+ description: "빌더 접근성 이름"
7671
+ }
7672
+ ] },
7673
+ ariaRole: "group",
7674
+ keyboardShortcuts: [{
7675
+ keys: "A",
7676
+ action: "포커스된 그룹에 조건 추가 메뉴 열기"
7677
+ }, {
7678
+ keys: "G",
7679
+ action: "그룹 추가"
7680
+ }],
7681
+ dependencies: [
7682
+ {
7683
+ slug: "segmented-control",
7684
+ category: "core"
7685
+ },
7686
+ {
7687
+ slug: "dropdown-menu",
7688
+ category: "core"
7689
+ },
7690
+ {
7691
+ slug: "button",
7692
+ category: "core"
7693
+ },
7694
+ {
7695
+ slug: "callout",
7696
+ category: "core"
7697
+ },
7698
+ {
7699
+ slug: "empty-state",
7700
+ category: "core"
7701
+ },
7702
+ {
7703
+ slug: "kbd",
7704
+ category: "core"
7705
+ }
7706
+ ],
7707
+ testDescribe: "CriteriaBuilder",
7708
+ usage: {
7709
+ bestPractices: [
7710
+ "조건 데이터 타입과 각 조건의 문장은 소비자가 소유합니다. renderCriterion에서 Expression 칩을 조합하고, 값 편집은 Popover로 여세요.",
7711
+ "createCriterion은 kind와 negate만 받아 초기 조건을 만듭니다. id는 여기서 고유하게 발급하세요.",
7712
+ "errors는 조건 id·그룹 id로 키를 잡습니다. 검증은 소비자가 하고 셸은 표시와 aria 연결만 맡습니다.",
7713
+ "aside에 라이브 카운트(StatCard)를 두면 넓은 화면에서 트리 옆에, 좁은 화면에서 아래에 놓입니다."
7714
+ ],
7715
+ anatomy: ["루트 AND/OR 세그먼트 컨트롤 → 그룹 패널(레터마크 + 그룹 AND/OR + 복제/제거) → 조건 행(부정 토글 + 문장 + 이동/복제/제거) → 조건 추가 메뉴 → 그룹 추가 버튼"],
7716
+ avoid: ["kinds에 없는 kind를 다른 kind의 필드로 대체 렌더링하지 마세요. 셸은 이를 경고 행으로 보여 주고 바꾸기/제거만 허용합니다.", "SegmentBuilder처럼 고정 select 조합이 충분하다면 이 셸은 과합니다. 문장형 편집과 그룹 논리가 필요할 때 쓰세요."]
7717
+ },
7718
+ examples: [{
7719
+ validation: "typechecked",
7720
+ title: "이벤트 조건 세그먼트",
7721
+ code: `"use client";
7722
+ import { useState } from "react";
7723
+ import {
7724
+ CriteriaBuilder,
7725
+ Expression,
7726
+ ExpressionGroup,
7727
+ type CriteriaBuilderValue,
7728
+ } from "@reopt-ai/opt-ui";
7729
+
7730
+ interface Criterion {
7731
+ id: string;
7732
+ kind: string;
7733
+ negate?: boolean;
7734
+ event: string;
7735
+ }
7736
+
7737
+ let seq = 0;
7738
+
7739
+ export function Example() {
7740
+ const [value, setValue] = useState<CriteriaBuilderValue<Criterion>>({
7741
+ logic: "and",
7742
+ groups: [
7743
+ {
7744
+ id: "g1",
7745
+ logic: "and",
7746
+ criteria: [{ id: "c1", kind: "performed", event: "purchase" }],
7747
+ },
7748
+ ],
7749
+ });
7750
+ return (
7751
+ <CriteriaBuilder
7752
+ value={value}
7753
+ onChange={setValue}
7754
+ kinds={[
7755
+ {
7756
+ kind: "performed",
7757
+ label: "이벤트를 수행함",
7758
+ negatedLabel: "이벤트를 수행하지 않음",
7759
+ },
7760
+ ]}
7761
+ createCriterion={(kind, negate) => ({
7762
+ id: \`c\${++seq}\`,
7763
+ kind,
7764
+ negate,
7765
+ event: "",
7766
+ })}
7767
+ renderCriterion={(criterion, api) => (
7768
+ <ExpressionGroup>
7769
+ <Expression
7770
+ description={criterion.negate ? "수행하지 않음" : "수행함"}
7771
+ value={criterion.event || "이벤트 선택"}
7772
+ invalid={api.invalid || criterion.event === ""}
7773
+ onClick={() => api.update({ event: "purchase" })}
7774
+ />
7775
+ </ExpressionGroup>
7776
+ )}
7777
+ />
7778
+ );
7779
+ }`
7780
+ }]
7781
+ },
7378
7782
  {
7379
7783
  name: "InsightsPanel",
7380
7784
  category: "shell",
@@ -7431,6 +7835,21 @@ const filters = [
7431
7835
  testDescribe: "InsightsPanel"
7432
7836
  },
7433
7837
  {
7838
+ usage: {
7839
+ bestPractices: ["제어 모드에서는 value와 onChange를 함께 연결합니다. labels와 presets의 label은 제품 언어로 제공합니다.", "프리셋은 로컬 날짜의 자정을 기준으로 계산하므로 API의 시간대·종료일 포함 여부를 조회 계층에서 명시적으로 변환합니다."],
7840
+ avoid: ["날짜 선택만으로 서버 집계가 갱신된다고 가정하거나 null 날짜를 유효한 조회 범위로 전송하지 않습니다."]
7841
+ },
7842
+ examples: [{
7843
+ validation: "typechecked",
7844
+ title: "제어되는 조회 기간",
7845
+ code: `"use client";
7846
+ import { useState } from "react";
7847
+ import { TimeRangeSelector, type DateRange } from "@reopt-ai/opt-ui";
7848
+ export function Example() {
7849
+ const [range, setRange] = useState<DateRange>({ start: null, end: null });
7850
+ return <TimeRangeSelector value={range} onChange={setRange} labels={{ title: "조회 기간", placeholder: "기간 선택" }} presets={[{ label: "최근 7일", days: 7 }]} />;
7851
+ }`
7852
+ }],
7434
7853
  name: "TimeRangeSelector",
7435
7854
  category: "shell",
7436
7855
  description: "시간 범위 선택기. Popover + DateRangePicker + 프리셋 버튼 조합. 12개 시계열 Surface에서 사용.",
@@ -7520,6 +7939,19 @@ const filters = [
7520
7939
  testDescribe: "ExportButton"
7521
7940
  },
7522
7941
  {
7942
+ usage: {
7943
+ bestPractices: ["stats에는 안정적인 id와 표시 value를 제공합니다. 감소가 좋은 지표는 polarity=negative로 비교 의미를 명시합니다.", "선택 가능한 카드에서는 onStatClick과 selectedStatId를 같은 조회 상태에 연결합니다. 빈 목록 문구는 labels로 제공합니다."],
7944
+ avoid: ["loading 중 빈 결과를 확정적으로 표시하거나 모든 상승 추세를 좋은 결과로 해석하지 않습니다."]
7945
+ },
7946
+ examples: [{
7947
+ validation: "typechecked",
7948
+ title: "의미가 다른 통계 비교",
7949
+ code: `import { SummaryRow } from "@reopt-ai/opt-ui";
7950
+ export function Example() { return <SummaryRow columns={2} stats={[
7951
+ { id: "orders", title: "주문", value: "120", change: "+10%", trend: "up" },
7952
+ { id: "errors", title: "오류", value: "2", change: "-50%", trend: "down", polarity: "negative" },
7953
+ ]} />; }`
7954
+ }],
7523
7955
  name: "SummaryRow",
7524
7956
  category: "shell",
7525
7957
  description: "통계 요약 행. StatCard 그리드 레이아웃을 표준화합니다. loading 시 SkeletonCard 표시.",
@@ -10682,7 +11114,6 @@ const messages = [
10682
11114
  category: "core"
10683
11115
  }
10684
11116
  ],
10685
- ariaRole: "dialog",
10686
11117
  testDescribe: "SaveQueryDialog"
10687
11118
  },
10688
11119
  {
@@ -10769,6 +11200,426 @@ const messages = [
10769
11200
  category: "core"
10770
11201
  }],
10771
11202
  testDescribe: "QueryWorkspace"
11203
+ },
11204
+ {
11205
+ name: "ReplayScrubber",
11206
+ category: "shell",
11207
+ functionalCategory: "data-display",
11208
+ maturity: "beta",
11209
+ keywords: [
11210
+ "replay",
11211
+ "scrubber",
11212
+ "seekbar",
11213
+ "timeline",
11214
+ "session recording",
11215
+ "playhead"
11216
+ ],
11217
+ description: "세션 리플레이의 탐색 트랙. 활동/비활성/미로딩 구간, 활동 밀도, 이벤트 마커를 한 플레이헤드 아래에 겹쳐 그리고, 드래그·키보드 탐색이 끝났을 때만 seek을 커밋한다.",
11218
+ slug: "replay-scrubber",
11219
+ exports: ["ReplayScrubber", "groupReplayMarkers"],
11220
+ props: { ReplayScrubber: [
11221
+ {
11222
+ name: "start",
11223
+ type: "number",
11224
+ required: true,
11225
+ description: "재생 가능한 첫 시각(절대값, 보통 epoch ms)"
11226
+ },
11227
+ {
11228
+ name: "end",
11229
+ type: "number",
11230
+ required: true,
11231
+ description: "재생 가능한 마지막 시각"
11232
+ },
11233
+ {
11234
+ name: "position",
11235
+ type: "number",
11236
+ required: true,
11237
+ description: "현재 플레이헤드. [start, end]로 클램프"
11238
+ },
11239
+ {
11240
+ name: "segments",
11241
+ type: "ReplayScrubberSegment[]",
11242
+ description: "활동 분류 구간. active/inactive/gap/buffer"
11243
+ },
11244
+ {
11245
+ name: "buffered",
11246
+ type: "Array<{ start: number; end: number }>",
11247
+ description: "이미 로드된 범위"
11248
+ },
11249
+ {
11250
+ name: "activity",
11251
+ type: "number[]",
11252
+ description: "0..1로 정규화한 활동 밀도 버킷. 로그 스케일 영역으로 그린다"
11253
+ },
11254
+ {
11255
+ name: "markers",
11256
+ type: "ReplayScrubberMarker[]",
11257
+ description: "트랙 위 관심 지점. 가까운 마커는 하나로 합쳐진다"
11258
+ },
11259
+ {
11260
+ name: "onSeek",
11261
+ type: "(time: number) => void",
11262
+ required: true,
11263
+ description: "포인터·키보드 조작이 끝났을 때 한 번 호출"
11264
+ },
11265
+ {
11266
+ name: "onPreview",
11267
+ type: "(time: number | null) => void",
11268
+ description: "드래그·호버 중 미리보기 시각. 비싼 seek에 쓰지 않는다"
11269
+ },
11270
+ {
11271
+ name: "onMarkerSelect",
11272
+ type: "(marker: ReplayScrubberMarker) => void",
11273
+ description: "마커 선택. 생략하면 해당 시각으로 seek"
11274
+ },
11275
+ {
11276
+ name: "formatTime",
11277
+ type: "(time: number) => string",
11278
+ description: "배지와 aria-valuetext 포맷. 기본 start 기준 m:ss"
11279
+ },
11280
+ {
11281
+ name: "stepMs",
11282
+ type: "number",
11283
+ default: "5000",
11284
+ description: "화살표 키 이동량"
11285
+ },
11286
+ {
11287
+ name: "largeStepMs",
11288
+ type: "number",
11289
+ default: "30000",
11290
+ description: "PageUp/PageDown·Shift+화살표 이동량"
11291
+ },
11292
+ {
11293
+ name: "disabled",
11294
+ type: "boolean",
11295
+ default: "false",
11296
+ description: "모든 조작을 비활성화"
11297
+ },
11298
+ {
11299
+ name: "labels",
11300
+ type: "ReplayScrubberLabels",
11301
+ description: "접근성 문구 오버라이드"
11302
+ },
11303
+ {
11304
+ name: "className",
11305
+ type: "string",
11306
+ description: "추가 클래스"
11307
+ }
11308
+ ] },
11309
+ usage: {
11310
+ bestPractices: ["onSeek에만 실제 seek을 연결한다. onPreview는 시각 표시용이다.", "segments의 buffer는 '아직 안 옴'이고 gap은 '없음'이다. 플레이어가 기다릴지 건너뛸지가 갈린다."],
11311
+ avoid: ["마커를 수천 개 넘기지 않는다. 우선순위로 100개 안팎으로 추려서 넘긴다."]
11312
+ },
11313
+ testDescribe: "ReplayScrubber",
11314
+ examples: [{
11315
+ title: "구간·마커가 있는 탐색 트랙",
11316
+ code: `import { ReplayScrubber } from "@reopt-ai/opt-ui";
11317
+
11318
+ <ReplayScrubber
11319
+ start={recording.start}
11320
+ end={recording.end}
11321
+ position={playhead}
11322
+ segments={[
11323
+ { start: recording.start, end: recording.start + 60_000, kind: "active" },
11324
+ { start: recording.start + 60_000, end: recording.end, kind: "inactive" },
11325
+ ]}
11326
+ markers={[{ id: "err", time: errorAt, label: "TypeError", tone: "danger" }]}
11327
+ onSeek={(time) => player.seek(time)}
11328
+ labels={{ track: "녹화 탐색" }}
11329
+ />`
11330
+ }]
11331
+ },
11332
+ {
11333
+ name: "ReplayTransport",
11334
+ category: "shell",
11335
+ functionalCategory: "actions",
11336
+ maturity: "beta",
11337
+ keywords: [
11338
+ "replay",
11339
+ "transport",
11340
+ "play",
11341
+ "pause",
11342
+ "speed",
11343
+ "skip inactivity",
11344
+ "session recording"
11345
+ ],
11346
+ description: "세션 리플레이 재생 컨트롤. 재생/일시정지, 앞뒤 점프, 배속 메뉴, 비활성 건너뛰기 토글, 전체화면, 경과/시계 타임코드 전환을 제공하며 재생 상태는 호스트가 소유한다.",
11347
+ slug: "replay-transport",
11348
+ exports: ["ReplayTransport", "formatReplayElapsed"],
11349
+ props: { ReplayTransport: [
11350
+ {
11351
+ name: "playing",
11352
+ type: "boolean",
11353
+ required: true,
11354
+ description: "재생 중 여부"
11355
+ },
11356
+ {
11357
+ name: "ended",
11358
+ type: "boolean",
11359
+ default: "false",
11360
+ description: "끝에 도달. 재생 버튼이 다시 시작을 제안"
11361
+ },
11362
+ {
11363
+ name: "position",
11364
+ type: "number",
11365
+ required: true,
11366
+ description: "절대 플레이헤드 시각"
11367
+ },
11368
+ {
11369
+ name: "start",
11370
+ type: "number",
11371
+ required: true,
11372
+ description: "녹화 시작 시각(절대값)"
11373
+ },
11374
+ {
11375
+ name: "end",
11376
+ type: "number",
11377
+ required: true,
11378
+ description: "녹화 종료 시각(절대값)"
11379
+ },
11380
+ {
11381
+ name: "onTogglePlay",
11382
+ type: "() => void",
11383
+ required: true,
11384
+ description: "재생/일시정지 토글. ended면 처음부터 다시 시작"
11385
+ },
11386
+ {
11387
+ name: "onSkip",
11388
+ type: "(deltaMs: number) => void",
11389
+ required: true,
11390
+ description: "부호 있는 시간만큼 이동"
11391
+ },
11392
+ {
11393
+ name: "onSkipToStart",
11394
+ type: "() => void",
11395
+ description: "처음으로. 생략하면 버튼 숨김"
11396
+ },
11397
+ {
11398
+ name: "jumpMs",
11399
+ type: "number",
11400
+ default: "10000",
11401
+ description: "앞뒤 점프 버튼 이동량(ms)"
11402
+ },
11403
+ {
11404
+ name: "speed",
11405
+ type: "number",
11406
+ required: true,
11407
+ description: "현재 배속"
11408
+ },
11409
+ {
11410
+ name: "speeds",
11411
+ type: "readonly number[]",
11412
+ default: "[0.5, 1, 1.5, 2, 3, 4, 8, 16]",
11413
+ description: "선택 가능한 배속 목록"
11414
+ },
11415
+ {
11416
+ name: "onSpeedChange",
11417
+ type: "(speed: number) => void",
11418
+ required: true,
11419
+ description: "배속 변경 콜백"
11420
+ },
11421
+ {
11422
+ name: "skipInactive",
11423
+ type: "boolean",
11424
+ required: true,
11425
+ description: "비활성 구간 건너뛰기 여부"
11426
+ },
11427
+ {
11428
+ name: "onSkipInactiveChange",
11429
+ type: "(skip: boolean) => void",
11430
+ required: true,
11431
+ description: "건너뛰기 토글 콜백"
11432
+ },
11433
+ {
11434
+ name: "fullscreen",
11435
+ type: "boolean",
11436
+ description: "전체화면 상태(호스트 소유)"
11437
+ },
11438
+ {
11439
+ name: "onFullscreenChange",
11440
+ type: "(fullscreen: boolean) => void",
11441
+ description: "있으면 전체화면 버튼 표시"
11442
+ },
11443
+ {
11444
+ name: "timecode",
11445
+ type: "\"elapsed\" | \"clock\"",
11446
+ default: "\"elapsed\"",
11447
+ description: "경과 시간 또는 실제 시각 표시 모드"
11448
+ },
11449
+ {
11450
+ name: "onTimecodeChange",
11451
+ type: "(mode: \"elapsed\" | \"clock\") => void",
11452
+ description: "있으면 타임코드를 클릭해 전환"
11453
+ },
11454
+ {
11455
+ name: "formatClock",
11456
+ type: "(time: number) => string",
11457
+ description: "clock 모드 포맷"
11458
+ },
11459
+ {
11460
+ name: "disabled",
11461
+ type: "boolean",
11462
+ default: "false",
11463
+ description: "모든 조작을 비활성화"
11464
+ },
11465
+ {
11466
+ name: "children",
11467
+ type: "ReactNode",
11468
+ description: "오른쪽에 추가 컨트롤"
11469
+ },
11470
+ {
11471
+ name: "labels",
11472
+ type: "ReplayTransportLabels",
11473
+ description: "문구 오버라이드"
11474
+ },
11475
+ {
11476
+ name: "shortcuts",
11477
+ type: "ReplayTransportShortcuts",
11478
+ description: "툴팁에 표시할 단축키"
11479
+ },
11480
+ {
11481
+ name: "className",
11482
+ type: "string",
11483
+ description: "추가 클래스"
11484
+ }
11485
+ ] },
11486
+ usage: {
11487
+ bestPractices: ["단축키는 ReplayPlayerLayout의 shortcuts로 묶고, 여기엔 표시용 문자열만 넘긴다.", "clock 타임코드는 로그와 대조할 때 필요하다. 세션의 실제 시각을 넘긴다."],
11488
+ avoid: ["재생 상태를 컴포넌트 안에 두지 않는다. 플레이어 엔진이 단일 소스다."]
11489
+ },
11490
+ testDescribe: "ReplayTransport",
11491
+ examples: [{
11492
+ title: "호스트가 재생 상태를 소유하는 트랜스포트",
11493
+ code: `import { ReplayTransport } from "@reopt-ai/opt-ui";
11494
+
11495
+ <ReplayTransport
11496
+ playing={playing}
11497
+ position={playhead}
11498
+ start={recording.start}
11499
+ end={recording.end}
11500
+ onTogglePlay={() => (playing ? player.pause() : player.play())}
11501
+ onSkip={(delta) => player.seek(playhead + delta)}
11502
+ speed={speed}
11503
+ onSpeedChange={setSpeed}
11504
+ skipInactive={skipInactive}
11505
+ onSkipInactiveChange={setSkipInactive}
11506
+ labels={{ play: "재생", pause: "일시정지" }}
11507
+ />`
11508
+ }]
11509
+ },
11510
+ {
11511
+ name: "ReplayPlayerLayout",
11512
+ category: "shell",
11513
+ functionalCategory: "layout",
11514
+ maturity: "beta",
11515
+ keywords: [
11516
+ "replay",
11517
+ "player",
11518
+ "layout",
11519
+ "inspector",
11520
+ "fullscreen",
11521
+ "shortcuts",
11522
+ "session recording"
11523
+ ],
11524
+ description: "세션 리플레이 플레이어의 프레임. 스테이지·메타 바·컨트롤·리사이즈 가능한 인스펙터를 배치하고, 플레이어 전체의 전체화면과 입력 필드를 피하는 키보드 단축키 범위를 소유한다.",
11525
+ slug: "replay-player-layout",
11526
+ exports: ["ReplayPlayerLayout", "replayShortcutKey"],
11527
+ props: { ReplayPlayerLayout: [
11528
+ {
11529
+ name: "stage",
11530
+ type: "ReactNode",
11531
+ required: true,
11532
+ description: "녹화가 렌더되는 영역(iframe 호스트)"
11533
+ },
11534
+ {
11535
+ name: "meta",
11536
+ type: "ReactNode",
11537
+ description: "스테이지 위 얇은 바: URL, 창, 기기"
11538
+ },
11539
+ {
11540
+ name: "controls",
11541
+ type: "ReactNode",
11542
+ description: "스테이지 아래 트랜스포트와 스크러버"
11543
+ },
11544
+ {
11545
+ name: "inspector",
11546
+ type: "ReactNode",
11547
+ description: "측면 패널. 생략하면 패널 없음"
11548
+ },
11549
+ {
11550
+ name: "inspectorOpen",
11551
+ type: "boolean",
11552
+ default: "true",
11553
+ description: "인스펙터 표시 여부(제어됨)"
11554
+ },
11555
+ {
11556
+ name: "onInspectorOpenChange",
11557
+ type: "(open: boolean) => void",
11558
+ description: "있으면 토글 버튼 표시"
11559
+ },
11560
+ {
11561
+ name: "inspectorSize",
11562
+ type: "number",
11563
+ default: "34",
11564
+ description: "인스펙터 초기 폭(%)"
11565
+ },
11566
+ {
11567
+ name: "layoutId",
11568
+ type: "string",
11569
+ description: "리사이즈 레이아웃 영속 id"
11570
+ },
11571
+ {
11572
+ name: "fullscreen",
11573
+ type: "boolean",
11574
+ default: "false",
11575
+ description: "전체화면 상태(호스트 소유)"
11576
+ },
11577
+ {
11578
+ name: "onFullscreenChange",
11579
+ type: "(fullscreen: boolean) => void",
11580
+ description: "전체화면 변경 콜백. 브라우저가 스스로 나가도 호출된다"
11581
+ },
11582
+ {
11583
+ name: "shortcuts",
11584
+ type: "ReplayShortcutMap",
11585
+ description: "포커스가 안에 있고 입력 필드가 아닐 때만 동작. false를 반환하면 전파"
11586
+ },
11587
+ {
11588
+ name: "overlay",
11589
+ type: "ReactNode",
11590
+ description: "스테이지 위 오버레이: 로딩, 오류, 건너뛰는 중"
11591
+ },
11592
+ {
11593
+ name: "labels",
11594
+ type: "ReplayPlayerLayoutLabels",
11595
+ description: "문구 오버라이드"
11596
+ },
11597
+ {
11598
+ name: "className",
11599
+ type: "string",
11600
+ description: "추가 클래스"
11601
+ }
11602
+ ] },
11603
+ usage: {
11604
+ bestPractices: ["단축키 키 이름은 replayShortcutKey가 만든다: space, arrowleft, shift+arrowright, mod+arrowleft, f.", "전체화면 상태는 호스트가 들고, 브라우저가 스스로 나가면 onFullscreenChange(false)로 통지된다."],
11605
+ avoid: ["스테이지 iframe에 직접 requestFullscreen하지 않는다. 컨트롤이 함께 보여야 한다."]
11606
+ },
11607
+ testDescribe: "ReplayPlayerLayout",
11608
+ examples: [{
11609
+ title: "스테이지·컨트롤·인스펙터 배치와 단축키",
11610
+ code: `import { ReplayPlayerLayout } from "@reopt-ai/opt-ui";
11611
+
11612
+ <ReplayPlayerLayout
11613
+ layoutId="replay-player"
11614
+ meta={<span>{currentPath}</span>}
11615
+ stage={<div ref={stageRef} />}
11616
+ controls={<>{scrubber}{transport}</>}
11617
+ inspector={<EventList onSelect={(e) => player.seek(e.time)} />}
11618
+ inspectorOpen={inspectorOpen}
11619
+ onInspectorOpenChange={setInspectorOpen}
11620
+ shortcuts={{ space: togglePlay, arrowleft: () => skip(-10_000), arrowright: () => skip(10_000) }}
11621
+ />`
11622
+ }]
10772
11623
  }
10773
11624
  ];
10774
11625
  //#endregion