@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.
- package/package.json +93 -0
- package/src/README.md +32 -0
- package/src/components/Accordion/accordion.tsx +104 -0
- package/src/components/Alert/alert.tsx +188 -0
- package/src/components/AppShell/_demo-helpers.tsx +198 -0
- package/src/components/AppShell/app-shell.tsx +364 -0
- package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
- package/src/components/Avatar/avatar.tsx +368 -0
- package/src/components/Badge/badge.tsx +104 -0
- package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
- package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
- package/src/components/Button/button-group.tsx +96 -0
- package/src/components/Button/button.tsx +539 -0
- package/src/components/Calendar/calendar.tsx +411 -0
- package/src/components/Carousel/carousel.tsx +371 -0
- package/src/components/Chart/chart.tsx +376 -0
- package/src/components/Checkbox/checkbox-group.tsx +94 -0
- package/src/components/Checkbox/checkbox.tsx +237 -0
- package/src/components/Chip/chip.tsx +359 -0
- package/src/components/CircularProgress/circular-progress.tsx +204 -0
- package/src/components/Coachmark/coachmark.tsx +255 -0
- package/src/components/Combobox/combobox.tsx +826 -0
- package/src/components/Command/command.tsx +187 -0
- package/src/components/DataTable/active-editor-controller.ts +72 -0
- package/src/components/DataTable/cell-registry.tsx +520 -0
- package/src/components/DataTable/column-types.ts +180 -0
- package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
- package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
- package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
- package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
- package/src/components/DataTable/data-table.css +165 -0
- package/src/components/DataTable/data-table.tsx +2924 -0
- package/src/components/DataTable/filter-operators.ts +225 -0
- package/src/components/DataTable/filter-tree.ts +313 -0
- package/src/components/DataTable/lib/column-meta.ts +79 -0
- package/src/components/DateGrid/date-grid.tsx +209 -0
- package/src/components/DatePicker/date-picker.tsx +1114 -0
- package/src/components/DescriptionList/description-list.tsx +141 -0
- package/src/components/Dialog/dialog.tsx +267 -0
- package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
- package/src/components/Empty/empty.tsx +108 -0
- package/src/components/Field/field-context.ts +136 -0
- package/src/components/Field/field-types.ts +52 -0
- package/src/components/Field/field-wrapper.tsx +348 -0
- package/src/components/Field/field.tsx +535 -0
- package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
- package/src/components/FileItem/file-item.tsx +322 -0
- package/src/components/FileUpload/file-upload.tsx +326 -0
- package/src/components/FileViewer/file-viewer-types.ts +76 -0
- package/src/components/FileViewer/file-viewer.tsx +1065 -0
- package/src/components/FileViewer/image-renderer.tsx +256 -0
- package/src/components/HoverCard/hover-card.tsx +79 -0
- package/src/components/Input/input.tsx +233 -0
- package/src/components/LinkInput/link-input.tsx +304 -0
- package/src/components/Menu/menu-item.tsx +334 -0
- package/src/components/NameCard/name-card.tsx +319 -0
- package/src/components/Notice/notice.tsx +196 -0
- package/src/components/NumberInput/number-input.tsx +203 -0
- package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
- package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
- package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
- package/src/components/PeoplePicker/people-picker.tsx +455 -0
- package/src/components/PeoplePicker/person-display.tsx +358 -0
- package/src/components/Popover/popover.tsx +183 -0
- package/src/components/ProgressBar/progress-bar.tsx +157 -0
- package/src/components/README.md +58 -0
- package/src/components/RadioGroup/radio-group.tsx +261 -0
- package/src/components/Rating/rating.tsx +295 -0
- package/src/components/ScrollArea/scroll-area.tsx +110 -0
- package/src/components/SegmentedControl/segmented-control.tsx +304 -0
- package/src/components/Select/select.tsx +658 -0
- package/src/components/SelectMenu/select-menu.tsx +430 -0
- package/src/components/SelectionControl/selection-item.tsx +261 -0
- package/src/components/Separator/separator.tsx +48 -0
- package/src/components/Sheet/sheet.tsx +240 -0
- package/src/components/Sidebar/sidebar.tsx +1280 -0
- package/src/components/Skeleton/skeleton.tsx +35 -0
- package/src/components/Slider/slider.tsx +158 -0
- package/src/components/Steps/steps.tsx +850 -0
- package/src/components/Switch/switch.tsx +285 -0
- package/src/components/Tabs/tabs.tsx +515 -0
- package/src/components/Tag/tag.tsx +246 -0
- package/src/components/Textarea/textarea.tsx +280 -0
- package/src/components/TimePicker/time-columns.tsx +260 -0
- package/src/components/TimePicker/time-picker.tsx +419 -0
- package/src/components/Toast/toast.tsx +129 -0
- package/src/components/Tooltip/tooltip.tsx +68 -0
- package/src/components/TreeView/tree-view.tsx +1031 -0
- package/src/hooks/use-controllable.ts +40 -0
- package/src/hooks/use-is-narrow-viewport.ts +19 -0
- package/src/hooks/use-is-touch-device.ts +21 -0
- package/src/hooks/use-overflow-items.ts +256 -0
- package/src/index.ts +85 -0
- package/src/lib/README.md +82 -0
- package/src/lib/drag-visual.ts +272 -0
- package/src/lib/i18n/README.md +60 -0
- package/src/lib/i18n/i18n-context.tsx +129 -0
- package/src/lib/multi-select-ordering.ts +61 -0
- package/src/lib/utils.ts +93 -0
- package/src/patterns/README.md +67 -0
- package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
- package/src/patterns/header-canonical/chrome-header.tsx +175 -0
- package/src/patterns/header-canonical/header-canonical.css +27 -0
- package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
- package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
- package/src/patterns/resize-handle/resize-handle.tsx +188 -0
- package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
- package/src/tokens/README.md +53 -0
- package/src/tokens/color/primitives.css +429 -0
- package/src/tokens/color/semantic.css +539 -0
- package/src/tokens/elevation/overlay-geometry.ts +13 -0
- package/src/tokens/layoutSpace/layoutSpace.css +36 -0
- package/src/tokens/motion/motion.css +30 -0
- package/src/tokens/motion/motion.ts +17 -0
- package/src/tokens/opacity/opacity.css +23 -0
- package/src/tokens/radius/radius.css +19 -0
- package/src/tokens/typography/typography.css +118 -0
- package/src/tokens/uiSize/icon-size.ts +52 -0
- 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 }
|