@qijenchen/design-system 0.1.0-beta.10

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 (507) hide show
  1. package/README.md +163 -0
  2. package/dist/components/Accordion/accordion.d.ts +37 -0
  3. package/dist/components/Accordion/accordion.d.ts.map +1 -0
  4. package/dist/components/Accordion/accordion.js +78 -0
  5. package/dist/components/Accordion/accordion.js.map +1 -0
  6. package/dist/components/Alert/alert.d.ts +47 -0
  7. package/dist/components/Alert/alert.d.ts.map +1 -0
  8. package/dist/components/Alert/alert.js +132 -0
  9. package/dist/components/Alert/alert.js.map +1 -0
  10. package/dist/components/AppShell/_demo-helpers.d.ts +49 -0
  11. package/dist/components/AppShell/_demo-helpers.d.ts.map +1 -0
  12. package/dist/components/AppShell/app-shell.d.ts +76 -0
  13. package/dist/components/AppShell/app-shell.d.ts.map +1 -0
  14. package/dist/components/AppShell/app-shell.js +214 -0
  15. package/dist/components/AppShell/app-shell.js.map +1 -0
  16. package/dist/components/AspectRatio/aspect-ratio.d.ts +40 -0
  17. package/dist/components/AspectRatio/aspect-ratio.d.ts.map +1 -0
  18. package/dist/components/AspectRatio/aspect-ratio.js +23 -0
  19. package/dist/components/AspectRatio/aspect-ratio.js.map +1 -0
  20. package/dist/components/Avatar/avatar.d.ts +85 -0
  21. package/dist/components/Avatar/avatar.d.ts.map +1 -0
  22. package/dist/components/Avatar/avatar.js +195 -0
  23. package/dist/components/Avatar/avatar.js.map +1 -0
  24. package/dist/components/Badge/badge.d.ts +43 -0
  25. package/dist/components/Badge/badge.d.ts.map +1 -0
  26. package/dist/components/Badge/badge.js +69 -0
  27. package/dist/components/Badge/badge.js.map +1 -0
  28. package/dist/components/Breadcrumb/breadcrumb.d.ts +163 -0
  29. package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -0
  30. package/dist/components/Breadcrumb/breadcrumb.js +300 -0
  31. package/dist/components/Breadcrumb/breadcrumb.js.map +1 -0
  32. package/dist/components/BulkActionBar/bulk-action-bar.d.ts +46 -0
  33. package/dist/components/BulkActionBar/bulk-action-bar.d.ts.map +1 -0
  34. package/dist/components/BulkActionBar/bulk-action-bar.js +78 -0
  35. package/dist/components/BulkActionBar/bulk-action-bar.js.map +1 -0
  36. package/dist/components/Button/button-group.d.ts +49 -0
  37. package/dist/components/Button/button-group.d.ts.map +1 -0
  38. package/dist/components/Button/button-group.js +46 -0
  39. package/dist/components/Button/button-group.js.map +1 -0
  40. package/dist/components/Button/button.d.ts +203 -0
  41. package/dist/components/Button/button.d.ts.map +1 -0
  42. package/dist/components/Button/button.js +309 -0
  43. package/dist/components/Button/button.js.map +1 -0
  44. package/dist/components/Calendar/calendar.d.ts +81 -0
  45. package/dist/components/Calendar/calendar.d.ts.map +1 -0
  46. package/dist/components/Calendar/calendar.js +282 -0
  47. package/dist/components/Calendar/calendar.js.map +1 -0
  48. package/dist/components/Carousel/carousel.d.ts +61 -0
  49. package/dist/components/Carousel/carousel.d.ts.map +1 -0
  50. package/dist/components/Carousel/carousel.js +276 -0
  51. package/dist/components/Carousel/carousel.js.map +1 -0
  52. package/dist/components/Chart/chart.d.ts +94 -0
  53. package/dist/components/Chart/chart.d.ts.map +1 -0
  54. package/dist/components/Chart/chart.js +233 -0
  55. package/dist/components/Chart/chart.js.map +1 -0
  56. package/dist/components/Checkbox/checkbox-group.d.ts +58 -0
  57. package/dist/components/Checkbox/checkbox-group.d.ts.map +1 -0
  58. package/dist/components/Checkbox/checkbox-group.js +28 -0
  59. package/dist/components/Checkbox/checkbox-group.js.map +1 -0
  60. package/dist/components/Checkbox/checkbox.d.ts +73 -0
  61. package/dist/components/Checkbox/checkbox.d.ts.map +1 -0
  62. package/dist/components/Checkbox/checkbox.js +125 -0
  63. package/dist/components/Checkbox/checkbox.js.map +1 -0
  64. package/dist/components/Chip/chip.d.ts +54 -0
  65. package/dist/components/Chip/chip.d.ts.map +1 -0
  66. package/dist/components/Chip/chip.js +224 -0
  67. package/dist/components/Chip/chip.js.map +1 -0
  68. package/dist/components/CircularProgress/circular-progress.d.ts +40 -0
  69. package/dist/components/CircularProgress/circular-progress.d.ts.map +1 -0
  70. package/dist/components/CircularProgress/circular-progress.js +118 -0
  71. package/dist/components/CircularProgress/circular-progress.js.map +1 -0
  72. package/dist/components/Coachmark/coachmark.d.ts +100 -0
  73. package/dist/components/Coachmark/coachmark.d.ts.map +1 -0
  74. package/dist/components/Coachmark/coachmark.js +107 -0
  75. package/dist/components/Coachmark/coachmark.js.map +1 -0
  76. package/dist/components/Combobox/combobox.d.ts +150 -0
  77. package/dist/components/Combobox/combobox.d.ts.map +1 -0
  78. package/dist/components/Combobox/combobox.js +595 -0
  79. package/dist/components/Combobox/combobox.js.map +1 -0
  80. package/dist/components/Command/command.d.ts +106 -0
  81. package/dist/components/Command/command.d.ts.map +1 -0
  82. package/dist/components/Command/command.js +123 -0
  83. package/dist/components/Command/command.js.map +1 -0
  84. package/dist/components/DataTable/active-editor-controller.d.ts +66 -0
  85. package/dist/components/DataTable/active-editor-controller.d.ts.map +1 -0
  86. package/dist/components/DataTable/cell-registry.d.ts +37 -0
  87. package/dist/components/DataTable/cell-registry.d.ts.map +1 -0
  88. package/dist/components/DataTable/cell-registry.js +377 -0
  89. package/dist/components/DataTable/cell-registry.js.map +1 -0
  90. package/dist/components/DataTable/column-types.d.ts +145 -0
  91. package/dist/components/DataTable/column-types.d.ts.map +1 -0
  92. package/dist/components/DataTable/column-types.js +17 -0
  93. package/dist/components/DataTable/column-types.js.map +1 -0
  94. package/dist/components/DataTable/data-table-column-visibility-panel.d.ts +49 -0
  95. package/dist/components/DataTable/data-table-column-visibility-panel.d.ts.map +1 -0
  96. package/dist/components/DataTable/data-table-filter-panel.d.ts +30 -0
  97. package/dist/components/DataTable/data-table-filter-panel.d.ts.map +1 -0
  98. package/dist/components/DataTable/data-table-interaction-layer.d.ts +78 -0
  99. package/dist/components/DataTable/data-table-interaction-layer.d.ts.map +1 -0
  100. package/dist/components/DataTable/data-table-interaction-layer.js +220 -0
  101. package/dist/components/DataTable/data-table-interaction-layer.js.map +1 -0
  102. package/dist/components/DataTable/data-table-sort-manager.d.ts +19 -0
  103. package/dist/components/DataTable/data-table-sort-manager.d.ts.map +1 -0
  104. package/dist/components/DataTable/data-table.d.ts +181 -0
  105. package/dist/components/DataTable/data-table.d.ts.map +1 -0
  106. package/dist/components/DataTable/data-table.js +1851 -0
  107. package/dist/components/DataTable/data-table.js.map +1 -0
  108. package/dist/components/DataTable/filter-operators.d.ts +116 -0
  109. package/dist/components/DataTable/filter-operators.d.ts.map +1 -0
  110. package/dist/components/DataTable/filter-tree.d.ts +66 -0
  111. package/dist/components/DataTable/filter-tree.d.ts.map +1 -0
  112. package/dist/components/DataTable/lib/column-meta.d.ts +49 -0
  113. package/dist/components/DataTable/lib/column-meta.d.ts.map +1 -0
  114. package/dist/components/DateGrid/date-grid.d.ts +61 -0
  115. package/dist/components/DateGrid/date-grid.d.ts.map +1 -0
  116. package/dist/components/DateGrid/date-grid.js +168 -0
  117. package/dist/components/DateGrid/date-grid.js.map +1 -0
  118. package/dist/components/DatePicker/date-picker.d.ts +119 -0
  119. package/dist/components/DatePicker/date-picker.d.ts.map +1 -0
  120. package/dist/components/DatePicker/date-picker.js +743 -0
  121. package/dist/components/DatePicker/date-picker.js.map +1 -0
  122. package/dist/components/DescriptionList/description-list.d.ts +60 -0
  123. package/dist/components/DescriptionList/description-list.d.ts.map +1 -0
  124. package/dist/components/DescriptionList/description-list.js +77 -0
  125. package/dist/components/DescriptionList/description-list.js.map +1 -0
  126. package/dist/components/Dialog/dialog.d.ts +54 -0
  127. package/dist/components/Dialog/dialog.d.ts.map +1 -0
  128. package/dist/components/Dialog/dialog.js +151 -0
  129. package/dist/components/Dialog/dialog.js.map +1 -0
  130. package/dist/components/DropdownMenu/dropdown-menu.d.ts +111 -0
  131. package/dist/components/DropdownMenu/dropdown-menu.d.ts.map +1 -0
  132. package/dist/components/DropdownMenu/dropdown-menu.js +288 -0
  133. package/dist/components/DropdownMenu/dropdown-menu.js.map +1 -0
  134. package/dist/components/Empty/empty.d.ts +40 -0
  135. package/dist/components/Empty/empty.d.ts.map +1 -0
  136. package/dist/components/Empty/empty.js +66 -0
  137. package/dist/components/Empty/empty.js.map +1 -0
  138. package/dist/components/Field/field-context.d.ts +77 -0
  139. package/dist/components/Field/field-context.d.ts.map +1 -0
  140. package/dist/components/Field/field-context.js +37 -0
  141. package/dist/components/Field/field-context.js.map +1 -0
  142. package/dist/components/Field/field-types.d.ts +5 -0
  143. package/dist/components/Field/field-types.d.ts.map +1 -0
  144. package/dist/components/Field/field-types.js +13 -0
  145. package/dist/components/Field/field-types.js.map +1 -0
  146. package/dist/components/Field/field-wrapper.d.ts +17 -0
  147. package/dist/components/Field/field-wrapper.d.ts.map +1 -0
  148. package/dist/components/Field/field-wrapper.js +252 -0
  149. package/dist/components/Field/field-wrapper.js.map +1 -0
  150. package/dist/components/Field/field.d.ts +127 -0
  151. package/dist/components/Field/field.d.ts.map +1 -0
  152. package/dist/components/Field/field.js +295 -0
  153. package/dist/components/Field/field.js.map +1 -0
  154. package/dist/components/FieldControlGroup/field-control-group.d.ts +74 -0
  155. package/dist/components/FieldControlGroup/field-control-group.d.ts.map +1 -0
  156. package/dist/components/FieldControlGroup/field-control-group.js +62 -0
  157. package/dist/components/FieldControlGroup/field-control-group.js.map +1 -0
  158. package/dist/components/FileItem/file-item.d.ts +44 -0
  159. package/dist/components/FileItem/file-item.d.ts.map +1 -0
  160. package/dist/components/FileItem/file-item.js +202 -0
  161. package/dist/components/FileItem/file-item.js.map +1 -0
  162. package/dist/components/FileUpload/file-upload.d.ts +97 -0
  163. package/dist/components/FileUpload/file-upload.d.ts.map +1 -0
  164. package/dist/components/FileUpload/file-upload.js +231 -0
  165. package/dist/components/FileUpload/file-upload.js.map +1 -0
  166. package/dist/components/FileViewer/file-viewer-types.d.ts +73 -0
  167. package/dist/components/FileViewer/file-viewer-types.d.ts.map +1 -0
  168. package/dist/components/FileViewer/file-viewer.d.ts +82 -0
  169. package/dist/components/FileViewer/file-viewer.d.ts.map +1 -0
  170. package/dist/components/FileViewer/file-viewer.js +752 -0
  171. package/dist/components/FileViewer/file-viewer.js.map +1 -0
  172. package/dist/components/FileViewer/image-renderer.d.ts +9 -0
  173. package/dist/components/FileViewer/image-renderer.d.ts.map +1 -0
  174. package/dist/components/FileViewer/image-renderer.js +165 -0
  175. package/dist/components/FileViewer/image-renderer.js.map +1 -0
  176. package/dist/components/HoverCard/hover-card.d.ts +30 -0
  177. package/dist/components/HoverCard/hover-card.d.ts.map +1 -0
  178. package/dist/components/HoverCard/hover-card.js +61 -0
  179. package/dist/components/HoverCard/hover-card.js.map +1 -0
  180. package/dist/components/Input/input.d.ts +72 -0
  181. package/dist/components/Input/input.d.ts.map +1 -0
  182. package/dist/components/Input/input.js +148 -0
  183. package/dist/components/Input/input.js.map +1 -0
  184. package/dist/components/LinkInput/link-input.d.ts +46 -0
  185. package/dist/components/LinkInput/link-input.d.ts.map +1 -0
  186. package/dist/components/LinkInput/link-input.js +215 -0
  187. package/dist/components/LinkInput/link-input.js.map +1 -0
  188. package/dist/components/Menu/menu-item.d.ts +83 -0
  189. package/dist/components/Menu/menu-item.d.ts.map +1 -0
  190. package/dist/components/Menu/menu-item.js +209 -0
  191. package/dist/components/Menu/menu-item.js.map +1 -0
  192. package/dist/components/NameCard/name-card.d.ts +85 -0
  193. package/dist/components/NameCard/name-card.d.ts.map +1 -0
  194. package/dist/components/NameCard/name-card.js +153 -0
  195. package/dist/components/NameCard/name-card.js.map +1 -0
  196. package/dist/components/Notice/notice.d.ts +69 -0
  197. package/dist/components/Notice/notice.d.ts.map +1 -0
  198. package/dist/components/Notice/notice.js +121 -0
  199. package/dist/components/Notice/notice.js.map +1 -0
  200. package/dist/components/NumberInput/number-input.d.ts +57 -0
  201. package/dist/components/NumberInput/number-input.d.ts.map +1 -0
  202. package/dist/components/NumberInput/number-input.js +131 -0
  203. package/dist/components/NumberInput/number-input.js.map +1 -0
  204. package/dist/components/OverflowIndicator/overflow-indicator.d.ts +23 -0
  205. package/dist/components/OverflowIndicator/overflow-indicator.d.ts.map +1 -0
  206. package/dist/components/OverflowIndicator/overflow-indicator.js +111 -0
  207. package/dist/components/OverflowIndicator/overflow-indicator.js.map +1 -0
  208. package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts +57 -0
  209. package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts.map +1 -0
  210. package/dist/components/PeoplePicker/avatar-stack-overflow.js +35 -0
  211. package/dist/components/PeoplePicker/avatar-stack-overflow.js.map +1 -0
  212. package/dist/components/PeoplePicker/people-picker-helpers.d.ts +7 -0
  213. package/dist/components/PeoplePicker/people-picker-helpers.d.ts.map +1 -0
  214. package/dist/components/PeoplePicker/people-picker-helpers.js +25 -0
  215. package/dist/components/PeoplePicker/people-picker-helpers.js.map +1 -0
  216. package/dist/components/PeoplePicker/people-picker.d.ts +77 -0
  217. package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -0
  218. package/dist/components/PeoplePicker/people-picker.js +263 -0
  219. package/dist/components/PeoplePicker/people-picker.js.map +1 -0
  220. package/dist/components/PeoplePicker/person-display.d.ts +66 -0
  221. package/dist/components/PeoplePicker/person-display.d.ts.map +1 -0
  222. package/dist/components/PeoplePicker/person-display.js +203 -0
  223. package/dist/components/PeoplePicker/person-display.js.map +1 -0
  224. package/dist/components/Popover/popover.d.ts +50 -0
  225. package/dist/components/Popover/popover.d.ts.map +1 -0
  226. package/dist/components/Popover/popover.js +113 -0
  227. package/dist/components/Popover/popover.js.map +1 -0
  228. package/dist/components/ProgressBar/progress-bar.d.ts +37 -0
  229. package/dist/components/ProgressBar/progress-bar.d.ts.map +1 -0
  230. package/dist/components/ProgressBar/progress-bar.js +86 -0
  231. package/dist/components/ProgressBar/progress-bar.js.map +1 -0
  232. package/dist/components/RadioGroup/radio-group.d.ts +78 -0
  233. package/dist/components/RadioGroup/radio-group.d.ts.map +1 -0
  234. package/dist/components/RadioGroup/radio-group.js +153 -0
  235. package/dist/components/RadioGroup/radio-group.js.map +1 -0
  236. package/dist/components/Rating/rating.d.ts +46 -0
  237. package/dist/components/Rating/rating.d.ts.map +1 -0
  238. package/dist/components/Rating/rating.js +179 -0
  239. package/dist/components/Rating/rating.js.map +1 -0
  240. package/dist/components/ScrollArea/scroll-area.d.ts +45 -0
  241. package/dist/components/ScrollArea/scroll-area.d.ts.map +1 -0
  242. package/dist/components/ScrollArea/scroll-area.js +65 -0
  243. package/dist/components/ScrollArea/scroll-area.js.map +1 -0
  244. package/dist/components/SegmentedControl/segmented-control.d.ts +102 -0
  245. package/dist/components/SegmentedControl/segmented-control.d.ts.map +1 -0
  246. package/dist/components/SegmentedControl/segmented-control.js +171 -0
  247. package/dist/components/SegmentedControl/segmented-control.js.map +1 -0
  248. package/dist/components/Select/select.d.ts +102 -0
  249. package/dist/components/Select/select.d.ts.map +1 -0
  250. package/dist/components/Select/select.js +435 -0
  251. package/dist/components/Select/select.js.map +1 -0
  252. package/dist/components/SelectMenu/select-menu.d.ts +103 -0
  253. package/dist/components/SelectMenu/select-menu.d.ts.map +1 -0
  254. package/dist/components/SelectMenu/select-menu.js +239 -0
  255. package/dist/components/SelectMenu/select-menu.js.map +1 -0
  256. package/dist/components/SelectionControl/selection-item.d.ts +69 -0
  257. package/dist/components/SelectionControl/selection-item.d.ts.map +1 -0
  258. package/dist/components/SelectionControl/selection-item.js +142 -0
  259. package/dist/components/SelectionControl/selection-item.js.map +1 -0
  260. package/dist/components/Separator/separator.d.ts +17 -0
  261. package/dist/components/Separator/separator.d.ts.map +1 -0
  262. package/dist/components/Separator/separator.js +39 -0
  263. package/dist/components/Separator/separator.js.map +1 -0
  264. package/dist/components/Sheet/sheet.d.ts +56 -0
  265. package/dist/components/Sheet/sheet.d.ts.map +1 -0
  266. package/dist/components/Sheet/sheet.js +145 -0
  267. package/dist/components/Sheet/sheet.js.map +1 -0
  268. package/dist/components/Sidebar/sidebar.d.ts +195 -0
  269. package/dist/components/Sidebar/sidebar.d.ts.map +1 -0
  270. package/dist/components/Sidebar/sidebar.js +826 -0
  271. package/dist/components/Sidebar/sidebar.js.map +1 -0
  272. package/dist/components/Skeleton/skeleton.d.ts +16 -0
  273. package/dist/components/Skeleton/skeleton.d.ts.map +1 -0
  274. package/dist/components/Skeleton/skeleton.js +30 -0
  275. package/dist/components/Skeleton/skeleton.js.map +1 -0
  276. package/dist/components/Slider/slider.d.ts +48 -0
  277. package/dist/components/Slider/slider.d.ts.map +1 -0
  278. package/dist/components/Slider/slider.js +108 -0
  279. package/dist/components/Slider/slider.js.map +1 -0
  280. package/dist/components/Steps/steps.d.ts +71 -0
  281. package/dist/components/Steps/steps.d.ts.map +1 -0
  282. package/dist/components/Steps/steps.js +583 -0
  283. package/dist/components/Steps/steps.js.map +1 -0
  284. package/dist/components/Switch/switch.d.ts +112 -0
  285. package/dist/components/Switch/switch.d.ts.map +1 -0
  286. package/dist/components/Switch/switch.js +179 -0
  287. package/dist/components/Switch/switch.js.map +1 -0
  288. package/dist/components/Tabs/tabs.d.ts +104 -0
  289. package/dist/components/Tabs/tabs.d.ts.map +1 -0
  290. package/dist/components/Tabs/tabs.js +316 -0
  291. package/dist/components/Tabs/tabs.js.map +1 -0
  292. package/dist/components/Tag/tag.d.ts +86 -0
  293. package/dist/components/Tag/tag.d.ts.map +1 -0
  294. package/dist/components/Tag/tag.js +172 -0
  295. package/dist/components/Tag/tag.js.map +1 -0
  296. package/dist/components/Textarea/textarea.d.ts +74 -0
  297. package/dist/components/Textarea/textarea.d.ts.map +1 -0
  298. package/dist/components/Textarea/textarea.js +224 -0
  299. package/dist/components/Textarea/textarea.js.map +1 -0
  300. package/dist/components/TimePicker/time-columns.d.ts +46 -0
  301. package/dist/components/TimePicker/time-columns.d.ts.map +1 -0
  302. package/dist/components/TimePicker/time-columns.js +173 -0
  303. package/dist/components/TimePicker/time-columns.js.map +1 -0
  304. package/dist/components/TimePicker/time-picker.d.ts +94 -0
  305. package/dist/components/TimePicker/time-picker.d.ts.map +1 -0
  306. package/dist/components/TimePicker/time-picker.js +253 -0
  307. package/dist/components/TimePicker/time-picker.js.map +1 -0
  308. package/dist/components/Toast/toast.d.ts +61 -0
  309. package/dist/components/Toast/toast.d.ts.map +1 -0
  310. package/dist/components/Toast/toast.js +76 -0
  311. package/dist/components/Toast/toast.js.map +1 -0
  312. package/dist/components/Tooltip/tooltip.d.ts +20 -0
  313. package/dist/components/Tooltip/tooltip.d.ts.map +1 -0
  314. package/dist/components/Tooltip/tooltip.js +53 -0
  315. package/dist/components/Tooltip/tooltip.js.map +1 -0
  316. package/dist/components/TreeView/tree-view.d.ts +166 -0
  317. package/dist/components/TreeView/tree-view.d.ts.map +1 -0
  318. package/dist/components/TreeView/tree-view.js +617 -0
  319. package/dist/components/TreeView/tree-view.js.map +1 -0
  320. package/dist/hooks/use-controllable.d.ts +16 -0
  321. package/dist/hooks/use-controllable.d.ts.map +1 -0
  322. package/dist/hooks/use-controllable.js +26 -0
  323. package/dist/hooks/use-controllable.js.map +1 -0
  324. package/dist/hooks/use-is-narrow-viewport.d.ts +2 -0
  325. package/dist/hooks/use-is-narrow-viewport.d.ts.map +1 -0
  326. package/dist/hooks/use-is-narrow-viewport.js +19 -0
  327. package/dist/hooks/use-is-narrow-viewport.js.map +1 -0
  328. package/dist/hooks/use-is-touch-device.d.ts +8 -0
  329. package/dist/hooks/use-is-touch-device.d.ts.map +1 -0
  330. package/dist/hooks/use-is-touch-device.js +16 -0
  331. package/dist/hooks/use-is-touch-device.js.map +1 -0
  332. package/dist/hooks/use-overflow-items.d.ts +124 -0
  333. package/dist/hooks/use-overflow-items.d.ts.map +1 -0
  334. package/dist/hooks/use-overflow-items.js +97 -0
  335. package/dist/hooks/use-overflow-items.js.map +1 -0
  336. package/dist/index.d.ts +74 -0
  337. package/dist/index.d.ts.map +1 -0
  338. package/dist/index.js +371 -0
  339. package/dist/index.js.map +1 -0
  340. package/dist/lib/drag-visual.d.ts +158 -0
  341. package/dist/lib/drag-visual.d.ts.map +1 -0
  342. package/dist/lib/drag-visual.js +96 -0
  343. package/dist/lib/drag-visual.js.map +1 -0
  344. package/dist/lib/i18n/i18n-context.d.ts +105 -0
  345. package/dist/lib/i18n/i18n-context.d.ts.map +1 -0
  346. package/dist/lib/multi-select-ordering.d.ts +54 -0
  347. package/dist/lib/multi-select-ordering.d.ts.map +1 -0
  348. package/dist/lib/multi-select-ordering.js +13 -0
  349. package/dist/lib/multi-select-ordering.js.map +1 -0
  350. package/dist/lib/utils.d.ts +12 -0
  351. package/dist/lib/utils.d.ts.map +1 -0
  352. package/dist/lib/utils.js +79 -0
  353. package/dist/lib/utils.js.map +1 -0
  354. package/dist/patterns/element-anatomy/item-anatomy.d.ts +370 -0
  355. package/dist/patterns/element-anatomy/item-anatomy.d.ts.map +1 -0
  356. package/dist/patterns/element-anatomy/item-anatomy.js +272 -0
  357. package/dist/patterns/element-anatomy/item-anatomy.js.map +1 -0
  358. package/dist/patterns/header-canonical/chrome-header.d.ts +80 -0
  359. package/dist/patterns/header-canonical/chrome-header.d.ts.map +1 -0
  360. package/dist/patterns/header-canonical/chrome-header.js +75 -0
  361. package/dist/patterns/header-canonical/chrome-header.js.map +1 -0
  362. package/dist/patterns/horizontal-overflow/horizontal-overflow.d.ts +101 -0
  363. package/dist/patterns/horizontal-overflow/horizontal-overflow.d.ts.map +1 -0
  364. package/dist/patterns/horizontal-overflow/horizontal-overflow.js +105 -0
  365. package/dist/patterns/horizontal-overflow/horizontal-overflow.js.map +1 -0
  366. package/dist/patterns/overlay-surface/overlay-surface.d.ts +28 -0
  367. package/dist/patterns/overlay-surface/overlay-surface.d.ts.map +1 -0
  368. package/dist/patterns/overlay-surface/overlay-surface.js +85 -0
  369. package/dist/patterns/overlay-surface/overlay-surface.js.map +1 -0
  370. package/dist/patterns/resize-handle/resize-handle.d.ts +102 -0
  371. package/dist/patterns/resize-handle/resize-handle.d.ts.map +1 -0
  372. package/dist/patterns/resize-handle/resize-handle.js +74 -0
  373. package/dist/patterns/resize-handle/resize-handle.js.map +1 -0
  374. package/dist/react-day-picker.css +457 -0
  375. package/dist/stories-helpers/anatomy/anatomy-utils.d.ts +40 -0
  376. package/dist/stories-helpers/anatomy/anatomy-utils.d.ts.map +1 -0
  377. package/dist/tokens/elevation/overlay-geometry.d.ts +12 -0
  378. package/dist/tokens/elevation/overlay-geometry.d.ts.map +1 -0
  379. package/dist/tokens/elevation/overlay-geometry.js +7 -0
  380. package/dist/tokens/elevation/overlay-geometry.js.map +1 -0
  381. package/dist/tokens/motion/motion.d.ts +15 -0
  382. package/dist/tokens/motion/motion.d.ts.map +1 -0
  383. package/dist/tokens/motion/motion.js +9 -0
  384. package/dist/tokens/motion/motion.js.map +1 -0
  385. package/dist/tokens/uiSize/icon-size.d.ts +53 -0
  386. package/dist/tokens/uiSize/icon-size.d.ts.map +1 -0
  387. package/package.json +92 -0
  388. package/src/README.md +32 -0
  389. package/src/components/Accordion/accordion.tsx +104 -0
  390. package/src/components/Alert/alert.tsx +188 -0
  391. package/src/components/AppShell/_demo-helpers.tsx +198 -0
  392. package/src/components/AppShell/app-shell.tsx +364 -0
  393. package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
  394. package/src/components/Avatar/avatar.tsx +368 -0
  395. package/src/components/Badge/badge.tsx +104 -0
  396. package/src/components/Breadcrumb/breadcrumb.tsx +619 -0
  397. package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
  398. package/src/components/Button/button-group.tsx +96 -0
  399. package/src/components/Button/button.tsx +539 -0
  400. package/src/components/Calendar/calendar.tsx +411 -0
  401. package/src/components/Carousel/carousel.tsx +371 -0
  402. package/src/components/Chart/chart.tsx +376 -0
  403. package/src/components/Checkbox/checkbox-group.tsx +94 -0
  404. package/src/components/Checkbox/checkbox.tsx +237 -0
  405. package/src/components/Chip/chip.tsx +359 -0
  406. package/src/components/CircularProgress/circular-progress.tsx +204 -0
  407. package/src/components/Coachmark/coachmark.tsx +255 -0
  408. package/src/components/Combobox/combobox.tsx +826 -0
  409. package/src/components/Command/command.tsx +187 -0
  410. package/src/components/DataTable/active-editor-controller.ts +72 -0
  411. package/src/components/DataTable/cell-registry.tsx +520 -0
  412. package/src/components/DataTable/column-types.ts +180 -0
  413. package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
  414. package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
  415. package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
  416. package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
  417. package/src/components/DataTable/data-table.css +165 -0
  418. package/src/components/DataTable/data-table.tsx +2924 -0
  419. package/src/components/DataTable/filter-operators.ts +225 -0
  420. package/src/components/DataTable/filter-tree.ts +313 -0
  421. package/src/components/DataTable/lib/column-meta.ts +79 -0
  422. package/src/components/DateGrid/date-grid.tsx +209 -0
  423. package/src/components/DatePicker/date-picker.tsx +1114 -0
  424. package/src/components/DescriptionList/description-list.tsx +141 -0
  425. package/src/components/Dialog/dialog.tsx +267 -0
  426. package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
  427. package/src/components/Empty/empty.tsx +108 -0
  428. package/src/components/Field/field-context.ts +136 -0
  429. package/src/components/Field/field-types.ts +52 -0
  430. package/src/components/Field/field-wrapper.tsx +348 -0
  431. package/src/components/Field/field.tsx +535 -0
  432. package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
  433. package/src/components/FileItem/file-item.tsx +322 -0
  434. package/src/components/FileUpload/file-upload.tsx +326 -0
  435. package/src/components/FileViewer/file-viewer-types.ts +76 -0
  436. package/src/components/FileViewer/file-viewer.tsx +1065 -0
  437. package/src/components/FileViewer/image-renderer.tsx +256 -0
  438. package/src/components/HoverCard/hover-card.tsx +79 -0
  439. package/src/components/Input/input.tsx +233 -0
  440. package/src/components/LinkInput/link-input.tsx +304 -0
  441. package/src/components/Menu/menu-item.tsx +334 -0
  442. package/src/components/NameCard/name-card.tsx +319 -0
  443. package/src/components/Notice/notice.tsx +196 -0
  444. package/src/components/NumberInput/number-input.tsx +203 -0
  445. package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
  446. package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
  447. package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
  448. package/src/components/PeoplePicker/people-picker.tsx +455 -0
  449. package/src/components/PeoplePicker/person-display.tsx +358 -0
  450. package/src/components/Popover/popover.tsx +183 -0
  451. package/src/components/ProgressBar/progress-bar.tsx +157 -0
  452. package/src/components/README.md +58 -0
  453. package/src/components/RadioGroup/radio-group.tsx +261 -0
  454. package/src/components/Rating/rating.tsx +295 -0
  455. package/src/components/ScrollArea/scroll-area.tsx +110 -0
  456. package/src/components/SegmentedControl/segmented-control.tsx +304 -0
  457. package/src/components/Select/select.tsx +658 -0
  458. package/src/components/SelectMenu/select-menu.tsx +430 -0
  459. package/src/components/SelectionControl/selection-item.tsx +261 -0
  460. package/src/components/Separator/separator.tsx +48 -0
  461. package/src/components/Sheet/sheet.tsx +240 -0
  462. package/src/components/Sidebar/sidebar.tsx +1280 -0
  463. package/src/components/Skeleton/skeleton.tsx +35 -0
  464. package/src/components/Slider/slider.tsx +158 -0
  465. package/src/components/Steps/steps.tsx +850 -0
  466. package/src/components/Switch/switch.tsx +285 -0
  467. package/src/components/Tabs/tabs.tsx +515 -0
  468. package/src/components/Tag/tag.tsx +246 -0
  469. package/src/components/Textarea/textarea.tsx +280 -0
  470. package/src/components/TimePicker/time-columns.tsx +260 -0
  471. package/src/components/TimePicker/time-picker.tsx +419 -0
  472. package/src/components/Toast/toast.tsx +129 -0
  473. package/src/components/Tooltip/tooltip.tsx +68 -0
  474. package/src/components/TreeView/tree-view.tsx +1031 -0
  475. package/src/hooks/use-controllable.ts +40 -0
  476. package/src/hooks/use-is-narrow-viewport.ts +19 -0
  477. package/src/hooks/use-is-touch-device.ts +21 -0
  478. package/src/hooks/use-overflow-items.ts +256 -0
  479. package/src/index.ts +85 -0
  480. package/src/lib/README.md +82 -0
  481. package/src/lib/drag-visual.ts +272 -0
  482. package/src/lib/i18n/README.md +60 -0
  483. package/src/lib/i18n/i18n-context.tsx +129 -0
  484. package/src/lib/multi-select-ordering.ts +61 -0
  485. package/src/lib/utils.ts +93 -0
  486. package/src/patterns/README.md +67 -0
  487. package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
  488. package/src/patterns/header-canonical/chrome-header.tsx +175 -0
  489. package/src/patterns/header-canonical/header-canonical.css +27 -0
  490. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
  491. package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
  492. package/src/patterns/resize-handle/resize-handle.tsx +188 -0
  493. package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
  494. package/src/styles/preset.css +31 -0
  495. package/src/styles/tokens.css +35 -0
  496. package/src/tokens/README.md +53 -0
  497. package/src/tokens/color/primitives.css +429 -0
  498. package/src/tokens/color/semantic.css +539 -0
  499. package/src/tokens/elevation/overlay-geometry.ts +13 -0
  500. package/src/tokens/layoutSpace/layoutSpace.css +36 -0
  501. package/src/tokens/motion/motion.css +30 -0
  502. package/src/tokens/motion/motion.ts +17 -0
  503. package/src/tokens/opacity/opacity.css +23 -0
  504. package/src/tokens/radius/radius.css +19 -0
  505. package/src/tokens/typography/typography.css +118 -0
  506. package/src/tokens/uiSize/icon-size.ts +52 -0
  507. package/src/tokens/uiSize/uiSize.css +125 -0
@@ -0,0 +1,1065 @@
1
+ // @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.
2
+ // code-quality-allow: file-size — composite 拼裝(Toolbar / ZoomInput / InfoPanel / Filmstrip + Dialog shell + renderer registry);拆檔會把 useState/useEffect/key handler 跨檔同步過於複雜
3
+ import * as React from 'react'
4
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
5
+ import {
6
+ X as XIcon,
7
+ Download,
8
+ Info,
9
+ ChevronLeft,
10
+ ChevronRight,
11
+ ChevronDown,
12
+ Plus,
13
+ Minus,
14
+ File as FileIcon,
15
+ FileText,
16
+ } from 'lucide-react'
17
+ import { cn } from '@/lib/utils'
18
+ import { Button } from '@/design-system/components/Button/button'
19
+ import { Separator } from '@/design-system/components/Separator/separator'
20
+ import { Input } from '@/design-system/components/Input/input'
21
+ import { Empty } from '@/design-system/components/Empty/empty'
22
+ import { AspectRatio } from '@/design-system/components/AspectRatio/aspect-ratio'
23
+ import { Textarea } from '@/design-system/components/Textarea/textarea'
24
+ import { Field, FieldLabel } from '@/design-system/components/Field/field'
25
+ import { DescriptionList, DescriptionItem } from '@/design-system/components/DescriptionList/description-list'
26
+ import { ItemInlineActionButton } from '@/design-system/patterns/element-anatomy/item-anatomy'
27
+ import { ChromeHeader } from '@/design-system/patterns/header-canonical/chrome-header'
28
+ import { ScrollArea } from '@/design-system/components/ScrollArea/scroll-area'
29
+ import {
30
+ DropdownMenu,
31
+ DropdownMenuTrigger,
32
+ DropdownMenuContent,
33
+ DropdownMenuItem,
34
+ DropdownMenuSeparator,
35
+ } from '@/design-system/components/DropdownMenu/dropdown-menu'
36
+ import {
37
+ useScrollEdges,
38
+ useScrollByPage,
39
+ buildFadeMask,
40
+ OverflowScrollArrow,
41
+ } from '@/design-system/patterns/horizontal-overflow/horizontal-overflow'
42
+ import { ImageRenderer, canRenderImage } from './image-renderer'
43
+ import type {
44
+ FileInfo,
45
+ FileRenderer,
46
+ FileRendererCapabilities,
47
+ } from './file-viewer-types'
48
+
49
+ /**
50
+ * FileViewer — 可延伸的網頁檔案 preview shell(modal fullscreen)
51
+ *
52
+ * ── 定位 ──
53
+ * 公開、composite 元件。consumer 傳 `files`,FileViewer 處理 overlay / toolbar /
54
+ * keyboard / filmstrip / info panel 一切 chrome;檔案本體由 renderer registry
55
+ * 按 file MIME 決定誰渲染(MVP 內建 ImageRenderer + FallbackRenderer)。
56
+ *
57
+ * ── 實作基礎 ──
58
+ * 自建 composite,消費 DS primitives:
59
+ * - Radix DialogPrimitive(焦點 trap / Esc / aria-modal,保有 shadcn 結構優勢)
60
+ * - `<Empty>` / `<Button>` / `<Input variant="bare">` / `<AspectRatio>` / `<Textarea>` / `<DropdownMenu>`
61
+ * - `patterns/horizontal-overflow`(filmstrip 溢出捲動)
62
+ * 不用 DS 的 `<Dialog>` wrapper:因為 FileViewer 需要 edge-to-edge fullscreen
63
+ * (無 viewport inset / 無 rounded-lg / 無 maxWidth),Dialog 的這些預設都要覆寫。
64
+ * 直接消費 Radix primitive 讓 shell 擁有完整 layout 控制權。
65
+ *
66
+ * ── Layout Family ──
67
+ * 非 Family 1/2/3/4 — composite / multi-region(Toolbar / Viewport / Filmstrip +
68
+ * 可選 InfoPanel)。見 `file-viewer.spec.md`「Layout Family」段。
69
+ *
70
+ * ── Extensibility ──
71
+ * `registerFileRenderer(renderer)` 註冊新 renderer;shell 按註冊順序 iterate,
72
+ * 第一個 `canRender(file)` 回 true 的渲染。FallbackRenderer 永遠兜底(未知檔案
73
+ * 類型顯示 icon + 檔名 + download)。
74
+ */
75
+
76
+ // ─── Renderer Registry ────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Fallback renderer — 無 renderer 能處理時兜底。
80
+ * 顯示 Empty 佈局:icon + 檔名 + 「請下載檢視」提示。
81
+ */
82
+ const FallbackRenderer: React.FC<{ file: FileInfo }> = ({ file }) => (
83
+ <div className="w-full h-full flex items-center justify-center p-8">
84
+ <Empty
85
+ icon={FileText}
86
+ title={file.name}
87
+ description={`無法在瀏覽器中預覽此檔案類型(${file.mimeType || 'unknown'})。請下載後檢視。`}
88
+ />
89
+ </div>
90
+ )
91
+
92
+ const fallbackRenderer: FileRenderer = {
93
+ id: 'fallback',
94
+ canRender: () => true,
95
+ component: ({ file }) => <FallbackRenderer file={file} />,
96
+ }
97
+
98
+ const imageRenderer: FileRenderer = {
99
+ id: 'image',
100
+ canRender: canRenderImage,
101
+ component: ImageRenderer,
102
+ }
103
+
104
+ // Registry 是 module-singleton:新 renderer 透過 registerFileRenderer 加入。
105
+ // Fallback 永遠最後(兜底),因此用陣列第二段存放。
106
+ const userRegistered: FileRenderer[] = []
107
+
108
+ export function registerFileRenderer(renderer: FileRenderer): void {
109
+ // 去重:同 id 則覆寫
110
+ const existingIdx = userRegistered.findIndex((r) => r.id === renderer.id)
111
+ if (existingIdx >= 0) {
112
+ userRegistered[existingIdx] = renderer
113
+ } else {
114
+ userRegistered.push(renderer)
115
+ }
116
+ }
117
+
118
+ function resolveRenderer(file: FileInfo): FileRenderer {
119
+ // 先查 user registered,再 built-in,最後 fallback
120
+ for (const r of userRegistered) {
121
+ if (r.canRender(file)) return r
122
+ }
123
+ if (imageRenderer.canRender(file)) return imageRenderer
124
+ return fallbackRenderer
125
+ }
126
+
127
+ // ─── Zoom presets ─────────────────────────────────────────────────────────────
128
+
129
+ type ZoomFit = 'fit-width' | 'fit-page'
130
+
131
+ const ZOOM_PRESETS: number[] = [10, 25, 50, 75, 100, 125, 150, 200, 400]
132
+ // i18n-allow-block: DS defaults for zoom fit menu;consumer override via `labels.zoomFitOptions` (future) or fork
133
+ const ZOOM_FIT_OPTIONS: { value: ZoomFit; label: string }[] = [
134
+ { value: 'fit-width', label: 'Fit to width' },
135
+ { value: 'fit-page', label: 'Fit to page' },
136
+ ]
137
+
138
+ function nextZoomIn(current: number): number {
139
+ for (const p of ZOOM_PRESETS) {
140
+ if (p > current) return p
141
+ }
142
+ return ZOOM_PRESETS[ZOOM_PRESETS.length - 1]
143
+ }
144
+ function nextZoomOut(current: number): number {
145
+ for (let i = ZOOM_PRESETS.length - 1; i >= 0; i--) {
146
+ if (ZOOM_PRESETS[i] < current) return ZOOM_PRESETS[i]
147
+ }
148
+ return ZOOM_PRESETS[0]
149
+ }
150
+
151
+ // ─── ZoomInput ────────────────────────────────────────────────────────────────
152
+
153
+ interface ZoomInputProps {
154
+ value: number
155
+ onChange: (next: number) => void
156
+ onFit: (fit: ZoomFit) => void
157
+ labels: Pick<Required<FileViewerLabels>, 'zoomInput' | 'zoomMenu'>
158
+ }
159
+
160
+ /**
161
+ * ZoomInput — [−] [% input(bare)with ⌄ menu trigger] [+]
162
+ *
163
+ * 世界級對照:Figma zoom control / Adobe Acrobat / Google Slides zoom。
164
+ *
165
+ * ── 消費 DS primitive ──
166
+ * - `<Button>` iconOnly size="sm" 作 ±按鈕
167
+ * - `<Input variant="bare" size="sm">` 作 %輸入(Toolbar inline editing canonical)
168
+ * - Input `endAction` slot 提供 ⌄ chevron 觸發 DropdownMenu
169
+ * - `<DropdownMenu>` 作 preset + fit 選單(取代原先 Popover + 手刻 button list)
170
+ *
171
+ * ── 為什麼 inline(不抽獨立 primitive)──
172
+ * 目前只 FileViewer 消費;MVP 階段遵循 YAGNI。當 PDF / Video viewer 也需要相同
173
+ * primitive 時,再依「建立前必查既有 pattern」原則從 FileViewer 抽出升級。
174
+ */
175
+ const ZoomInput: React.FC<ZoomInputProps> = ({ value, onChange, onFit, labels }) => {
176
+ const [draft, setDraft] = React.useState<string>(`${value}%`)
177
+ const [menuOpen, setMenuOpen] = React.useState(false)
178
+
179
+ React.useEffect(() => {
180
+ setDraft(`${value}%`)
181
+ }, [value])
182
+
183
+ const commitDraft = () => {
184
+ const parsed = parseInt(draft.replace(/[^0-9]/g, ''), 10)
185
+ if (Number.isFinite(parsed) && parsed > 0) {
186
+ // 限 10–400 範圍,對齊 ImageRenderer MIN_SCALE/MAX_SCALE
187
+ const clamped = Math.min(400, Math.max(10, parsed))
188
+ onChange(clamped)
189
+ setDraft(`${clamped}%`)
190
+ } else {
191
+ setDraft(`${value}%`)
192
+ }
193
+ }
194
+
195
+ return (
196
+ // zoom group = toolbar 按鈕群組,`gap-2`(8px)對齊本 DS 按鈕 gap canonical。
197
+ <div className="inline-flex items-center gap-2">
198
+ {/* 縮小 */}
199
+ <Button
200
+ variant="text"
201
+ size="sm"
202
+ iconOnly
203
+ startIcon={Minus}
204
+ aria-label="縮小"
205
+ disabled={value <= 10}
206
+ onClick={() => onChange(nextZoomOut(value))}
207
+ />
208
+
209
+ {/* % Input + chevron 內嵌為 endSlot(ItemInlineActionButton 作 DropdownMenuTrigger):
210
+ — Input body 可自由打字(chevron 是 Input 內部 element,body 區域 click 不觸發 menu)
211
+ — Chevron 是 inline action,同時是 DropdownMenuTrigger → menu 精確 anchor 在 chevron 下方
212
+ — 靠 Radix asChild + ItemInlineActionButton:視覺是 Input + endAction,行為是 chevron-as-trigger
213
+ — 完全對齊 user AR:「只有 inline action 能觸發選單,menu 對齊 inline action」 */}
214
+ <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
215
+ <Input
216
+ size="sm"
217
+ autoWidth
218
+ aria-label={labels.zoomInput}
219
+ value={draft}
220
+ onChange={(e) => setDraft(e.target.value)}
221
+ onBlur={commitDraft}
222
+ onKeyDown={(e) => {
223
+ if (e.key === 'Enter') {
224
+ e.preventDefault()
225
+ commitDraft()
226
+ ;(e.target as HTMLInputElement).blur()
227
+ }
228
+ }}
229
+ className="text-center tabular-nums"
230
+ endSlot={
231
+ <DropdownMenuTrigger asChild>
232
+ <ItemInlineActionButton
233
+ icon={ChevronDown}
234
+ aria-label={labels.zoomMenu}
235
+ size="sm"
236
+ overlayTrigger
237
+ />
238
+ </DropdownMenuTrigger>
239
+ }
240
+ />
241
+ {/* data-theme="dark":DropdownMenuContent 走 Portal 到 document body 外,
242
+ 不繼承 FileViewer 外層 data-theme="dark",需顯式打 dark 讓選單跟 chrome 一致。
243
+ **加 bg-surface-raised 強制用 dark token**(純 data-theme attr 在 Portal 不夠,
244
+ Tailwind 條件 class + CSS variable 都要一起帶) */}
245
+ <DropdownMenuContent
246
+ align="end"
247
+ sideOffset={8}
248
+ // minWidth 對齊 trigger(Input autoWidth),menu 寬度 fit-content 更貼近觸發點視覺中心
249
+ className="min-w-[9rem] w-auto bg-surface-raised text-foreground border-divider"
250
+ data-theme="dark"
251
+ >
252
+ {/* 內層 data-theme 再覆蓋一次 — 確保 DropdownMenuItem children 都 resolve dark token */}
253
+ <div data-theme="dark" className="contents">
254
+ {ZOOM_FIT_OPTIONS.map((opt) => (
255
+ <DropdownMenuItem
256
+ key={opt.value}
257
+ onSelect={() => onFit(opt.value)}
258
+ >
259
+ {opt.label}
260
+ </DropdownMenuItem>
261
+ ))}
262
+ <DropdownMenuSeparator />
263
+ {ZOOM_PRESETS.map((p) => {
264
+ const selected = p === value
265
+ return (
266
+ <DropdownMenuItem
267
+ key={p}
268
+ onSelect={() => onChange(p)}
269
+ data-state={selected ? 'checked' : undefined}
270
+ className={cn(
271
+ 'tabular-nums',
272
+ selected && 'bg-neutral-selected',
273
+ )}
274
+ >
275
+ {p}%
276
+ </DropdownMenuItem>
277
+ )
278
+ })}
279
+ </div>
280
+ </DropdownMenuContent>
281
+ </DropdownMenu>
282
+
283
+ {/* 放大 */}
284
+ <Button
285
+ variant="text"
286
+ size="sm"
287
+ iconOnly
288
+ startIcon={Plus}
289
+ aria-label="放大"
290
+ disabled={value >= 400}
291
+ onClick={() => onChange(nextZoomIn(value))}
292
+ />
293
+ </div>
294
+ )
295
+ }
296
+ ZoomInput.displayName = 'ZoomInput'
297
+
298
+ // ─── Toolbar ──────────────────────────────────────────────────────────────────
299
+
300
+ interface ToolbarProps {
301
+ file: FileInfo
302
+ capabilities: FileRendererCapabilities
303
+ zoom: number
304
+ onZoomChange: (z: number) => void
305
+ onFit: (fit: ZoomFit) => void
306
+ infoOpen: boolean
307
+ onInfoToggle: () => void
308
+ onDownload?: () => void
309
+ allowDownload: boolean
310
+ onClose: () => void
311
+ labels: Required<FileViewerLabels>
312
+ }
313
+
314
+ const Toolbar: React.FC<ToolbarProps> = ({
315
+ file,
316
+ capabilities,
317
+ zoom,
318
+ onZoomChange,
319
+ onFit,
320
+ infoOpen,
321
+ onInfoToggle,
322
+ onDownload,
323
+ allowDownload,
324
+ onClose,
325
+ labels,
326
+ }) => {
327
+ return (
328
+ <ChromeHeader
329
+ lockDensity="lg"
330
+ className={cn(
331
+ // Chrome layer — `bg-surface-raised` 對齊 token semantic「遮蓋型浮層必須不透明」。
332
+ // FileViewer 整體是 overlay,chrome 屬其 raised surface(同 DropdownMenuContent line 244)。
333
+ // 不用 bg-surface(dark = white α8 半透明,outer 透明時失去 backdrop 洗白)。
334
+ // 不用 bg-canvas(那是「頁面最底層」semantic,chrome 不是 page)。
335
+ // ChromeHeader 自帶 flex/items-center/gap-2/shrink-0/h-chrome-header-height/border-b/px-loose
336
+ 'bg-surface-raised',
337
+ )}
338
+ >
339
+ {/* 檔名(左,佔據可用寬度,ellipsis)—— file-type icon 代表檔名的意象(這是什麼檔),
340
+ 對齊 CLAUDE.md「icon 代表 label 意象時與 label 同色」原則:icon 走 text-foreground
341
+ 不走 text-fg-muted(後者是裝飾性 / 輔助 icon 的色階) */}
342
+ <div className="flex items-center gap-2 min-w-0 flex-1">
343
+ <FileIcon size={16} className="text-foreground shrink-0" aria-hidden />
344
+ <span
345
+ className="text-body-lg text-foreground truncate"
346
+ title={file.name}
347
+ >
348
+ {file.name}
349
+ </span>
350
+ </div>
351
+
352
+ {/* 按鈕順序 canonical:zoom → info → download → close(影響力遞增)
353
+ action-bar 三分區:zoom(data op)/ info+download(action group)/ close(dismiss)
354
+ dismiss 前分隔線 = action-bar「dismiss 跟動作分群」canonical
355
+
356
+ ── gap-2 canonical(2026-04-21 follow-up)──
357
+ 按鈕間距 **8px**(gap-2),對齊 Dialog footer `gap-2` / CLAUDE 按鈕間距 SSOT。
358
+ zoom group 內部例外 gap-0.5(見 ZoomInput) — 那是「連緊 segmented pill」語意,
359
+ 跟這裡 action-group-to-action-group 的 gap-2 不同層級。 */}
360
+ <div className="flex items-center gap-2 shrink-0">
361
+ {capabilities.zoom && (
362
+ <>
363
+ {/* Zoom group:-/%/+/▼ 屬同類「縮放」操作,群組並在右側加分隔線跟其他動作分群 */}
364
+ <ZoomInput value={zoom} onChange={onZoomChange} onFit={onFit} labels={labels} />
365
+ {/* zoom group → next action group divider(action-bar canonical;v11 升級成 Separator
366
+ 元件,對齊 separator.spec.md「consumer 手動放置 toolbar 群組分隔線 = 用 Separator」)*/}
367
+ <Separator orientation="vertical" className="h-6 mx-1" />
368
+ </>
369
+ )}
370
+ <Button
371
+ variant="text"
372
+ size="sm"
373
+ iconOnly
374
+ startIcon={Info}
375
+ aria-label={infoOpen ? labels.infoToggleCollapse : labels.infoToggleExpand}
376
+ pressed={infoOpen}
377
+ onClick={onInfoToggle}
378
+ />
379
+ {allowDownload && (
380
+ <Button
381
+ variant="text"
382
+ size="sm"
383
+ iconOnly
384
+ startIcon={Download}
385
+ aria-label={labels.download}
386
+ onClick={onDownload}
387
+ />
388
+ )}
389
+ {/* action-bar canonical:dismiss 前加分隔線跟其他動作分群(info/download = action group,
390
+ close = dismiss group;v11 升級成 Separator,對齊 separator.spec.md canonical)*/}
391
+ <Separator orientation="vertical" className="h-6 mx-1" />
392
+ {/* Close X 走 dismiss canonical(`<Button iconOnly dismiss />`)——對齊 CLAUDE.md
393
+ `button.spec.md`「Dismiss 視覺類」+ `patterns/element-anatomy/item-anatomy.spec.md`
394
+ 「Dismiss canonical」:chrome corner close X = Button dismiss,不是 Inline Action。 */}
395
+ <Button
396
+ iconOnly
397
+ dismiss
398
+ size="sm"
399
+ data-dismiss
400
+ startIcon={XIcon}
401
+ aria-label={labels.close}
402
+ onClick={onClose}
403
+ />
404
+ </div>
405
+ </ChromeHeader>
406
+ )
407
+ }
408
+
409
+ // ─── InfoPanel ────────────────────────────────────────────────────────────────
410
+
411
+ interface InfoPanelProps {
412
+ file: FileInfo
413
+ readOnly: boolean
414
+ onDescriptionChange?: (fileId: string, description: string) => void
415
+ onClose: () => void
416
+ labels: Required<FileViewerLabels>
417
+ }
418
+
419
+ function formatBytes(bytes: number | undefined): string | undefined {
420
+ if (bytes == null) return undefined
421
+ if (bytes < 1024) return `${bytes} B`
422
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
423
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
424
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
425
+ }
426
+
427
+ const InfoPanel: React.FC<InfoPanelProps> = ({
428
+ file,
429
+ readOnly,
430
+ onDescriptionChange,
431
+ onClose,
432
+ labels,
433
+ }) => {
434
+ const [draft, setDraft] = React.useState(file.description ?? '')
435
+
436
+ React.useEffect(() => {
437
+ setDraft(file.description ?? '')
438
+ }, [file.id, file.description])
439
+
440
+ const commit = () => {
441
+ if (readOnly) return
442
+ if (draft !== (file.description ?? '')) {
443
+ onDescriptionChange?.(file.id, draft)
444
+ }
445
+ }
446
+
447
+ const sizeText = formatBytes(file.size)
448
+
449
+ return (
450
+ <aside
451
+ className={cn(
452
+ // Chrome — bg-surface-raised 同 Toolbar / Filmstrip(token semantic「遮蓋型浮層」)
453
+ 'w-80 shrink-0 flex flex-col bg-surface-raised border-l border-divider',
454
+ 'h-full',
455
+ )}
456
+ aria-label={labels.detailPanel}
457
+ >
458
+ {/* Panel header — 與 Toolbar 等高(consume ChromeHeader lockDensity="lg"),視覺一致 */}
459
+ <ChromeHeader lockDensity="lg" className="justify-between">
460
+ <h3 className="text-body-lg font-medium text-foreground">{labels.detailsHeading}</h3>
461
+ {/* InfoPanel close 走 dismiss canonical `<Button iconOnly dismiss />`,對齊 button.spec.md
462
+ 「Dismiss 視覺類」+ inline-action.spec.md「Dismiss canonical — X close only」。 */}
463
+ <Button
464
+ iconOnly
465
+ dismiss
466
+ size="sm"
467
+ data-dismiss
468
+ startIcon={XIcon}
469
+ aria-label={labels.detailPanelClose}
470
+ onClick={onClose}
471
+ />
472
+ </ChromeHeader>
473
+
474
+ {/* Panel body — header(shrink-0)上常駐 + body 走 ScrollArea(高度小時內容可捲動)。
475
+ padding 對齊 layoutSpace v6 規則 4「bounded region → 容器底(無 action buttons)= loose」。
476
+ gap 對齊 v6 規則 3「跨範疇 parallel = loose」(說明 vs 檔案資訊兩個獨立 functional sections,
477
+ 屬「跨範疇 + 不相關」)— 從 gap-4 寫死改為 token-aware loose。 */}
478
+ <ScrollArea className="flex-1 min-h-0">
479
+ <div className={cn(
480
+ 'flex flex-col gap-[var(--layout-space-loose)]',
481
+ 'px-[var(--layout-space-loose)]',
482
+ 'pt-[var(--layout-space-tight)] pb-[var(--layout-space-loose)]',
483
+ )}>
484
+ {/* 說明 — 用 DS Field + FieldLabel + Textarea(2026-04-20 B12 決策:
485
+ FileViewer 一律消費 DS Field 家族,不手刻 `<span>label` + raw control) */}
486
+ <Field>
487
+ <FieldLabel>說明</FieldLabel>
488
+ <Textarea
489
+ value={draft}
490
+ onChange={(e) => setDraft(e.target.value)}
491
+ onBlur={commit}
492
+ readOnly={readOnly}
493
+ placeholder={readOnly ? labels.descriptionPlaceholderReadOnly : labels.descriptionPlaceholderEdit}
494
+ rows={5}
495
+ />
496
+ </Field>
497
+
498
+ {/* 檔案資訊 — 用 DS DescriptionList horizontal + divided(Google Drive /
499
+ Notion file info panel 模式):
500
+ - section header 用 FieldLabel 同款 typography 保視覺一致
501
+ - DescriptionList direction="horizontal" divided 提供 row 下底線
502
+ 對齊格線,key 長度不一也易讀
503
+ - 不再手刻 dl/dt/dd — canonical 由 DS primitive own */}
504
+ {/* heading → first-item gap = item → item gap(Gestalt proximity,見 description-list.spec.md) */}
505
+ <div className="flex flex-col gap-[var(--layout-space-tight)]">
506
+ <span className="text-body font-normal text-foreground">{labels.fileInfoHeading}</span>
507
+ <DescriptionList direction="horizontal" divided>
508
+ <DescriptionItem label="檔名">{file.name}</DescriptionItem>
509
+ <DescriptionItem label="類型">{file.mimeType || '—'}</DescriptionItem>
510
+ {sizeText && (
511
+ <DescriptionItem label="大小">
512
+ <span className="tabular-nums">{sizeText}</span>
513
+ </DescriptionItem>
514
+ )}
515
+ {file.metadata &&
516
+ Object.entries(file.metadata).map(([k, v]) => (
517
+ <DescriptionItem key={k} label={k}>{String(v)}</DescriptionItem>
518
+ ))}
519
+ </DescriptionList>
520
+ </div>
521
+ </div>
522
+ </ScrollArea>
523
+ </aside>
524
+ )
525
+ }
526
+
527
+ // ─── Filmstrip ────────────────────────────────────────────────────────────────
528
+
529
+ interface FilmstripProps {
530
+ files: FileInfo[]
531
+ activeIndex: number
532
+ onSelect: (index: number) => void
533
+ labels: Pick<Required<FileViewerLabels>, 'filmstripLabel'>
534
+ }
535
+
536
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
537
+ const THUMB_SIZE = 64 // px, 固定
538
+
539
+ const Filmstrip: React.FC<FilmstripProps> = ({ files, activeIndex, onSelect, labels }) => {
540
+ const { scrollRef, atStart, atEnd, canScroll } = useScrollEdges<HTMLDivElement>()
541
+ const scrollByPage = useScrollByPage(scrollRef)
542
+ const maskImage = buildFadeMask({ canScroll, atStart, atEnd, reserveArrowWidth: 32 })
543
+
544
+ // 切換當前檔案時,自動 scroll 讓 active thumb 可見
545
+ React.useEffect(() => {
546
+ const el = scrollRef.current
547
+ if (!el) return
548
+ const active = el.querySelector<HTMLButtonElement>(`[data-thumb-index="${activeIndex}"]`)
549
+ active?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
550
+ }, [activeIndex, scrollRef])
551
+
552
+ return (
553
+ <div
554
+ className={cn(
555
+ // Chrome — bg-surface-raised 同 Toolbar / InfoPanel(token semantic「遮蓋型浮層」)
556
+ 'relative shrink-0 h-24 bg-surface-raised border-t border-divider',
557
+ 'flex items-center',
558
+ 'px-[var(--layout-space-loose)]',
559
+ )}
560
+ >
561
+ {canScroll && !atStart && (
562
+ <OverflowScrollArrow direction="left" onClick={() => scrollByPage('left')} />
563
+ )}
564
+ <div
565
+ ref={scrollRef}
566
+ className={cn(
567
+ 'flex items-center',
568
+ // 刻意隱藏 native scrollbar + 用 fade-mask(horizontal-overflow pattern)
569
+ 'scrollbar-none overflow-x-auto overflow-y-hidden h-full py-2',
570
+ 'w-full',
571
+ )}
572
+ style={{
573
+ maskImage,
574
+ WebkitMaskImage: maskImage,
575
+ }}
576
+ >
577
+ {/* 內層 wrapper:mx-auto 讓 thumbs 在少量時水平置中,多量溢出時 mx-auto = 0 自然轉 scroll。
578
+ gap-[var(--layout-space-tight)] 走 DS density-aware token(不用 raw gap-1)——
579
+ 世界級 idiom:Google Drive / Dropbox / Notion file preview 的 filmstrip 都是
580
+ 少量置中 / 多量靠 start scroll。
581
+ role="tablist" 擺在 tabs 的直接父元件,符合 ARIA tab pattern 語意。 */}
582
+ <div
583
+ role="tablist"
584
+ aria-label={labels.filmstripLabel}
585
+ className="flex items-center gap-[var(--layout-space-tight)] mx-auto shrink-0"
586
+ >
587
+ {files.map((file, i) => {
588
+ const active = i === activeIndex
589
+ const isImage = canRenderImage(file)
590
+ const ext = file.name.split('.').pop()?.toUpperCase() ?? '檔'
591
+ return (
592
+ <button
593
+ key={file.id}
594
+ type="button"
595
+ role="tab"
596
+ aria-selected={active}
597
+ aria-label={`${i + 1} / ${files.length}:${file.name}`}
598
+ data-thumb-index={i}
599
+ onClick={() => onSelect(i)}
600
+ className={cn(
601
+ 'shrink-0 rounded-md bg-muted overflow-hidden',
602
+ 'outline-none focus-visible:ring-2 focus-visible:ring-ring',
603
+ 'transition-shadow duration-150',
604
+ active
605
+ ? 'ring-2 ring-primary'
606
+ : 'ring-1 ring-border hover:ring-border-hover',
607
+ )}
608
+ style={{ width: THUMB_SIZE, height: THUMB_SIZE }}
609
+ >
610
+ <AspectRatio ratio={1} className="w-full h-full">
611
+ {isImage ? (
612
+ <img
613
+ src={file.url}
614
+ alt=""
615
+ aria-hidden
616
+ className="w-full h-full object-cover"
617
+ draggable={false}
618
+ />
619
+ ) : (
620
+ <div className="w-full h-full flex flex-col items-center justify-center gap-0.5">
621
+ <FileText size={20} className="text-fg-muted" aria-hidden />
622
+ <span className="text-footnote text-fg-muted font-medium">{ext}</span>
623
+ </div>
624
+ )}
625
+ </AspectRatio>
626
+ </button>
627
+ )
628
+ })}
629
+ </div>
630
+ </div>
631
+ {canScroll && !atEnd && (
632
+ <OverflowScrollArrow direction="right" onClick={() => scrollByPage('right')} />
633
+ )}
634
+ </div>
635
+ )
636
+ }
637
+
638
+ // ─── FileViewer (shell) ───────────────────────────────────────────────────────
639
+
640
+ /**
641
+ * i18n-able labels for FileViewer chrome / controls.
642
+ * All keys are optional — defaults are CJK (see `DEFAULT_LABELS`).
643
+ * Consumer typically spreads partial override:
644
+ * `<FileViewer labels={{ close: 'Close', download: 'Download' }} />`
645
+ */
646
+ // code-quality-allow: dead-export — public API surface — consumer-exposed for future use
647
+ export interface FileViewerLabels {
648
+ /** Zoom input ARIA label */
649
+ zoomInput?: string
650
+ /** Zoom menu trigger ARIA label */
651
+ zoomMenu?: string
652
+ /** Info panel toggle button — shown when panel is OPEN */
653
+ infoToggleCollapse?: string
654
+ /** Info panel toggle button — shown when panel is CLOSED */
655
+ infoToggleExpand?: string
656
+ /** Download button ARIA label */
657
+ download?: string
658
+ /** Close viewer button ARIA label */
659
+ close?: string
660
+ /** InfoPanel outer aside ARIA label */
661
+ detailPanel?: string
662
+ /** InfoPanel heading text */
663
+ detailsHeading?: string
664
+ /** InfoPanel close button ARIA label */
665
+ detailPanelClose?: string
666
+ /** Description textarea placeholder (readOnly) */
667
+ descriptionPlaceholderReadOnly?: string
668
+ /** Description textarea placeholder (editable) */
669
+ descriptionPlaceholderEdit?: string
670
+ /** Detail section — file info section heading */
671
+ fileInfoHeading?: string
672
+ /** Filmstrip tablist ARIA label */
673
+ filmstripLabel?: string
674
+ /** Previous-file nav button ARIA label */
675
+ previousFile?: string
676
+ /** Next-file nav button ARIA label */
677
+ nextFile?: string
678
+ }
679
+
680
+ // i18n-allow: DS defaults;consumer override via `labels` prop
681
+ const DEFAULT_LABELS: Required<FileViewerLabels> = {
682
+ zoomInput: '縮放比例',
683
+ zoomMenu: '開啟縮放選單',
684
+ infoToggleCollapse: '收合詳細資訊面板',
685
+ infoToggleExpand: '展開詳細資訊面板',
686
+ download: '下載檔案',
687
+ close: '關閉檢視器',
688
+ detailPanel: '檔案詳細資訊',
689
+ detailsHeading: '詳細資訊',
690
+ detailPanelClose: '關閉詳細資訊',
691
+ descriptionPlaceholderReadOnly: '尚無說明',
692
+ descriptionPlaceholderEdit: '為這個檔案加上說明…',
693
+ fileInfoHeading: '檔案資訊',
694
+ filmstripLabel: '檔案佇列',
695
+ previousFile: '上一個檔案',
696
+ nextFile: '下一個檔案',
697
+ }
698
+
699
+ export interface FileViewerProps
700
+ extends Omit<
701
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
702
+ 'onOpenChange'
703
+ > {
704
+ files: FileInfo[]
705
+ initialIndex?: number
706
+ /** Controlled open state。與 `defaultOpen` 二擇一。 */
707
+ open?: boolean
708
+ /** Uncontrolled open 預設(2026-04-25 加,對齊 Dialog/Sheet/Popover dual-mode canonical)。 */
709
+ defaultOpen?: boolean
710
+ onOpenChange?: (open: boolean) => void
711
+ /** 當前索引(controlled);consumer 想自己控制 active file 時傳。不傳則 shell 管理。 */
712
+ index?: number
713
+ onIndexChange?: (index: number) => void
714
+ /** 當前檔案 description 變化。consumer 負責持久化。readOnly 為 true 時不觸發。 */
715
+ onDescriptionChange?: (fileId: string, description: string) => void
716
+ /** true → InfoPanel 的 description textarea 為 readOnly。預設 false。 */
717
+ readOnly?: boolean
718
+ /** 顯示底部 filmstrip。預設 false;files.length < 2 時自動隱藏。 */
719
+ showFilmstrip?: boolean
720
+ /** 是否提供 download 按鈕。預設 true。 */
721
+ allowDownload?: boolean
722
+ /** 自訂 download 行為;未傳則用 anchor download attribute。 */
723
+ onDownload?: (file: FileInfo) => void
724
+ /** i18n labels override. Partial — merged with DS defaults. */
725
+ labels?: FileViewerLabels
726
+ }
727
+
728
+ const FileViewer = React.forwardRef<HTMLDivElement, FileViewerProps>(function FileViewer({
729
+ files,
730
+ initialIndex = 0,
731
+ open,
732
+ defaultOpen,
733
+ onOpenChange,
734
+ index: indexProp,
735
+ onIndexChange,
736
+ onDescriptionChange,
737
+ readOnly = false,
738
+ showFilmstrip = false,
739
+ allowDownload = true,
740
+ onDownload,
741
+ labels: labelsOverride,
742
+ ...props
743
+ }, ref) {
744
+ const labels = React.useMemo(
745
+ () => ({ ...DEFAULT_LABELS, ...labelsOverride }) satisfies Required<FileViewerLabels>,
746
+ [labelsOverride],
747
+ )
748
+ // Index:uncontrolled fallback
749
+ const [internalIndex, setInternalIndex] = React.useState(initialIndex)
750
+ const activeIndex = indexProp ?? internalIndex
751
+
752
+ const setIndex = React.useCallback(
753
+ (next: number) => {
754
+ const clamped = Math.max(0, Math.min(files.length - 1, next))
755
+ if (indexProp === undefined) setInternalIndex(clamped)
756
+ onIndexChange?.(clamped)
757
+ },
758
+ [files.length, indexProp, onIndexChange],
759
+ )
760
+
761
+ // 開啟時若 uncontrolled,重置為 initialIndex
762
+ React.useEffect(() => {
763
+ if (open && indexProp === undefined) {
764
+ setInternalIndex(Math.max(0, Math.min(files.length - 1, initialIndex)))
765
+ }
766
+ }, [open, initialIndex, files.length, indexProp])
767
+
768
+ // Info panel open state(shell own)
769
+ const [infoOpen, setInfoOpen] = React.useState(false)
770
+
771
+ // Zoom state(shell own,renderer 消費 + 回報)
772
+ const [zoom, setZoom] = React.useState(100)
773
+ // 切換檔案時不再 setZoom(100)— 把「下一張該怎麼初始化 zoom」的決定權交給 renderer:
774
+ // image-renderer 自己 watch file.url change → reset loaded → onLoad → 重 fit-page。
775
+ // 原本 setZoom(100) 在 cache 命中(onLoad 沒 fire)時會卡 100% 不 fit(user 抓的 bug)。
776
+
777
+ // Fit request(shell → renderer 指令;nonce 遞增讓重複同 fit 也觸發 renderer)
778
+ const [fitRequest, setFitRequest] = React.useState<{ fit: ZoomFit; nonce: number } | null>(null)
779
+
780
+ // Renderer capabilities(mount 時 renderer emit)
781
+ const [capabilities, setCapabilities] = React.useState<FileRendererCapabilities>({
782
+ zoom: false,
783
+ })
784
+
785
+ const file = files[activeIndex]
786
+ const Renderer = file ? resolveRenderer(file) : null
787
+
788
+ // Fit-to-* 下指令給 renderer,renderer 算 container/image 比例後透過 onZoomChange 回報
789
+ const handleFit = React.useCallback((fit: ZoomFit) => {
790
+ setFitRequest((prev) => ({ fit, nonce: (prev?.nonce ?? 0) + 1 }))
791
+ }, [])
792
+
793
+ // Download handler
794
+ const handleDownload = React.useCallback(() => {
795
+ if (!file) return
796
+ if (onDownload) {
797
+ onDownload(file)
798
+ return
799
+ }
800
+ // 預設:anchor download(同源檔案有效;跨域由 consumer 提供 onDownload)
801
+ const a = document.createElement('a')
802
+ a.href = file.url
803
+ a.download = file.name
804
+ a.target = '_blank'
805
+ a.rel = 'noopener'
806
+ document.body.appendChild(a)
807
+ a.click()
808
+ document.body.removeChild(a)
809
+ }, [file, onDownload])
810
+
811
+ // Keyboard shortcuts(focus 在 input / textarea 時不觸發)
812
+ React.useEffect(() => {
813
+ if (!open) return
814
+ const handler = (e: KeyboardEvent) => {
815
+ const target = e.target as HTMLElement | null
816
+ const tag = target?.tagName
817
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return
818
+
819
+ if (e.key === 'ArrowLeft' && files.length > 1) {
820
+ e.preventDefault()
821
+ setIndex(activeIndex - 1)
822
+ } else if (e.key === 'ArrowRight' && files.length > 1) {
823
+ e.preventDefault()
824
+ setIndex(activeIndex + 1)
825
+ } else if (e.key === '+' || e.key === '=') {
826
+ if (capabilities.zoom) {
827
+ e.preventDefault()
828
+ setZoom((z) => nextZoomIn(z))
829
+ }
830
+ } else if (e.key === '-') {
831
+ if (capabilities.zoom) {
832
+ e.preventDefault()
833
+ setZoom((z) => nextZoomOut(z))
834
+ }
835
+ } else if (e.key === '0') {
836
+ if (capabilities.zoom) {
837
+ e.preventDefault()
838
+ setZoom(100)
839
+ }
840
+ } else if (e.key === 'f' || e.key === 'F') {
841
+ if (capabilities.zoom) {
842
+ e.preventDefault()
843
+ handleFit('fit-page')
844
+ }
845
+ } else if (e.key === 'i' || e.key === 'I') {
846
+ e.preventDefault()
847
+ setInfoOpen((o) => !o)
848
+ }
849
+ }
850
+ window.addEventListener('keydown', handler)
851
+ return () => window.removeEventListener('keydown', handler)
852
+ }, [open, activeIndex, files.length, setIndex, capabilities.zoom, handleFit])
853
+
854
+ // Arrows idle auto-hide(世界級 lightbox canonical:Google Photos / Dropbox / PhotoSwipe)
855
+ // 滑鼠移入 viewport → 顯示箭頭;持續 2.5 秒無 mouse move → 自動淡出(對齊世界級行為)
856
+ const [armVisible, setArmVisible] = React.useState(false)
857
+ const idleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
858
+ const handleViewportMouseMove = React.useCallback(() => {
859
+ setArmVisible(true)
860
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
861
+ idleTimerRef.current = setTimeout(() => setArmVisible(false), 2500)
862
+ }, [])
863
+ const handleViewportMouseLeave = React.useCallback(() => {
864
+ setArmVisible(false)
865
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
866
+ }, [])
867
+ React.useEffect(() => () => {
868
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
869
+ }, [])
870
+
871
+ if (!file || !Renderer) {
872
+ // files 為空或 index 超界 — 不渲染
873
+ return null
874
+ }
875
+
876
+ const showFilmstripResolved = showFilmstrip && files.length > 1
877
+ const showArrows = files.length > 1
878
+
879
+ return (
880
+ <DialogPrimitive.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
881
+ <DialogPrimitive.Portal>
882
+ {/* Overlay — FileViewer 固定深色氛圍,與 Dialog 共用 bg-overlay。
883
+ **data-theme="dark"**(2026-04-30):Overlay 在 Portal 內、是 Content 的 sibling,
884
+ 不繼承 Content 內層的 dark 主題 → `--overlay` 默認 resolve 成 light theme α45 黑。
885
+ FileViewer 永遠 dark(line 899 outer),mask 也須 dark token = α65 黑(更深)
886
+ 才語意一致。同 DropdownMenuContent Portal 處理(line 245)。 */}
887
+ <DialogPrimitive.Overlay
888
+ data-theme="dark"
889
+ className={cn(
890
+ 'fixed inset-0 z-50 bg-overlay',
891
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
892
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
893
+ )}
894
+ />
895
+ <DialogPrimitive.Content
896
+ ref={ref}
897
+ className={cn(
898
+ // Edge-to-edge fullscreen,無 inset / 無 radius(與一般 Dialog 差別的所在)
899
+ 'fixed inset-0 z-50 outline-none',
900
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
901
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
902
+ )}
903
+ // 避免 Radix 自動把焦點送進 Content 的第一個 tabbable —— 我們要留給 viewport
904
+ onOpenAutoFocus={(e) => e.preventDefault()}
905
+ {...props}
906
+ >
907
+ {/* 鎖 dark subtree。Density 繼承 page(不另設 data-density)。
908
+ header 高度透過 `--chrome-header-height` 自動 density-aware(md=48 / lg=56)。
909
+ ── Q1 mask 透明度(2026-04-30)──
910
+ outer **不**設 bg → Overlay(`bg-overlay` α45/α65)透出 image 周圍區域,
911
+ 對齊 Notion / Dropbox / Slack lightbox idiom 跟 Dialog mask 同 token 一致。
912
+ chrome(Toolbar / Filmstrip / InfoPanel)各自 `bg-surface-raised` opaque dark
913
+ (對齊 Apple Photos / Drive lightbox 派 — chrome opaque vs mask 半透明,
914
+ 清楚區分 backdrop click 區 vs 互動區)。
915
+ **不**用 bg-surface — dark mode `--surface = white α8` 半透明,outer 透明時
916
+ 無 dark backdrop 撐 → 視覺洗白。 */}
917
+ <div
918
+ data-theme="dark"
919
+ className="w-full h-full flex flex-col text-foreground"
920
+ >
921
+ {/* Accessible title — 視覺隱藏但 screen reader 讀得到 */}
922
+ <DialogPrimitive.Title className="sr-only">
923
+ 檔案檢視器:{file.name}
924
+ </DialogPrimitive.Title>
925
+
926
+ <Toolbar
927
+ file={file}
928
+ capabilities={capabilities}
929
+ zoom={zoom}
930
+ onZoomChange={setZoom}
931
+ onFit={handleFit}
932
+ infoOpen={infoOpen}
933
+ onInfoToggle={() => setInfoOpen((o) => !o)}
934
+ onDownload={handleDownload}
935
+ allowDownload={allowDownload}
936
+ onClose={() => onOpenChange?.(false)}
937
+ labels={labels}
938
+ />
939
+
940
+ {/* 主區:Viewport + 可選 InfoPanel(右側)
941
+ Arrows visibility = armVisible(state)控制:mouse move 顯示 / 2.5s idle 隱藏 / mouse leave 立即隱藏
942
+ 對齊 Google Photos / Dropbox lightbox / PhotoSwipe world-class canonical */}
943
+ <div className="flex-1 min-h-0 flex">
944
+ <div
945
+ className="relative flex-1 min-w-0"
946
+ onMouseMove={handleViewportMouseMove}
947
+ onMouseLeave={handleViewportMouseLeave}
948
+ // Backdrop click-to-close(對齊 Google Drive / Dropbox lightbox / Apple Photos canonical):
949
+ // 點擊 image 周圍的暗色 backdrop 區關閉,跟 modal mask 同 idiom。
950
+ //
951
+ // 為何 geometric check 而非 closest('img')?
952
+ // react-zoom-pan-pinch 的 TransformComponent 是 wrapper div 蓋在 image 之上(absorb
953
+ // pan/zoom events),click target 是該 wrapper div 不是 <img>。closest('img') 檢查
954
+ // ancestor 永遠 false。改 geometric check:看 click 座標是否落在 <img> 視覺 rect 內。
955
+ onClick={(e) => {
956
+ const t = e.target as HTMLElement
957
+ // 互動元素(side arrows / chrome buttons 透過冒泡)→ 不關
958
+ if (t.closest('button, [role="button"]')) return
959
+ // 點到 image 視覺範圍 → 不關(image 本體 click ≠ close)
960
+ const img = e.currentTarget.querySelector('img')
961
+ if (img) {
962
+ const r = img.getBoundingClientRect()
963
+ if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return
964
+ }
965
+ // 否則 = 點到 backdrop(image-renderer TransformComponent 透出的 bg-canvas)→ close
966
+ onOpenChange?.(false)
967
+ }}
968
+ >
969
+ {showArrows && activeIndex > 0 && (
970
+ <div
971
+ className={cn(
972
+ 'absolute left-[var(--layout-space-loose)] top-1/2 -translate-y-1/2 z-10',
973
+ 'transition-opacity duration-150',
974
+ // armVisible state 控制,或 focus-within 時 a11y 強制顯示
975
+ armVisible ? 'opacity-100' : 'opacity-0 pointer-events-none',
976
+ 'focus-within:opacity-100 focus-within:pointer-events-auto',
977
+ )}
978
+ >
979
+ <Button
980
+ variant="text"
981
+ size="md"
982
+ iconOnly
983
+ startIcon={ChevronLeft}
984
+ aria-label={labels.previousFile}
985
+ onClick={() => setIndex(activeIndex - 1)}
986
+ />
987
+ </div>
988
+ )}
989
+ <div className="w-full h-full">
990
+ <Renderer.component
991
+ file={file}
992
+ zoom={zoom}
993
+ onZoomChange={setZoom}
994
+ fitRequest={fitRequest}
995
+ onCapabilitiesChange={setCapabilities}
996
+ />
997
+ </div>
998
+ {showArrows && activeIndex < files.length - 1 && (
999
+ <div
1000
+ className={cn(
1001
+ 'absolute right-[var(--layout-space-loose)] top-1/2 -translate-y-1/2 z-10',
1002
+ 'transition-opacity duration-150',
1003
+ armVisible ? 'opacity-100' : 'opacity-0 pointer-events-none',
1004
+ 'focus-within:opacity-100 focus-within:pointer-events-auto',
1005
+ )}
1006
+ >
1007
+ <Button
1008
+ variant="text"
1009
+ size="md"
1010
+ iconOnly
1011
+ startIcon={ChevronRight}
1012
+ aria-label={labels.nextFile}
1013
+ onClick={() => setIndex(activeIndex + 1)}
1014
+ />
1015
+ </div>
1016
+ )}
1017
+ </div>
1018
+ {infoOpen && (
1019
+ <InfoPanel
1020
+ file={file}
1021
+ readOnly={readOnly}
1022
+ onDescriptionChange={onDescriptionChange}
1023
+ onClose={() => setInfoOpen(false)}
1024
+ labels={labels}
1025
+ />
1026
+ )}
1027
+ </div>
1028
+
1029
+ {showFilmstripResolved && (
1030
+ <Filmstrip
1031
+ files={files}
1032
+ activeIndex={activeIndex}
1033
+ onSelect={setIndex}
1034
+ labels={labels}
1035
+ />
1036
+ )}
1037
+ </div>
1038
+ </DialogPrimitive.Content>
1039
+ </DialogPrimitive.Portal>
1040
+ </DialogPrimitive.Root>
1041
+ )
1042
+ })
1043
+ FileViewer.displayName = 'FileViewer'
1044
+
1045
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
1046
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
1047
+ export const fileViewerMeta = {
1048
+ component: 'FileViewer',
1049
+ family: null, // non-family composite / overlay / layout
1050
+ variants: {
1051
+
1052
+ },
1053
+ sizes: {
1054
+
1055
+ },
1056
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
1057
+ tokens: {
1058
+ bg: ['bg-muted', 'bg-surface', 'bg-surface-raised'],
1059
+ fg: ['text-fg-muted', 'text-foreground'],
1060
+ ring: ['ring-primary', 'ring-ring'],
1061
+ },
1062
+ } as const
1063
+
1064
+ export { FileViewer }
1065
+ export type { FileInfo, FileRenderer, FileRendererCapabilities, FileRendererProps } from './file-viewer-types'