@qijenchen/design-system 0.1.0-beta.3

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 (119) hide show
  1. package/package.json +93 -0
  2. package/src/README.md +32 -0
  3. package/src/components/Accordion/accordion.tsx +104 -0
  4. package/src/components/Alert/alert.tsx +188 -0
  5. package/src/components/AppShell/_demo-helpers.tsx +198 -0
  6. package/src/components/AppShell/app-shell.tsx +364 -0
  7. package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
  8. package/src/components/Avatar/avatar.tsx +368 -0
  9. package/src/components/Badge/badge.tsx +104 -0
  10. package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
  11. package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
  12. package/src/components/Button/button-group.tsx +96 -0
  13. package/src/components/Button/button.tsx +539 -0
  14. package/src/components/Calendar/calendar.tsx +411 -0
  15. package/src/components/Carousel/carousel.tsx +371 -0
  16. package/src/components/Chart/chart.tsx +376 -0
  17. package/src/components/Checkbox/checkbox-group.tsx +94 -0
  18. package/src/components/Checkbox/checkbox.tsx +237 -0
  19. package/src/components/Chip/chip.tsx +359 -0
  20. package/src/components/CircularProgress/circular-progress.tsx +204 -0
  21. package/src/components/Coachmark/coachmark.tsx +255 -0
  22. package/src/components/Combobox/combobox.tsx +826 -0
  23. package/src/components/Command/command.tsx +187 -0
  24. package/src/components/DataTable/active-editor-controller.ts +72 -0
  25. package/src/components/DataTable/cell-registry.tsx +520 -0
  26. package/src/components/DataTable/column-types.ts +180 -0
  27. package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
  28. package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
  29. package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
  30. package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
  31. package/src/components/DataTable/data-table.css +165 -0
  32. package/src/components/DataTable/data-table.tsx +2924 -0
  33. package/src/components/DataTable/filter-operators.ts +225 -0
  34. package/src/components/DataTable/filter-tree.ts +313 -0
  35. package/src/components/DataTable/lib/column-meta.ts +79 -0
  36. package/src/components/DateGrid/date-grid.tsx +209 -0
  37. package/src/components/DatePicker/date-picker.tsx +1114 -0
  38. package/src/components/DescriptionList/description-list.tsx +141 -0
  39. package/src/components/Dialog/dialog.tsx +267 -0
  40. package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
  41. package/src/components/Empty/empty.tsx +108 -0
  42. package/src/components/Field/field-context.ts +136 -0
  43. package/src/components/Field/field-types.ts +52 -0
  44. package/src/components/Field/field-wrapper.tsx +348 -0
  45. package/src/components/Field/field.tsx +535 -0
  46. package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
  47. package/src/components/FileItem/file-item.tsx +322 -0
  48. package/src/components/FileUpload/file-upload.tsx +326 -0
  49. package/src/components/FileViewer/file-viewer-types.ts +76 -0
  50. package/src/components/FileViewer/file-viewer.tsx +1065 -0
  51. package/src/components/FileViewer/image-renderer.tsx +256 -0
  52. package/src/components/HoverCard/hover-card.tsx +79 -0
  53. package/src/components/Input/input.tsx +233 -0
  54. package/src/components/LinkInput/link-input.tsx +304 -0
  55. package/src/components/Menu/menu-item.tsx +334 -0
  56. package/src/components/NameCard/name-card.tsx +319 -0
  57. package/src/components/Notice/notice.tsx +196 -0
  58. package/src/components/NumberInput/number-input.tsx +203 -0
  59. package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
  60. package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
  61. package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
  62. package/src/components/PeoplePicker/people-picker.tsx +455 -0
  63. package/src/components/PeoplePicker/person-display.tsx +358 -0
  64. package/src/components/Popover/popover.tsx +183 -0
  65. package/src/components/ProgressBar/progress-bar.tsx +157 -0
  66. package/src/components/README.md +58 -0
  67. package/src/components/RadioGroup/radio-group.tsx +261 -0
  68. package/src/components/Rating/rating.tsx +295 -0
  69. package/src/components/ScrollArea/scroll-area.tsx +110 -0
  70. package/src/components/SegmentedControl/segmented-control.tsx +304 -0
  71. package/src/components/Select/select.tsx +658 -0
  72. package/src/components/SelectMenu/select-menu.tsx +430 -0
  73. package/src/components/SelectionControl/selection-item.tsx +261 -0
  74. package/src/components/Separator/separator.tsx +48 -0
  75. package/src/components/Sheet/sheet.tsx +240 -0
  76. package/src/components/Sidebar/sidebar.tsx +1280 -0
  77. package/src/components/Skeleton/skeleton.tsx +35 -0
  78. package/src/components/Slider/slider.tsx +158 -0
  79. package/src/components/Steps/steps.tsx +850 -0
  80. package/src/components/Switch/switch.tsx +285 -0
  81. package/src/components/Tabs/tabs.tsx +515 -0
  82. package/src/components/Tag/tag.tsx +246 -0
  83. package/src/components/Textarea/textarea.tsx +280 -0
  84. package/src/components/TimePicker/time-columns.tsx +260 -0
  85. package/src/components/TimePicker/time-picker.tsx +419 -0
  86. package/src/components/Toast/toast.tsx +129 -0
  87. package/src/components/Tooltip/tooltip.tsx +68 -0
  88. package/src/components/TreeView/tree-view.tsx +1031 -0
  89. package/src/hooks/use-controllable.ts +40 -0
  90. package/src/hooks/use-is-narrow-viewport.ts +19 -0
  91. package/src/hooks/use-is-touch-device.ts +21 -0
  92. package/src/hooks/use-overflow-items.ts +256 -0
  93. package/src/index.ts +85 -0
  94. package/src/lib/README.md +82 -0
  95. package/src/lib/drag-visual.ts +272 -0
  96. package/src/lib/i18n/README.md +60 -0
  97. package/src/lib/i18n/i18n-context.tsx +129 -0
  98. package/src/lib/multi-select-ordering.ts +61 -0
  99. package/src/lib/utils.ts +93 -0
  100. package/src/patterns/README.md +67 -0
  101. package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
  102. package/src/patterns/header-canonical/chrome-header.tsx +175 -0
  103. package/src/patterns/header-canonical/header-canonical.css +27 -0
  104. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
  105. package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
  106. package/src/patterns/resize-handle/resize-handle.tsx +188 -0
  107. package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
  108. package/src/tokens/README.md +53 -0
  109. package/src/tokens/color/primitives.css +429 -0
  110. package/src/tokens/color/semantic.css +539 -0
  111. package/src/tokens/elevation/overlay-geometry.ts +13 -0
  112. package/src/tokens/layoutSpace/layoutSpace.css +36 -0
  113. package/src/tokens/motion/motion.css +30 -0
  114. package/src/tokens/motion/motion.ts +17 -0
  115. package/src/tokens/opacity/opacity.css +23 -0
  116. package/src/tokens/radius/radius.css +19 -0
  117. package/src/tokens/typography/typography.css +118 -0
  118. package/src/tokens/uiSize/icon-size.ts +52 -0
  119. package/src/tokens/uiSize/uiSize.css +125 -0
@@ -0,0 +1,256 @@
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
+ import * as React from 'react'
3
+ import {
4
+ TransformWrapper,
5
+ TransformComponent,
6
+ type ReactZoomPanPinchRef,
7
+ } from 'react-zoom-pan-pinch'
8
+ import type { FileRendererProps } from './file-viewer-types'
9
+
10
+ /**
11
+ * ImageRenderer — FileViewer 的圖片 renderer。
12
+ *
13
+ * ── 世界級 zoom semantic canonical(2026-04-21 重寫)──
14
+ * Figma / Preview.app / Adobe Acrobat / Google Drive 共通:
15
+ * - `100%` = image natural pixel size(**非** CSS contain-scaled)
16
+ * - 開圖預設 fit-to-page(image 自動 fit,zoom input 顯示 fit % 如 40%)
17
+ * - `fit-to-width` = image width 填滿 container width(portrait 會 overflow 垂直)
18
+ * - `fit-to-page` = image 完整可見(contain semantic)
19
+ * - `+/-` preset 改 zoom 對應 natural 倍率,精準
20
+ *
21
+ * ── 實作細節 ──
22
+ * image 不走 CSS `object-contain`(那會 pre-scale,導致 transform.scale 解讀錯誤);
23
+ * 改走 **natural size + transform scale 管實際顯示**。onLoad 時算 fit-page scale
24
+ * 再 `onZoomChange(fitPct)` 將 UI zoom 同步到真實倍率。
25
+ *
26
+ * ── 為什麼消費 react-zoom-pan-pinch ──
27
+ * Zoom + pan 是行為 primitive;自寫 pinch / wheel 踩大量 edge case
28
+ * (trackpad vs mouse / momentum / bounds),library 是 canonical 解法
29
+ * (世界級 Figma Community / Miro embed / PhotoSwipe 同類流派)。
30
+ */
31
+
32
+ const MIN_SCALE = 0.1 // 10%
33
+ const MAX_SCALE = 4.0 // 400%
34
+
35
+ type FitMode = 'fit-width' | 'fit-page'
36
+
37
+ export const ImageRenderer: React.FC<FileRendererProps> = ({
38
+ file,
39
+ zoom,
40
+ onZoomChange,
41
+ fitRequest,
42
+ onCapabilitiesChange,
43
+ }) => {
44
+ const apiRef = React.useRef<ReactZoomPanPinchRef | null>(null)
45
+ const imgRef = React.useRef<HTMLImageElement | null>(null)
46
+ const containerRef = React.useRef<HTMLDivElement | null>(null)
47
+ const [loaded, setLoaded] = React.useState(false)
48
+ // 2026-05-16 Round 5 codex audit fix:capture image-load rAF ID + cancel on unmount
49
+ // (原 uncancelled rAF 可能 unmount 後 fire onZoomChange → setState 後 component 已 gone)
50
+ const handleImageLoadRafIdRef = React.useRef<number>(0)
51
+ React.useEffect(() => () => { if (handleImageLoadRafIdRef.current) cancelAnimationFrame(handleImageLoadRafIdRef.current) }, [])
52
+ // Q2 RWD:track「user 最後一次的 zoom 意圖」— fit-page / fit-width 自動 reflow,manual 不動。
53
+ // 對齊 Apple Photos / Drive canonical:resize 時若 user 在 fit mode → recompute,manual zoom → 維持。
54
+ const lastFitModeRef = React.useRef<FitMode | 'manual'>('fit-page')
55
+ // 區分「user wheel/pinch」vs「programmatic centerView」— lib onTransform 兩者都觸發,
56
+ // 用 flag 防 programmatic 的 onTransform 誤標 mode = manual。
57
+ const programmaticZoomRef = React.useRef(false)
58
+
59
+ // 宣告 capability — shell 用此決定 toolbar 內容。
60
+ React.useEffect(() => {
61
+ onCapabilitiesChange({ zoom: true })
62
+ }, [onCapabilitiesChange])
63
+
64
+ // file.url 切換 → reset state,等 onLoad 重 fit。
65
+ // 原本 bug:cache 命中時 onLoad 不 fire → handleImageLoad 不跑 → zoom 卡上一張的值
66
+ // (或 shell 設的 100%)→ user 看到「同一張圖每次切過來尺寸不一致」。
67
+ React.useEffect(() => {
68
+ setLoaded(false)
69
+ lastFitModeRef.current = 'fit-page'
70
+ // cache 命中(<img complete>)→ onLoad 可能不 fire,直接觸發 handleImageLoad 邏輯
71
+ const img = imgRef.current
72
+ if (img && img.complete && img.naturalWidth > 0) {
73
+ // 等下一個 microtask,確保 ref / state 都到位
74
+ Promise.resolve().then(() => handleImageLoad())
75
+ }
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [file.url])
78
+
79
+ // 算 fit scale(container 寬高 / image natural 寬高)
80
+ const computeFitScale = React.useCallback((fit: FitMode): number | null => {
81
+ const img = imgRef.current
82
+ const container = containerRef.current
83
+ if (!img || !container) return null
84
+ if (!img.naturalWidth || !img.naturalHeight) return null
85
+ const cw = container.clientWidth
86
+ const ch = container.clientHeight
87
+ if (cw <= 0 || ch <= 0) return null
88
+ const widthRatio = cw / img.naturalWidth
89
+ const heightRatio = ch / img.naturalHeight
90
+ // fit-width = 寬填滿;fit-page = 完整可見(取較小 scale)
91
+ return fit === 'fit-width' ? widthRatio : Math.min(widthRatio, heightRatio)
92
+ }, [])
93
+
94
+ const clampToPct = React.useCallback((scale: number): number => {
95
+ const clamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
96
+ // Floor 而非 round:fit-to-page / fit-to-width 時 scale 是 float(e.g. 0.8356)。
97
+ // round(0.8356 * 100) = 84 → 實際 scale 0.84 → image 比 canvas 大 4px,垂直溢出破壞
98
+ // 對稱置中(user 抓:「上下邊距不對稱」)。Floor → 0.83 → image 比 canvas 小,永遠
99
+ // 完整可見 + 視覺 symmetric padding。代價是最多 ~1% 的空間餘量,視覺幾乎看不出。
100
+ return Math.floor(clamped * 100)
101
+ }, [])
102
+
103
+ // Image onLoad → 自動 fit-to-page(世界級開圖預設)
104
+ const handleImageLoad = React.useCallback(() => {
105
+ setLoaded(true)
106
+ const scale = computeFitScale('fit-page')
107
+ if (scale == null) return
108
+ const pct = clampToPct(scale)
109
+ lastFitModeRef.current = 'fit-page'
110
+ // 等 transform 就緒再更新(避免 initialScale=1 → fit 過程跳兩段)
111
+ if (handleImageLoadRafIdRef.current) cancelAnimationFrame(handleImageLoadRafIdRef.current)
112
+ handleImageLoadRafIdRef.current = requestAnimationFrame(() => {
113
+ handleImageLoadRafIdRef.current = 0
114
+ onZoomChange(pct)
115
+ })
116
+ }, [computeFitScale, clampToPct, onZoomChange])
117
+
118
+ // Q2 RWD:container resize 時若在 fit mode 重算 — 對齊 Apple Photos / Drive canonical
119
+ // rAF debounce:drag window edge 期間 ResizeObserver 連續 fire 數十次,
120
+ // 合併到下一 frame 只觸發一次 → 避免 race / 過多 centerView animation 互相打斷。
121
+ React.useEffect(() => {
122
+ if (!loaded) return
123
+ const container = containerRef.current
124
+ if (!container) return
125
+ let rafId = 0
126
+ const obs = new ResizeObserver(() => {
127
+ if (rafId) cancelAnimationFrame(rafId)
128
+ rafId = requestAnimationFrame(() => {
129
+ rafId = 0
130
+ const mode = lastFitModeRef.current
131
+ if (mode === 'manual') return
132
+ const scale = computeFitScale(mode)
133
+ if (scale == null) return
134
+ onZoomChange(clampToPct(scale))
135
+ })
136
+ })
137
+ obs.observe(container)
138
+ return () => {
139
+ obs.disconnect()
140
+ if (rafId) cancelAnimationFrame(rafId)
141
+ }
142
+ }, [loaded, computeFitScale, clampToPct, onZoomChange])
143
+
144
+ // Q3 雙擊 toggle fit ↔ 100%(對齊 Apple Photos / Preview.app / Imgur / PhotoSwipe canonical)
145
+ const handleDoubleClick = React.useCallback(() => {
146
+ if (!loaded) return
147
+ const fitScale = computeFitScale('fit-page')
148
+ if (fitScale == null) return
149
+ const fitPct = clampToPct(fitScale)
150
+ // 在 fit-page 附近(±5pt)→ 跳 100% natural;否則 → 回 fit-page
151
+ const atFit = Math.abs(zoom - fitPct) < 5
152
+ const targetPct = atFit ? 100 : fitPct
153
+ lastFitModeRef.current = atFit ? 'manual' : 'fit-page' // 跳 100% = manual,回 fit = fit mode
154
+ onZoomChange(targetPct)
155
+ }, [loaded, zoom, computeFitScale, clampToPct, onZoomChange])
156
+
157
+ // 外部 zoom 變動(preset / ± / 打字 / fit request)→ centerView 重定位
158
+ // library canonical `centerView` 同時處理 scale + 置中 + animation + bounds。
159
+ React.useEffect(() => {
160
+ const api = apiRef.current
161
+ if (!api || !loaded) return
162
+ const currentScale = api.state.scale
163
+ const targetScale = zoom / 100
164
+ if (Math.abs(currentScale - targetScale) < 0.005) return
165
+ // 標記 programmatic — onTransform 期間不要被誤標 manual mode
166
+ programmaticZoomRef.current = true
167
+ api.centerView(targetScale, 200)
168
+ // 動畫 ~200ms + buffer 後解 flag
169
+ const t = setTimeout(() => { programmaticZoomRef.current = false }, 280)
170
+ return () => clearTimeout(t)
171
+ }, [zoom, loaded])
172
+
173
+ // Fit request(toolbar 菜單點 fit-width / fit-page)→ 算 scale emit 回 shell
174
+ React.useEffect(() => {
175
+ if (!fitRequest || !loaded) return
176
+ const scale = computeFitScale(fitRequest.fit)
177
+ if (scale == null) return
178
+ lastFitModeRef.current = fitRequest.fit // 記 fit mode 給 ResizeObserver 用
179
+ onZoomChange(clampToPct(scale))
180
+ }, [fitRequest, loaded, computeFitScale, clampToPct, onZoomChange])
181
+
182
+ // 內部 wheel / pinch zoom → 同步回 shell + 標記為 manual mode(打破 fit auto-reflow)
183
+ // programmatic centerView 期間 lib 也會 fire onTransform → 用 flag 跳過,避免誤標 manual。
184
+ const handleTransformed = React.useCallback(
185
+ (_ref: ReactZoomPanPinchRef, state: { scale: number }) => {
186
+ if (programmaticZoomRef.current) return // programmatic update,不標 manual
187
+ const nextZoom = Math.round(state.scale * 100)
188
+ if (nextZoom !== zoom) {
189
+ lastFitModeRef.current = 'manual'
190
+ onZoomChange(nextZoom)
191
+ }
192
+ },
193
+ [zoom, onZoomChange],
194
+ )
195
+
196
+ return (
197
+ <div ref={containerRef} className="w-full h-full overflow-hidden" onDoubleClick={handleDoubleClick}>
198
+ <TransformWrapper
199
+ // any-allow: react-zoom-pan-pinch TransformWrapper ref type not exported; API surface stable per lib docs
200
+ ref={apiRef as any}
201
+ initialScale={1}
202
+ minScale={MIN_SCALE}
203
+ maxScale={MAX_SCALE}
204
+ centerOnInit
205
+ centerZoomedOut
206
+ // Teams 對標(2026-04-23):image viewer 走 chat-app lightbox 慣例 —
207
+ // drag 時 image 保持在 canvas bounds 內(zoom-fit 時 drag 無意義,zoom-in 時 drag pan 有限制)。
208
+ // `limitToBounds=true` 跟 Microsoft Teams / Slack / iOS Photos 等 chat-lightbox 互動一致,
209
+ // 避免 Figma-canvas 式「可 drag 到任意位置」的無界體驗混淆 viewer 語境。
210
+ limitToBounds={true}
211
+ // Wheel zoom canonical:
212
+ // - `step: 0.03` = 每 tick ~3% scale,對齊 Figma / Preview.app 細緻度
213
+ // (原 0.1 = 10% 太粗,接近 Google Slides 離散慣例)
214
+ // - multiplicative 等距:library 內部 scale factor 乘算,log 視覺等距
215
+ // 註:原本用 `smoothStep: 0.005` 但當前 lib type 不含該 key;若需 trackpad 細緻,
216
+ // 升級 react-zoom-pan-pinch 到有此 prop 的版本或切到 `smoothScroll` API
217
+ wheel={{ step: 0.03 }}
218
+ // Q3 雙擊改自定 handler:lib `mode: 'reset'` 永遠 reset 到 initialScale=1 → 100%,
219
+ // 失去 fit ↔ 100% toggle UX(Apple Photos / Drive canonical)。disabled lib 預設 + 自定 onDoubleClick。
220
+ doubleClick={{ disabled: true }}
221
+ onTransform={handleTransformed}
222
+ >
223
+ {/* 2026-04-23 debug fix:contentClass 不設 `!w-full !h-full`。
224
+ 設 `!w-full !h-full` 會讓 `.react-transform-component` 強制 1280×752 容器尺寸,
225
+ 但 image 是 natural 1440×900(自然溢出 container)。Library `centerView(scale)`
226
+ 基於 component 尺寸計算 translate → 計算偏 61px(視 image 被 WRAPPER 框住而非
227
+ 自然 size)。
228
+ 移除 content fixed size 後:component 自然 size = image natural → library 以
229
+ image 實際尺寸計算置中,translate 正確(42.4, 2.5)得到 symmetric padding。
230
+ wrapper 保留 `!w-full !h-full` 作 interaction capture bounds。 */}
231
+ <TransformComponent wrapperClass="!w-full !h-full">
232
+ <img
233
+ ref={imgRef}
234
+ src={file.url}
235
+ alt={file.name}
236
+ onLoad={handleImageLoad}
237
+ draggable={false}
238
+ // natural size(**不走 object-contain**)— transform scale 管實際顯示大小
239
+ className="max-w-none max-h-none select-none"
240
+ style={{ pointerEvents: 'none' }}
241
+ />
242
+ </TransformComponent>
243
+ </TransformWrapper>
244
+ </div>
245
+ )
246
+ }
247
+ ImageRenderer.displayName = 'ImageRenderer'
248
+
249
+ const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico'])
250
+
251
+ /** 判斷檔案是否可用 ImageRenderer 渲染。 */
252
+ export function canRenderImage(file: { mimeType: string; name: string }): boolean {
253
+ if (file.mimeType.startsWith('image/')) return true
254
+ const ext = file.name.split('.').pop()?.toLowerCase()
255
+ return ext ? IMAGE_EXTS.has(ext) : false
256
+ }
@@ -0,0 +1,79 @@
1
+ import * as React from "react"
2
+ import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
3
+
4
+ import { cn } from "@/lib/utils"
5
+ import { OVERLAY_SIDE_OFFSET } from "@/design-system/tokens/elevation/overlay-geometry"
6
+
7
+ /**
8
+ * HoverCard — hover 顯示可互動內容的浮層(行為 primitive)
9
+ *
10
+ * 跟 Tooltip 的差異:內容可互動(按鈕、連結、hover 子元素)。
11
+ *
12
+ * **不含視覺樣式**——bg、border、shadow、padding 由 consumer 決定:
13
+ * - OverflowIndicator:深色 Tooltip 樣式(bg-tooltip + data-theme="dark")
14
+ * - NameCard:亮色 Card 樣式(bg-surface-raised + elevation-200)
15
+ *
16
+ * 只提供:z-index、動畫、sideOffset。
17
+ */
18
+
19
+ const HoverCard = HoverCardPrimitive.Root
20
+
21
+ const HoverCardTrigger = HoverCardPrimitive.Trigger
22
+
23
+ const HoverCardContent = React.forwardRef<
24
+ React.ElementRef<typeof HoverCardPrimitive.Content>,
25
+ React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
26
+ >(({ className, align = "center", sideOffset = OVERLAY_SIDE_OFFSET, collisionPadding = 12, ...props }, ref) => (
27
+ // HoverCardPrimitive.Portal(2026-04-23):把 Content 搬到 `document.body`。
28
+ // 不 Portal 時 Content 會 DOM-nested 在 trigger subtree,如 trigger 位於 OverflowIndicator
29
+ // `data-theme="dark"` tooltip 內部 → Avatar 自帶 HoverCard 的 Content 也卡在 dark subtree,
30
+ // CSS var(--foreground) 繼承 dark 值 → NameCard 內部文字變 white 看不見(user 抓的 bug)。
31
+ // Portal 到 body 讓 CSS 繼承 chain 從 app root data-theme 起算,不受 trigger subtree 污染。
32
+ //
33
+ // collisionPadding=12:Radix / browser 內部 1-2px rounding 讓 visual padding 比 prop 值少 1-2px。
34
+ // 提高到 12 保證使用者實際看到 ≥ 8px viewport edge gap(overlay-surface「靠邊 8px」canonical)。
35
+ <HoverCardPrimitive.Portal>
36
+ <HoverCardPrimitive.Content
37
+ ref={ref}
38
+ align={align}
39
+ sideOffset={sideOffset}
40
+ collisionPadding={collisionPadding}
41
+ className={cn(
42
+ "z-50 outline-none",
43
+ // 2026-05-04 viewport-aware max-h SSOT(對齊 Popover):header/footer 永遠 in-viewport,body 壓縮 scroll
44
+ // 2026-05-05 audit dim 35 fix:加 `min-h-0` 完成 M25 chain invariant
45
+ "max-h-[var(--radix-hover-card-content-available-height,100vh)] flex flex-col overflow-hidden min-h-0",
46
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
47
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
48
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
49
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
50
+ "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
51
+ "origin-[var(--radix-hover-card-content-transform-origin)]",
52
+ className,
53
+ )}
54
+ {...props}
55
+ />
56
+ </HoverCardPrimitive.Portal>
57
+ ))
58
+ HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
59
+
60
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
61
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
62
+ export const hoverCardMeta = {
63
+ component: 'HoverCard',
64
+ family: null, // non-family composite / overlay / layout
65
+ variants: {
66
+
67
+ },
68
+ sizes: {
69
+
70
+ },
71
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
72
+ tokens: {
73
+ bg: ['bg-surface-raised'],
74
+ fg: ['--foreground'],
75
+ ring: [],
76
+ },
77
+ } as const
78
+
79
+ export { HoverCard, HoverCardTrigger, HoverCardContent }
@@ -0,0 +1,233 @@
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
+ import * as React from 'react'
3
+ import { type VariantProps } from 'class-variance-authority'
4
+ import type { LucideIcon } from 'lucide-react'
5
+ import { cn } from '@/lib/utils'
6
+ import type { FieldMode, FieldVariant } from '@/design-system/components/Field/field-types'
7
+ import { fieldWrapperStyles, bareInputStyles, EMPTY_DISPLAY } from '@/design-system/components/Field/field-wrapper'
8
+ import { useFieldContext } from '@/design-system/components/Field/field-context'
9
+ import { ItemInlineAction, ItemPrefix, type InlineActionConfig } from '@/design-system/patterns/element-anatomy/item-anatomy'
10
+ import { CircularProgress } from '@/design-system/components/CircularProgress/circular-progress'
11
+ import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
12
+
13
+ // ── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ export interface InputProps
16
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
17
+ Omit<VariantProps<typeof fieldWrapperStyles>, 'mode' | 'variant'> {
18
+ /** Field display mode */
19
+ mode?: FieldMode
20
+ /**
21
+ * Visual chrome(正交於 mode);Phase B1(2026-05-05)從 `variant` 改名 `chrome`,對齊 FieldContext.variant 透傳。
22
+ * - `'default'`(預設)— Field wrapper 完整 variant:bg-surface + 明顯 border + hover/focus 回饋。適用表單、Field 內嵌。
23
+ * - `'bare'` — 透明 variant,hover / focus 才出現 border。適用 Toolbar inline editing(如 FileViewer zoom input / chart config toolbar / rich text toolbar number input)+ DataTable cell-as-input。保留 padding / typography / height,只拿掉背景和常態 border。
24
+ *
25
+ * 透傳:在 `<Field variant="bare">` 內自動繼承 context.variant;per-prop override context。
26
+ * 世界級對照(bare):VS Code settings input / Figma toolbar number / Notion prop input。
27
+ */
28
+ variant?: FieldVariant
29
+ /** Error 狀態(正交於 mode)。border-error + aria-invalid。 */
30
+ error?: boolean
31
+ /** 左側靜態 icon — 輔助理解 input 用途(如 Search)。fg-muted。 */
32
+ startIcon?: LucideIcon
33
+ /** 右側 inline action — 宣告式 API,Field 根據 size 自動渲染。 */
34
+ endAction?: InlineActionConfig
35
+ /**
36
+ * 右側 slot(ReactNode)— escape hatch 供 consumer 放自訂元素(如 DropdownMenuTrigger asChild + ItemInlineActionButton)。
37
+ * 跟 `endAction` 互斥(同時傳 endSlot 會優先,endAction 被忽略)。
38
+ *
39
+ * **使用情境**:ZoomInput 需要 chevron 作 DropdownMenuTrigger anchor,config-only API 無法做到。
40
+ * **禁止情境**:表單欄位 / 一般 inline action → 用 `endAction` 宣告式 API。
41
+ */
42
+ endSlot?: React.ReactNode
43
+ /**
44
+ * Loading 狀態(async 驗證 / debounce fetch 中)。
45
+ * - **input 保持可編輯**(user 可以邊改邊讀,debounce 場景 UX 最好)
46
+ * - 世界級對照:Ant Input.Search 派(input editable during loading);非 Material readonly 派
47
+ * - 自動在 endAction slot 塞 `<CircularProgress size={iconSize}/>`(與 endAction prop 互斥)
48
+ * - 宣告 `aria-busy="true"` 讓 screen reader 感知處理中
49
+ */
50
+ loading?: boolean
51
+ /**
52
+ * Auto-width:Input 寬度 = 內容寬(value / placeholder 文字寬)+ startIcon + endAction + padding。
53
+ * 使用 CSS `field-sizing: content`(Chrome 123+ / Safari 17.4+;Firefox 還在實驗)。
54
+ *
55
+ * **使用情境**:
56
+ * - Inline edit(VS Code setting row / Figma property toolbar number input)
57
+ * - ZoomInput(FileViewer 縮放比例:輸入「100%」自動縮到三位數寬)
58
+ * - Tag / Chip 內 inline rename
59
+ *
60
+ * **不要用在**:表單 Field(Field 需要欄寬對齊,不該隨值跳動)
61
+ *
62
+ * **fallback**:不支援 `field-sizing` 的瀏覽器會退化為 `w-auto`(wrapper 縮到 content 尺寸,
63
+ * input 本身有 min-width 避免消失)。UX 上稍不一致但不致斷;若必須精準對齊所有瀏覽器,
64
+ * consumer 可自行傳 `style={{ width: ... }}` 顯式寬度,不走 auto。
65
+ */
66
+ autoWidth?: boolean
67
+ }
68
+
69
+ // ── Component ───────────────────────────────────────────────────────────────
70
+
71
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
72
+ const Input = React.forwardRef<HTMLInputElement, InputProps>(
73
+ (
74
+ {
75
+ mode: modeProp,
76
+ variant: variantProp,
77
+ error = false,
78
+ size,
79
+ startIcon: StartIcon,
80
+ endAction,
81
+ endSlot,
82
+ loading = false,
83
+ autoWidth = false,
84
+ className,
85
+ disabled,
86
+ readOnly,
87
+ value,
88
+ id: idProp,
89
+ 'aria-describedby': ariaDescribedByProp,
90
+ 'aria-errormessage': ariaErrorMessageProp,
91
+ ...props
92
+ },
93
+ ref
94
+ ) => {
95
+ // ── FieldContext 自動讀取(在 <Field> 內時,invalid / disabled / mode / chrome 由 context 接管) ──
96
+ const fieldCtx = useFieldContext()
97
+ // chrome 透傳:per-prop override context;context 沒值則 'default'
98
+ const variant: FieldVariant = variantProp ?? fieldCtx?.variant ?? 'default'
99
+ // mode resolve order(Phase B1 2026-05-05):
100
+ // prop > fieldCtx.mode > (readOnly → 'readonly') > (disabled → 'disabled') > 'edit'
101
+ // loading 期間 input 保持可編輯(Ant Input.Search 派,UX「邊改邊讀」)
102
+ // 只用 aria-busy + endAction Spinner 標示狀態,不動 mode
103
+ const resolvedMode: FieldMode = modeProp
104
+ ?? fieldCtx?.mode
105
+ ?? (readOnly ? 'readonly' : disabled ? 'disabled' : 'edit')
106
+ const isEditable = resolvedMode === 'edit'
107
+ const isDisplay = resolvedMode === 'display'
108
+ // error 合併:自身 error prop OR Field context invalid
109
+ const resolvedError = error || (fieldCtx?.invalid ?? false)
110
+ // 2026-05-18 改 import ICON_SIZE SSOT(per user『做完』approval,消除 M17 違反 7+ 重複 ternary)
111
+ const iconSize = ICON_SIZE[size as 'sm' | 'md' | 'lg']
112
+ const iconColor = resolvedMode === 'disabled' ? 'text-fg-disabled' : 'text-fg-muted'
113
+
114
+ // ── display mode:純展示,渲染 <span> 取代 <input> ──
115
+ // 對齊 Carbon read-only / PatternFly inline-edit hidden-input / Cloudscape display-mode
116
+ if (isDisplay) {
117
+ const displayValue = value != null && value !== '' ? String(value) : null
118
+ return (
119
+ <div
120
+ className={cn(
121
+ fieldWrapperStyles({ mode: 'display', variant: variant, size }),
122
+ autoWidth && 'inline-flex w-auto',
123
+ className,
124
+ )}
125
+ data-field-mode="display"
126
+ >
127
+ {StartIcon && (
128
+ <ItemPrefix>
129
+ <StartIcon
130
+ size={iconSize}
131
+ className={cn('pointer-events-none', iconColor)}
132
+ aria-hidden
133
+ />
134
+ </ItemPrefix>
135
+ )}
136
+ <span
137
+ className={cn(
138
+ bareInputStyles,
139
+ // B1 fix(2026-05-05):display mode 單行 ellipsis 截斷(對齊 Notion / Airtable / Linear
140
+ // cell display canonical:single-line value 過長 → ellipsis。Textarea display 走 wrap path,
141
+ // 不在此處;Input display 永遠 single-line。)
142
+ 'truncate',
143
+ displayValue == null && 'text-fg-muted',
144
+ )}
145
+ >
146
+ {displayValue ?? EMPTY_DISPLAY}
147
+ </span>
148
+ </div>
149
+ )
150
+ }
151
+
152
+ return (
153
+ <div
154
+ className={cn(
155
+ fieldWrapperStyles({ mode: resolvedMode, variant: variant, size }),
156
+ isEditable && resolvedError && [
157
+ 'border-error hover:border-error-hover',
158
+ 'focus-within:border-error focus-within:hover:border-error',
159
+ ],
160
+ // autoWidth:wrapper 縮到 inline-flex + w-auto,讓寬度由 startIcon + input(field-sizing: content)+ endAction 自然累加
161
+ autoWidth && 'inline-flex w-auto',
162
+ className,
163
+ )}
164
+ data-field-mode={resolvedMode}
165
+ data-error={isEditable && resolvedError ? '' : undefined}
166
+ aria-busy={loading || undefined}
167
+ >
168
+ {StartIcon && (
169
+ <ItemPrefix>
170
+ <StartIcon
171
+ size={iconSize}
172
+ className={cn('pointer-events-none', iconColor)}
173
+ aria-hidden
174
+ />
175
+ </ItemPrefix>
176
+ )}
177
+ <input
178
+ ref={ref}
179
+ type="text"
180
+ id={idProp ?? fieldCtx?.id}
181
+ value={value as string | number | readonly string[] | undefined}
182
+ readOnly={resolvedMode === 'readonly'}
183
+ disabled={resolvedMode === 'disabled'}
184
+ aria-invalid={resolvedError || undefined}
185
+ aria-required={fieldCtx?.required || undefined}
186
+ aria-describedby={ariaDescribedByProp ?? fieldCtx?.descriptionId}
187
+ aria-errormessage={ariaErrorMessageProp ?? (resolvedError ? fieldCtx?.errorId : undefined)}
188
+ className={cn(
189
+ bareInputStyles,
190
+ resolvedMode === 'disabled' && 'text-fg-disabled placeholder:text-fg-disabled cursor-not-allowed',
191
+ // autoWidth:input 本身 field-sizing:content(Chrome 123+ / Safari 17.4+),寬度跟 value 文字寬。
192
+ // w-auto 關掉預設 w-full;min-w-0 讓 flex shrink 不卡住。
193
+ autoWidth && '[field-sizing:content] w-auto min-w-0',
194
+ )}
195
+ {...props}
196
+ />
197
+ {loading ? (
198
+ <CircularProgress size={iconSize} className="shrink-0" />
199
+ ) : endSlot && isEditable ? (
200
+ // endSlot escape hatch:consumer 自控右側 slot(如 DropdownMenuTrigger asChild wrap)
201
+ endSlot
202
+ ) : endAction && isEditable ? (
203
+ <ItemInlineAction action={endAction} size={size ?? 'md'} />
204
+ ) : null}
205
+ </div>
206
+ )
207
+ }
208
+ )
209
+ Input.displayName = 'Input'
210
+
211
+ // Phase B1(2026-05-05):InputDisplay 退場。改用 `<Input mode="display" value={...} />`
212
+ // 對齊 Carbon read-only / PatternFly inline-edit hidden-input / Cloudscape display-mode 統一 mode 模型。
213
+
214
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
215
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
216
+ export const inputMeta = {
217
+ component: 'Input',
218
+ family: 4,
219
+ variants: {
220
+
221
+ },
222
+ sizes: {
223
+
224
+ },
225
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
226
+ tokens: {
227
+ bg: ['bg-surface'],
228
+ fg: ['text-fg-disabled', 'text-fg-muted'],
229
+ ring: [],
230
+ },
231
+ } as const
232
+
233
+ export { Input }