@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,826 @@
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
+ // @renderer-symmetry-allow: ComboboxTagStack(display path)接 consumer tagRenderer 是 Stream C 下 cycle 工作 — 2026-05-12 先 ship Issues 2/3/4 surgical fixes(placeholder vocabulary + cell surface metrics + placeholder truncate),tagRenderer display-path unify deferred per field-controls.spec.md 共享 contract a。當前 multi=1 顯示已透過 PeoplePicker tagRenderer 線 314 PersonDisplay SSOT 對齊;其他 Combobox consumer 走 default `<Tag>` 純文字 backward-compat。
3
+ // code-quality-allow: file-size — Combobox 含 NativeCombobox/CustomCombobox/useOverflowCount/OverflowTagList/ComboboxTagStack 5 子元件 + 共用 helpers,split-into-files 會破壞 measurement closures + 重複 type definitions。當前 751 lines 在 800 hard cap 內。
4
+ import * as React from 'react'
5
+ import { X, ChevronDown } from 'lucide-react'
6
+ import { cn } from '@/lib/utils'
7
+ import type { FieldMode, FieldVariant } from '@/design-system/components/Field/field-types'
8
+ import { fieldWrapperStyles, EMPTY_DISPLAY, nakedCellRowModeAlign, fieldDisplayTextClass } from '@/design-system/components/Field/field-wrapper'
9
+ import { useFieldContext } from '@/design-system/components/Field/field-context'
10
+ import { Tag } from '@/design-system/components/Tag/tag'
11
+ import { ItemInlineAction, ItemSuffix } from '@/design-system/patterns/element-anatomy/item-anatomy'
12
+ import { OverflowIndicator } from '@/design-system/components/OverflowIndicator/overflow-indicator'
13
+ import { SelectMenu, type SelectMenuOption } from '@/design-system/components/SelectMenu/select-menu'
14
+ import { useIsTouchDevice } from '@/design-system/hooks/use-is-touch-device'
15
+ import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
16
+
17
+ // ── constants ───────────────────────────────────────────────────────────────
18
+
19
+ const GAP = 4
20
+
21
+ const tagPadding: Record<string, string> = {
22
+ sm: 'px-[calc((var(--field-height-sm)_-_1.25rem)_/_2)]',
23
+ md: 'px-[calc((var(--field-height-md)_-_1.5rem)_/_2)]',
24
+ lg: 'px-[calc((var(--field-height-lg)_-_1.5rem)_/_2)]',
25
+ }
26
+
27
+ /**
28
+ * Combobox option schema(2026-05-10 post-Issue-4 audit unify):**explicit extends
29
+ * SelectMenuOption(primitive SSOT)** — 避免重蹈先前 PeoplePicker 改壞的 wrapper schema drift。
30
+ *
31
+ * Why `extends SelectMenuOption`(per user 「全盤檢查避免下次又改壞或是偏移」要求):
32
+ * 原 `interface SelectOption { value: string; label: string }` 是 weak schema,跟 Select 的
33
+ * `SelectOption`(同名)雙重宣告但欄位不同 → TypeScript 不抓(同名 interface 在不同 file
34
+ * 各 export,consumer import 到哪個版本看 import path)→ schema drift。
35
+ * PeoplePicker multi-mode 走 Combobox 路徑,dropdown menu rows lose avatar / description —
36
+ * user 看到「single mode 有 avatar / multi mode 沒 avatar」inconsistency。
37
+ *
38
+ * Fix(post-Issue-4 follow-up):extend SelectMenuOption → 全 primitive surface 自動繼承。
39
+ * Wrapper-only field 都沒有 → empty body interface(future 加 wrapper-only field 加在此處)。
40
+ * `menuOptions` mapping(below)forward 全 SelectMenuOption surface。
41
+ *
42
+ * 對齊 Polaris ChoiceList / Material Autocomplete / Carbon Dropdown 的 wrapper-vs-primitive
43
+ * schema-extension idiom。Hook `check_wrapper_primitive_schema_drift.sh`(M30 機械強制)。
44
+ */
45
+ export interface SelectOption extends SelectMenuOption {
46
+ // (no wrapper-only fields yet — kept for future扩 + same-name SSOT cross-wrapper)
47
+ }
48
+
49
+ // ── useOverflowCount (unchanged) ────────────────────────────────────────────
50
+
51
+ function useOverflowCount(
52
+ containerRef: React.RefObject<HTMLDivElement | null>,
53
+ tagEls: React.MutableRefObject<(HTMLDivElement | null)[]>,
54
+ overflowEl: React.RefObject<HTMLDivElement | null>,
55
+ totalCount: number,
56
+ enabled: boolean,
57
+ gap: number = GAP, // (2026-05-07 v15.13)stack avatar 模式傳 0
58
+ visibleCountOverride?: number, // 2026-05-15 Bug 3 fix:override DOM measurement(PeoplePicker stack 走 formula primitive)
59
+ ): { visibleCount: number; ready: boolean } {
60
+ const [state, setState] = React.useState({ visibleCount: totalCount, ready: !enabled })
61
+ // 2026-05-18 Round 6 fix(per Codex M31 Round 6 H7 verdict + Step 5 共識):
62
+ // `ofEl.offsetWidth` 在 expanded state(visibleCount === totalCount → overflow=0 →
63
+ // `OverflowIndicator` line 92 `return null` → ofEl wrapper empty)= 0,fallback 60;
64
+ // 在 collapsed state(+N rendered)= 真實寬(~28-32px)。同 `available` 在兩 state 給
65
+ // 不同 verdict → 臨界值區 `max(B+g+Q, B+g+O) ≤ available < B+g+F` 振盪。Cache last
66
+ // non-zero measurement → measurement state-independent → oscillation 收斂。
67
+ // 初始 60 沿用舊 fallback(無 measure 史 ok-ish over-estimate)。
68
+ const lastOverflowWRef = React.useRef<number>(60)
69
+
70
+ // 2026-05-16 RACE FIX(user 抓「逐個 click 滿 6」vs「取消全選→再全選」same length 不同 visible):
71
+ //
72
+ // 原 useEffect + 雙 rAF 沒 capture rAF IDs → cleanup 不 cancel pending rAFs。
73
+ // Path B(length=6→0→6):length=0 時 override=undefined 走 internal calc 排 rAF,
74
+ // 然後 length=6 + override=N → deps change → cleanup 跑(disconnect ResizeObserver 但
75
+ // 不 cancel rAF)→ 新 useEffect 跑 override 寫 el.hidden → 舊 rAF 仍 fire → 跑舊
76
+ // internal calc → 覆寫 el.hidden 用 internal measurement(不一致於 override formula)。
77
+ //
78
+ // Fix:
79
+ // 1. useEffect → useLayoutEffect:tighter timing,measurement 在 paint 前 sync
80
+ // 2. Capture rAF IDs,cancel on cleanup
81
+ // 3. scheduleCalc 函式包裝,cancel in-flight rAF 才排新一輪(避免 ResizeObserver
82
+ // re-fire 堆 rAF)
83
+ //
84
+ // 對齊 2026-05-14 I3 fix comment「user 抓『全選 vs 逐個勾 result 不同』」 — 當時 fix
85
+ // 只加 double-rAF 但漏 cancel,本次補完 race close。
86
+ // 2026-05-18 Round 5 fix(per visual test probe):useLayoutEffect 在 nested component 場景
87
+ // 會在 parent ref attach 前 fire(child layout effect 先於 parent ref attach)→ containerRef.current
88
+ // null → early return → calc never runs → setProperty never called → CSS var unset → tag overflow。
89
+ // 改 useEffect:fires AFTER paint,所有 refs 都 attach。double-rAF guard ensures layout done。
90
+ // Trade-off:可能 1-2 frame flicker,但 functional setState guard + paint target measurement 已 cover。
91
+ React.useEffect(() => {
92
+ if (!enabled || totalCount === 0) { setState({ visibleCount: totalCount, ready: true }); return }
93
+ if (visibleCountOverride !== undefined) {
94
+ for (let i = 0; i < tagEls.current.length; i++) {
95
+ const el = tagEls.current[i]
96
+ if (el) el.hidden = i >= visibleCountOverride
97
+ }
98
+ const ofEl = overflowEl.current
99
+ if (ofEl) ofEl.hidden = visibleCountOverride >= totalCount
100
+ setState({ visibleCount: visibleCountOverride, ready: true }); return
101
+ }
102
+ // totalCount=1 fast path:single-tag case 直接 visible 不跑 measurement loop。
103
+ // (歷史:c90d029 曾移除此 bypass,後復原 — 移除會造成 narrow cell 1-selected 跑 unbounded Tag
104
+ // measurement 後 visibleCount=0 → 顯 +1 indicator 而非 single tag,違反 PeoplePicker length===1
105
+ // 走 PersonDisplay SSOT。)
106
+ // 2026-05-16 Round 5 codex edge case fix:explicit unhide DOM nodes(對齊 override branch)。
107
+ // 原 fast-path 只設 React state 不動 `el.hidden`,如 wrappers 之前 hidden 殘留(從 length>1 降到 1)
108
+ // 可能視覺漏顯。Override branch L78-80 同 contract 對齊。
109
+ if (totalCount === 1) {
110
+ for (let i = 0; i < tagEls.current.length; i++) {
111
+ const el = tagEls.current[i]
112
+ if (el) el.hidden = i >= 1
113
+ }
114
+ const ofEl = overflowEl.current
115
+ if (ofEl) ofEl.hidden = true
116
+ setState({ visibleCount: 1, ready: true }); return
117
+ }
118
+ const container = containerRef.current
119
+ if (!container) return
120
+
121
+ const calc = () => {
122
+ const cs = getComputedStyle(container)
123
+ const available = container.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0)
124
+ // 2026-05-18 Round 5 fix(per user 拍板「那就開始做」+ Codex M31 Round 5 verdict):
125
+ // inject available 成 CSS var,Tag 用 explicit length 而非 cyclic percentage(避 CSS Sizing 3
126
+ // §5.2.1 cyclic percentage 退化問題)。
127
+ container.style.setProperty('--combobox-tag-area-inline-size', `${available}px`)
128
+ for (const el of tagEls.current) if (el) el.hidden = false
129
+ const ofEl = overflowEl.current
130
+ if (ofEl) ofEl.hidden = false
131
+ // 2026-05-18 Round 6 fix:cache last non-zero ofEl width 破 expanded/collapsed state
132
+ // 量測二態(expanded → ofEl 空 offsetWidth=0 / collapsed → real ~28-32px)。沒 cache
133
+ // 之前同 `available` 在兩 state 給不同 verdict → 永動。詳本 hook 頂部 ref 註解。
134
+ const measuredOverflowW = ofEl?.offsetWidth || 0
135
+ if (measuredOverflowW > 0) lastOverflowWRef.current = measuredOverflowW
136
+ const overflowW = lastOverflowWRef.current
137
+ // **#3 fix(2026-05-04)**:width-check 先於 count++,並處理 i=0 邊界(1 tag 自身就太寬 → 全 hidden 顯 +N)
138
+ // 之前 bug:greedy `count++` 永遠至少 = 1,1-tag-too-wide case 視覺呈半個 tag clipped + +N(錯)
139
+ // 修後:1 tag 太寬時 count = 0,全 N tags 走 +N 顯 indicator
140
+ // 2026-05-18 Round 5:量 paint target `[data-tag-root]` 而非 wrapper(per codex Round 5 verdict)。
141
+ // wrapper basis:auto 自由 grow,offsetWidth ≠ Tag actual paint width。
142
+ let used = 0, count = 0
143
+ for (let i = 0; i < totalCount; i++) {
144
+ const el = tagEls.current[i]
145
+ if (!el) continue
146
+ const tagRoot = el.querySelector('[data-tag-root]') as HTMLElement | null
147
+ const w = tagRoot ? tagRoot.getBoundingClientRect().width : el.offsetWidth
148
+ const next = used + (count > 0 ? gap : 0) + w
149
+ const remaining = totalCount - count - 1
150
+ // width check FIRST(無 `count > 0` 短路):任何超寬都 break,包含 i=0 case
151
+ if (remaining > 0 && next + gap + overflowW > available) break
152
+ if (remaining === 0 && next > available) break
153
+ used = next; count++
154
+ }
155
+ for (let i = 0; i < tagEls.current.length; i++) { const el = tagEls.current[i]; if (el) el.hidden = i >= count }
156
+ if (ofEl) ofEl.hidden = count >= totalCount
157
+ // 2026-05-18 Round 5 last guard(per Codex Round 5 verdict):safety net 防 measurement drift
158
+ // (sub-pixel / rounding)。verify last visible tag rect.right ≤ container right,超出遞減 count。
159
+ const containerRect = container.getBoundingClientRect()
160
+ while (count > 0) {
161
+ const lastEl = tagEls.current[count - 1]
162
+ const tagRoot = lastEl?.querySelector('[data-tag-root]') as HTMLElement | null
163
+ if (!tagRoot) break
164
+ const tagRect = tagRoot.getBoundingClientRect()
165
+ if (tagRect.right <= containerRect.right + 0.5) break
166
+ count--
167
+ if (lastEl) lastEl.hidden = true
168
+ }
169
+ if (ofEl) ofEl.hidden = count >= totalCount
170
+ // 2026-05-18 A' fix functional setState value-equal guard(per Codex Round 3 verdict):
171
+ // sync calc 在 useLayoutEffect 內 + ResizeObserver re-fire 同時跑 → 若每次都 new object setState
172
+ // 觸發 re-render 即使值沒變,可能 cascade。回 prev 不更新 = avoid 抖動。
173
+ setState(prev => (prev.visibleCount === count && prev.ready) ? prev : { visibleCount: count, ready: true })
174
+ }
175
+
176
+ // 2026-05-14 I3 fix(per codex M31 verdict + user 抓「全選 vs 逐個勾 result 不同」):
177
+ // double-rAF ensures layout 完成 before measurement(原 single rAF 在 batched render
178
+ // 場景 tag 還 0-width)。Plus observe per-item ResizeObserver — 任何 tag width 變動
179
+ // 都 trigger recalc(deterministic regardless of commit order)。
180
+ //
181
+ // 2026-05-16 Race close:capture rAF IDs + cancel on cleanup(原版 race I3 沒 close
182
+ // 完;user 抓 path A 逐個 click vs path B 取消全選再全選 same length 不同 visible)。
183
+ // 2026-05-18 A' fix(per Codex Round 3 共識,user 拍板「執行」)— sync calc in useLayoutEffect:
184
+ // React 18 `useLayoutEffect` 在 DOM commit 後、瀏覽器繪製前同步跑,sync calc 在 paint 前
185
+ // 完成 `el.hidden` 設定 + functional setState guard(value-equal 不更新 = 避免 ResizeObserver
186
+ // 抖動 cascade rerender)。double-rAF 改 fallback only(ResizeObserver / async update path)。
187
+ // 解 user verbatim「tag 過長 / 過多會先全顯再變 +N 閃動」root cause(per codex Round 3 cite
188
+ // `combobox.tsx:248 render 沒設 hidden + L129 calc imperative 寫 DOM`)。
189
+ // 對齊 React docs https://react.dev/reference/react/useLayoutEffect pre-paint guarantee。
190
+ calc()
191
+ let rafId1 = 0, rafId2 = 0
192
+ const scheduleCalc = () => {
193
+ if (rafId1) { cancelAnimationFrame(rafId1); rafId1 = 0 }
194
+ if (rafId2) { cancelAnimationFrame(rafId2); rafId2 = 0 }
195
+ rafId1 = requestAnimationFrame(() => {
196
+ rafId1 = 0
197
+ rafId2 = requestAnimationFrame(() => {
198
+ rafId2 = 0
199
+ calc()
200
+ })
201
+ })
202
+ }
203
+ scheduleCalc()
204
+ const containerObs = new ResizeObserver(scheduleCalc)
205
+ containerObs.observe(container)
206
+ const itemObs = new ResizeObserver(scheduleCalc)
207
+ for (const el of tagEls.current) {
208
+ if (el) itemObs.observe(el)
209
+ }
210
+ return () => {
211
+ if (rafId1) cancelAnimationFrame(rafId1)
212
+ if (rafId2) cancelAnimationFrame(rafId2)
213
+ containerObs.disconnect()
214
+ itemObs.disconnect()
215
+ }
216
+ }, [containerRef, totalCount, enabled, gap, visibleCountOverride]) // 2026-05-15 Bug 3 fix:visibleCountOverride 入 deps,override 改 trigger recalc
217
+
218
+ return state
219
+ }
220
+
221
+ // ── OverflowTagList (unchanged) ──────────────────────────────────────────────
222
+
223
+ type ComboboxOverflowShape = 'circle' | 'tag'
224
+
225
+ // 2026-05-16 fix:overflow chip wrapper 必能跟 tag wrapper 套同 overlap class
226
+ // (per user 物理模型「avatar 和 +N 都是同尺寸圓形 + 同 step」)。原 chip wrapper
227
+ // 只有 `shrink-0`,在 stack 模式 -ml-0.5 不 apply → chip 不 overlap → 視覺多 22px
228
+ // 額外空間 → length=4→4 / length=5→2+3 saw bug 物理根因。
229
+ // PeoplePicker stack mode pass `'-ml-0.5 first:ml-0 relative inline-flex'` 對齊。
230
+ interface OverflowTagListProps {
231
+ containerRef: React.RefObject<HTMLDivElement | null>
232
+ items: { value: string; label: string }[]
233
+ size: 'sm' | 'md' | 'lg'
234
+ wrap: boolean
235
+ renderTag: (item: { value: string; label: string }, index: number) => React.ReactNode
236
+ /**
237
+ * 2026-05-14 I4 fix(per codex M31 verdict + user 抓「display overflow 有 avatar / edit 無」):
238
+ * Optional renderer for hidden items in `+N` overflow popover。Default fallback = `<Tag>{label}</Tag>`
239
+ * (純文字 chip,backward-compat)。Consumer pass 此 prop 讓 hidden items 顯示同 avatar 視覺
240
+ * (對齊 display MultiPersonDisplay overflow popover Tag avatar SSOT)。
241
+ */
242
+ renderHiddenTag?: (item: { value: string; label: string }) => React.ReactNode
243
+ onRemove?: (value: string) => void
244
+ trailing?: React.ReactNode
245
+ /** Tag area gap in px(default 4)。Stack mode 傳 0 讓 negative margin 生效 */
246
+ gap?: number
247
+ /**
248
+ * Optional class merged into each tag's outer measurement wrapper `<div className="shrink-0">`.
249
+ * (2026-05-07 v15.13)為 PeoplePicker stack mode 提供 hook point — 讓 stack avatar 走
250
+ * `-ml-0.5 first:ml-0 relative inline-flex group/avatar` 達成 overlap + dismiss group selector,
251
+ * 同時保留 `useOverflowCount` 量測 wrapper(必要,不可移除)。
252
+ *
253
+ * **Caveat(Q2 known tradeoff)**:`-ml-0.5` 負 margin 不改 each wrapper 的 `offsetWidth` →
254
+ * `useOverflowCount` 累加按完整寬計算 → 視覺實際塞得進的 tag 數 > 量測判定能塞的數 →
255
+ * **+N indicator 偏保守**(視覺還有空間但已顯 `+N`)。當前 v1 接受此 tradeoff;若窄 trigger
256
+ * + 多人場景明顯不對,future 可加 `overlapPx` prop 讓量測補償。
257
+ */
258
+ tagWrapperClassName?: string
259
+ /** 2026-05-16 fix:overflow chip 圓形 wrapper 套此 class(stack 模式套 `-ml-0.5` overlap),
260
+ * 讓 chip 跟 avatar 同 step 物理 — 避免「chip 多 24px 額外空間」造成 saw transition。*/
261
+ overflowWrapperClassName?: string
262
+ /**
263
+ * Overflow indicator(+N)形狀(2026-05-12 Round 7 fix,user 抓 PeoplePicker stack +N 該圓形):
264
+ * - `'tag'`(default,backward-compat)— 矩形 chip(對齊 Combobox 文字 tag)
265
+ * - `'circle'`(opt-in for avatar stack consumers)— 圓形 avatar-shape +N(對齊 GitHub picker idiom)
266
+ * PeoplePicker stack mode pass `'circle'`,Combobox 文字 tag 自走 `'tag'`。
267
+ */
268
+ overflowShape?: ComboboxOverflowShape
269
+ /**
270
+ * 2026-05-15 Bug 3 fix:override visible count via formula-based primitive(PeoplePicker stack
271
+ * 用 `avatar-stack-overflow` primitive deterministic formula 計算 visible,bypass DOM offsetWidth
272
+ * measurement)。對齊 user SSOT「同 cell width 同 overflow 判斷」。
273
+ */
274
+ visibleCountOverride?: number
275
+ }
276
+
277
+ function OverflowTagList({ containerRef, items, size, wrap, renderTag, renderHiddenTag, onRemove, trailing, tagWrapperClassName, overflowWrapperClassName, gap = GAP, overflowShape = 'tag', visibleCountOverride }: OverflowTagListProps) {
278
+ const tagEls = React.useRef<(HTMLDivElement | null)[]>([])
279
+ const overflowEl = React.useRef<HTMLDivElement>(null)
280
+ const { visibleCount, ready } = useOverflowCount(containerRef, tagEls, overflowEl, items.length, !wrap, gap, visibleCountOverride)
281
+ tagEls.current.length = items.length
282
+
283
+ if (wrap) return <>{items.map((item, i) => renderTag(item, i))}{trailing}</>
284
+
285
+ const overflow = items.length - visibleCount
286
+ const hiddenItems = items.slice(visibleCount)
287
+
288
+ // 2026-05-18 A' fix(per Codex Round 3 共識,user 拍板「執行」)— 撤掉舊的 `style={{ opacity: ready ? 1 : 0 }}`
289
+ // gate(掛在 `<span className="contents">` 上,但 CSS Display 3 spec 規定 `display:contents` 元素不產生 box,
290
+ // parent opacity 對 children 無效 → gate 從沒生效是 dead code)。Flicker 已改 sync calc in useLayoutEffect 解
291
+ // (L153 `calc()` 直跑,paint 前 hidden 設好)。`ready` state 保留供未來 instrumentation / debug,不再當 visual gate。
292
+ // 保留 `<span className="contents">` 維持 fragment-like rendering(parent JSX 期望 single child)。
293
+ void ready // intentional: ready 留供 future debug,目前無 consumer
294
+ return (
295
+ <span className="contents">
296
+ {items.map((item, i) => (
297
+ // 2026-05-14 I5 fix(per codex M31 verdict + user 抓「avatar stack 堆疊方向不一致」):
298
+ // 加 z-index per-index — 前 item z 高(對齊 MultiPersonDisplay zIndex: visible.length - i
299
+ // canonical + MUI AvatarGroup surplus pattern)。display + edit stack 堆疊方向統一。
300
+ <div key={item.value} ref={el => { tagEls.current[i] = el }} className={cn('shrink-0 max-w-full', tagWrapperClassName)} style={{ zIndex: items.length - i }}>{renderTag(item, i)}</div>
301
+ ))}
302
+ <div ref={overflowEl} className={cn('shrink-0', overflowWrapperClassName)}>
303
+ <OverflowIndicator count={overflow} shape={overflowShape} size={size}>
304
+ {hiddenItems.map(item => (
305
+ renderHiddenTag
306
+ ? <React.Fragment key={item.value}>{renderHiddenTag(item)}</React.Fragment>
307
+ : <Tag key={item.value} size="sm" onDismiss={onRemove ? () => onRemove(item.value) : undefined}>
308
+ {item.label}
309
+ </Tag>
310
+ ))}
311
+ </OverflowIndicator>
312
+ </div>
313
+ {trailing}
314
+ </span>
315
+ )
316
+ }
317
+
318
+ // ── Internal tag-stack renderer (consumed by ReadonlyMultiSelect / mode='display') ───
319
+ //
320
+ // Phase B2(2026-05-05):原 ComboboxDisplay sub-component 已 retire,改 inline `<Combobox mode="display">`。
321
+ // 本 helper 只負責 tag-stack 內容渲染(OverflowTagList 消費),不包 Field wrapper。
322
+ function ComboboxTagStack({
323
+ value, options, tagSize = 'md', wrap = false, containerRef: externalRef, disabled = false,
324
+ }: {
325
+ value?: string[] | null; options?: SelectOption[]; tagSize?: 'sm' | 'md' | 'lg'
326
+ wrap?: boolean; containerRef?: React.RefObject<HTMLDivElement | null>; disabled?: boolean
327
+ }) {
328
+ const ownRef = React.useRef<HTMLDivElement>(null)
329
+ if (!value || value.length === 0) return <span className="text-fg-muted">{EMPTY_DISPLAY}</span>
330
+ const items = value.map(v => ({ value: v, label: options?.find(o => o.value === v)?.label ?? v }))
331
+ const disabledClass = disabled ? 'bg-disabled text-fg-disabled' : undefined
332
+
333
+ const content = (
334
+ <OverflowTagList containerRef={externalRef ?? ownRef} items={items} size={tagSize} wrap={wrap}
335
+ // 2026-05-18 7B' fix(per user 拍板「執行」+ Codex Round 3 共識)— 移除 `unbounded`,Tag 回預設
336
+ // `max-w-40` cap(160px)+ 內建 `[data-tag-text] truncate min-w-0` 自帶 ellipsis。原 unbounded 是
337
+ // 「cell-as-input narrow cell < 160px」設計(`tag.tsx:85-90`),但 generic Combobox tag display
338
+ // 應走 Tag canonical cap-with-ellipsis(per `data-table.spec.md:235`「Tag 文字內部 truncate;
339
+ // multiSelect 動態 +N」+ `data-table.spec.md:467`「Tag 不可被外層 overflow-hidden 裁掉邊框」)。
340
+ // Trade-off:長 tag 觸發 +N 提前(160 + gap + overflowW > available 較 quick)— acceptable per user。
341
+ // Round 2 dynamic slot maxWidth 提案經 user + codex 共識撤回(3 chicken-and-egg fatal,KISS 勝)。
342
+ renderTag={(item) => <Tag size={tagSize} className={cn('shrink-0', disabledClass)}>{item.label}</Tag>} />
343
+ )
344
+
345
+ if (externalRef) return content
346
+ // 2026-05-05 v9 fix(Bug 4):display path 內 wrapper 必須 `flex-1 min-w-0`,否則在 cell flex
347
+ // parent 下不認領完整可用寬度 → OverflowTagList 量得寬度小於 edit path → 顯 `+N` 多於 edit。
348
+ // edit path tagAreaRef wrapper 已是 `flex-1 min-w-0`(NativeCombobox/CustomCombobox line 258 / 354),
349
+ // display 必對稱才 SSOT。
350
+ // 2026-05-15 F1 Q3 fix(per user round 3 verbatim「單人選取時 Tag 越界蓋 indicator」):
351
+ // `overflow-visible` → `overflow-hidden` 讓 narrow cell width 強制 clip(Tag 內建 truncate
352
+ // 處理 text ellipsis,stack `-ml-0.5` 負 margin 在 wrapper 內不受影響)。對齊
353
+ // `data-table.spec.md:233`「禁硬裁無 ellipsis」+ MUI X / Ant Table column.ellipsis 共識。
354
+ // (2026-05-14 nakedCellRowModeAlign 同保留 — autoRowHeight cell first-line align canonical。)
355
+ return (
356
+ <div ref={ownRef} className={cn('flex-1 min-w-0 flex items-center', nakedCellRowModeAlign, wrap ? 'flex-wrap' : 'overflow-hidden')} style={{ gap: GAP }}>
357
+ {content}
358
+ </div>
359
+ )
360
+ }
361
+
362
+ // ── Types ───────────────────────────────────────────────────────────────────
363
+
364
+ export interface ComboboxProps {
365
+ mode?: FieldMode
366
+ /** Field chrome variant. Default = context.variant ?? 'default'. Per-prop override. */
367
+ variant?: FieldVariant
368
+ error?: boolean
369
+ size?: 'sm' | 'md' | 'lg'
370
+ options: SelectOption[]
371
+ value?: string[]
372
+ onChange?: (value: string[]) => void
373
+ placeholder?: string
374
+ className?: string
375
+ disabled?: boolean
376
+ wrap?: boolean
377
+ clearable?: boolean
378
+ /** 啟用搜尋 */
379
+ searchable?: boolean
380
+ /** Loading state(2026-05-15 audit B fix per user verbatim「dropdown 隨時可開,讀取在 panel 中間 CircularProgress」)。
381
+ * Forward 給 SelectMenu primitive SSOT;dropdown 開啟時取代 options 顯 CircularProgress + loadingText。
382
+ * Trigger 不變(user 隨時可開)。對齊 MUI Autocomplete `loadingText` + Field SSOT + Empty 元件 compose。*/
383
+ loading?: boolean
384
+ /** 搜尋框位置:menu(浮層內,預設)或 trigger(inline input) */
385
+ searchIn?: 'menu' | 'trigger'
386
+ /** 搜尋框 placeholder(未有選項時顯示)。Default: 「搜尋…」 */
387
+ searchPlaceholder?: string
388
+ /** 搜尋框 ARIA label。Default: 「搜尋選項」 */
389
+ searchAriaLabel?: string
390
+ /** Empty-selection placeholder text。Default: 「選擇…」 */
391
+ emptyPlaceholder?: string
392
+ /** a11y:無 Field wrapper 時提供 role='combobox' 的 accessible name(axe aria-input-field-name) */
393
+ 'aria-label'?: string
394
+ /** Initial open state(uncontrolled)— 對齊 Select.defaultOpen / Radix Popover canonical。
395
+ * DataTable cell-as-input 1-step open 用 */
396
+ defaultOpen?: boolean
397
+ /** open state 變更 callback。DataTable cell-as-input 用:open=false → cell exit edit */
398
+ onOpenChange?: (open: boolean) => void
399
+ /**
400
+ * Selected tag pill 客製 render(2026-05-07 v15.5)。
401
+ *
402
+ * 設了 → 每個 selected tag pill 走 consumer 提供的 ReactNode(收 item={value, label}
403
+ * + onRemove,consumer 自己組 onDismiss);沒設 → 走預設 `<Tag>` text-only pill。
404
+ *
405
+ * 用例:PeoplePicker(multi)用此 slot 把 selected tag 換成 avatar + name pill,而非
406
+ * 純文字 Tag。對齊 PeoplePicker = Combobox wrapper SSOT。
407
+ */
408
+ tagRenderer?: (item: { value: string; label: string }, onRemove: () => void) => React.ReactNode
409
+ /**
410
+ * 2026-05-14 I4 fix:Optional renderer for hidden items in `+N` overflow popover
411
+ * (對齊 display MultiPersonDisplay overflow popover 含 avatar SSOT)。PeoplePicker stack
412
+ * pass 此 prop 讓 hidden items 顯 avatar + name(同 display path)。Default fallback
413
+ * `<Tag>{label}</Tag>` 純文字 backward-compat。
414
+ */
415
+ renderHiddenTag?: (item: { value: string; label: string }) => React.ReactNode
416
+ /**
417
+ * Optional class merged into each tag's outer measurement wrapper (2026-05-07 v15.13)。
418
+ * Stack avatar 模式用此 hook point 達成 sibling-level overlap (`-ml-0.5`) + group selector
419
+ * (`group/avatar`)— 既保留 Combobox 必要 measurement wrapper,又讓 dismiss/overlap 視覺生效。
420
+ */
421
+ tagWrapperClassName?: string
422
+ /** 2026-05-16:overflow chip wrapper 套此 class(對齊 tagWrapperClassName)。Stack 模式
423
+ * pass `-ml-0.5 first:ml-0` 讓 chip 跟 avatar 同 overlap step,物理上 chip = 1 個 slot 不
424
+ * 外加 24px。Default undefined = chip 不 overlap(text-tag mode 等)。*/
425
+ overflowWrapperClassName?: string
426
+ /**
427
+ * Tag area gap in px (2026-05-07 v15.13)。預設 4(pill mode 標準 spacing)。
428
+ * Stack avatar 模式傳 0,讓 `tagWrapperClassName` 的 `-ml-0.5` negative margin 生效
429
+ * (CSS `gap` 套在 flex container 上會強制 sibling spacing,蓋過 negative margin)。
430
+ * **Q2 known tradeoff**:0 後 useOverflowCount 仍按 wrapper.offsetWidth 累加(不含 overlap
431
+ * 補償)→ +N 偏保守。當前接受;若需精準可 future 加 `overlapPx` 補償邏輯。
432
+ */
433
+ tagAreaGapPx?: number
434
+ /**
435
+ * tagAreaRef container 左 paddingLeft(px,2026-05-12 加,for PeoplePicker Avatar inset)。
436
+ * Default undefined = no extra padding(Field wrapper `tagPadding[size]` calc 公式自然 inset)。
437
+ * 設值時 tagAreaRef 增 `style.paddingLeft`,**useOverflowCount 的 `available = clientWidth -
438
+ * paddingLeft - paddingRight` 自動 include**(`parseFloat(cs.paddingLeft)` 從 container CSS 抓)
439
+ * → width calc 不漂移,無 side-effect。
440
+ *
441
+ * **2026-05-13 v2 deprecate path**:原 PeoplePicker pass `{8}` 假設「Combobox tagPadding=4px,4+8=12」
442
+ * 但 `tagPadding[size]` 是 density-dependent calc `(field-height - icon-size) / 2`,只在 md size +
443
+ * default density 才 = 4px;其他 size/density 漂 6/8px → 4+8=12 公式破。改 PeoplePicker 直接 inject
444
+ * `!px-3` className 到 Combobox Field wrapper(per people-picker.spec.md:94 v2),`tagAreaPaddingLeftPx`
445
+ * 走 undefined。Future 仍保留此 prop 給其他 consumer 精準調整 padding,但 PeoplePicker 已不再用。
446
+ */
447
+ tagAreaPaddingLeftPx?: number
448
+ /**
449
+ * Overflow indicator (+N) 形狀(2026-05-12 Round 7,opt-in 給 avatar stack consumer):
450
+ * - `'tag'`(default)— 矩形 chip(Combobox 文字 tag default)
451
+ * - `'circle'`(opt-in)— 圓形 avatar-shape(PeoplePicker stack 用)
452
+ */
453
+ overflowShape?: ComboboxOverflowShape
454
+ /**
455
+ * 2026-05-15 Bug 3 fix:override visible count via formula-based primitive(opt-in;default 走
456
+ * DOM-based `useOverflowCount`)。PeoplePicker stack mode 用 `avatar-stack-overflow` primitive
457
+ * deterministic formula 計算 visible count,forward 給 Combobox bypass DOM offsetWidth
458
+ * measurement,避免 dual-algorithm drift。對齊 user SSOT「同 cell width 同 overflow 判斷」。
459
+ */
460
+ visibleCountOverride?: number
461
+ /**
462
+ * Display 是否渲 ChevronDown + Field naked wrapper(D-path opt-in,2026-05-08)
463
+ * — DataTable cell display↔edit 像素級對齊用。預設 false(裸 tag stack,backward compat)。
464
+ * 設 true 時 display 走 fieldWrapperStyles(naked variant)+ ItemSuffix ChevronDown,
465
+ * 與 edit 同 DOM 結構,消除 Layer-B padding mismatch。
466
+ */
467
+ showDisplayEndIcon?: boolean
468
+ }
469
+
470
+ // 2026-05-18 改 import ICON_SIZE SSOT(per user『做完』approval,消除 M17 違反)
471
+ const getIconSize = (size: string) => ICON_SIZE[size as 'sm' | 'md' | 'lg']
472
+
473
+ // ── Shared readonly/disabled/display render ─────────────────────────────────
474
+
475
+ function ReadonlyMultiSelect({
476
+ mode, variant: variantProp, size, options, value, wrap, className, showDisplayEndIcon = false,
477
+ }: Pick<ComboboxProps, 'mode' | 'variant' | 'size' | 'options' | 'value' | 'wrap' | 'className' | 'showDisplayEndIcon'>) {
478
+ const resolvedMode = mode ?? 'readonly'
479
+ const variant = variantProp ?? 'default'
480
+ const sz = size ?? 'md'
481
+ const iconSize = sz === 'lg' ? 20 : 16
482
+ const containerRef = React.useRef<HTMLDivElement>(null)
483
+ const hasTags = (value?.length ?? 0) > 0
484
+
485
+ // mode='display'(Phase B2 2026-05-05):純內容輸出 — tag stack 不包 Field wrapper / 不 reserve 高度。
486
+ // 對齊原 ComboboxDisplay sub-component(retired)。
487
+ // Opt-in(showDisplayEndIcon=true,2026-05-08 D-path):Field naked wrapper + ItemSuffix ChevronDown,
488
+ // 與 edit 同結構消除 cell display↔edit 像素偏移(Layer-B padding mismatch)。
489
+ if (resolvedMode === 'display') {
490
+ if (!showDisplayEndIcon) {
491
+ // 2026-05-14 I2 fix(spec contract (e) display typography canonical):empty bare span 套
492
+ // `fieldDisplayTextClass(sz)`(sm/md→text-body,lg→text-body-lg)— 對齊跨 Field family 統一。
493
+ if (!hasTags) return <span className={cn(fieldDisplayTextClass(sz), 'text-fg-muted', className)}>{EMPTY_DISPLAY}</span>
494
+ return (
495
+ <ComboboxTagStack value={value} options={options} tagSize={sz} wrap={wrap} />
496
+ )
497
+ }
498
+ return (
499
+ <div
500
+ className={cn(fieldWrapperStyles({ mode: 'display', variant, size: sz }), hasTags && tagPadding[sz], className)}
501
+ data-field-mode="display"
502
+ >
503
+ {hasTags ? (
504
+ <ComboboxTagStack value={value} options={options} tagSize={sz} wrap={wrap} />
505
+ ) : (
506
+ <span className={cn('flex-1 min-w-0', 'text-fg-muted')}>{EMPTY_DISPLAY}</span>
507
+ )}
508
+ <ItemSuffix className="pointer-events-none">
509
+ <ChevronDown size={iconSize} className="shrink-0 text-fg-muted" aria-hidden />
510
+ </ItemSuffix>
511
+ </div>
512
+ )
513
+ }
514
+
515
+ return (
516
+ <div ref={containerRef}
517
+ className={cn(fieldWrapperStyles({ mode: resolvedMode, variant, size: sz }), hasTags && tagPadding[sz],
518
+ // 2026-05-18 #6A Round 1 Step 1/4(per user 拍板「決策6選a」+ codex M31 Step 5 verdict cite combobox.tsx:451):
519
+ // readonly/disabled path 對齊 L293 display wrapper 已 ship 的 overflow-hidden fix。
520
+ // M10 propagation:原 overflow-visible 讓 readonly tag 越界蓋 indicator,跟 display 不對稱。
521
+ wrap ? 'flex-wrap py-1' : 'overflow-hidden', className)}
522
+ style={{ gap: GAP, ...(wrap ? { height: 'auto' } : undefined) }} data-field-mode={resolvedMode}>
523
+ {hasTags ? (
524
+ <ComboboxTagStack value={value} options={options} tagSize={sz} wrap={wrap}
525
+ containerRef={containerRef} disabled={resolvedMode === 'disabled'} />
526
+ ) : (
527
+ <span className="text-fg-muted">{EMPTY_DISPLAY}</span>
528
+ )}
529
+ </div>
530
+ )
531
+ }
532
+
533
+ // ── Native Combobox (mobile) ────────────────────────────────────────
534
+
535
+ // 2026-05-16 Bug A root cause fix(Claude+Codex M31 Step 5 比稿 consensus,user verbatim
536
+ // 「圖二/圖三 同 180px 不同 length 不同 visible — 跟 user 一開始抓的問題一模一樣」):
537
+ // 公開 `Combobox.forwardRef` 之前用 `(props, _ref)` 把 ref drop,內部 `NativeCombobox` /
538
+ // `CustomCombobox` 從未拿 ref → PeoplePicker `stackContainerRef.current` 永遠 null →
539
+ // `useLayoutEffect` early return → `visibleCountOverride` 永遠 undefined →
540
+ // Combobox 走原 internal `useOverflowCount` 60px chip fallback bug → drift。
541
+ // Fix:internal `__triggerRef` prop(underscore = internal-only)attach root div;
542
+ // 公開 `Combobox.forwardRef` 把 `ref` forward 為 `__triggerRef`。對齊 codex DS-wide iceberg
543
+ // audit:`SelectMenu` / `DateGrid` / `Toast` 的 `_ref` 是 intentional documented(no DOM
544
+ // target);唯本處 actionable drop。
545
+ type ComboboxInternalProps = ComboboxProps & { __triggerRef?: React.Ref<HTMLDivElement> }
546
+
547
+ function NativeCombobox({
548
+ mode = 'edit', variant: variantProp, error = false, size = 'md', options, value = [], onChange, placeholder,
549
+ className, disabled, wrap = false, clearable = false, showDisplayEndIcon = false,
550
+ __triggerRef,
551
+ }: ComboboxInternalProps) {
552
+ const fieldCtx = useFieldContext()
553
+ const variant: FieldVariant = variantProp ?? fieldCtx?.variant ?? 'default'
554
+ const resolvedMode = disabled ? 'disabled' : mode
555
+ const iconSize = getIconSize(size)
556
+ const showClear = clearable && value.length > 0 && resolvedMode === 'edit'
557
+
558
+ const handleRemove = (v: string) => onChange?.(value.filter(x => x !== v))
559
+ const handleAdd = (v: string) => { if (!value.includes(v)) onChange?.([...value, v]) }
560
+
561
+ if (resolvedMode !== 'edit') {
562
+ return <ReadonlyMultiSelect mode={resolvedMode} variant={variant} size={size} options={options} value={value} wrap={wrap} className={className} showDisplayEndIcon={showDisplayEndIcon} />
563
+ }
564
+
565
+ const items = value.map(v => ({ value: v, label: options.find(o => o.value === v)?.label ?? v }))
566
+ const unselected = options.filter(o => !value.includes(o.value))
567
+ const selectRef = React.useRef<HTMLSelectElement>(null)
568
+ const tagAreaRef = React.useRef<HTMLDivElement>(null)
569
+ const tagHeight = size === 'sm' ? 20 : 24
570
+
571
+ const selectDropdown = unselected.length > 0 ? (
572
+ <select ref={selectRef} value="" onChange={(e) => handleAdd(e.target.value)}
573
+ className={cn('bg-transparent outline-none border-none p-0 text-[inherit] font-[inherit] leading-[inherit] text-fg-muted cursor-pointer appearance-none',
574
+ value.length > 0 ? 'absolute inset-0 w-full h-full opacity-0 z-0 cursor-pointer' : 'relative z-10 flex-1 min-w-20')}>
575
+ <option value="" disabled>{placeholder ?? '選擇...'}</option>
576
+ {unselected.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
577
+ </select>
578
+ ) : null
579
+
580
+ return (
581
+ <div ref={__triggerRef} className={cn(fieldWrapperStyles({ mode: 'edit', variant: variant, size }), value.length > 0 && tagPadding[size], 'relative',
582
+ wrap && 'items-start py-1', error && ['border-error hover:border-error-hover', 'focus-within:border-error focus-within:hover:border-error'], className)}
583
+ style={{ paddingRight: '0.75rem', ...(wrap ? { height: 'auto' } : undefined) }} data-field-mode="edit" data-error={error ? '' : undefined}
584
+ onClick={(e) => { if (e.target === e.currentTarget) { selectRef.current?.showPicker?.(); selectRef.current?.focus() } }}>
585
+ {/* 2026-05-18 F2 sync(per user verbatim「modifying 修好 PeoplePicker stack 後改壞 Combobox tag display」
586
+ + 「tag 應該要判斷所在空間最多可以呈現幾個tag(包括+n)去自動判斷何時要變成+n」):
587
+ edit path tagArea 對齊 display path L293 已 ship 的 `overflow-hidden` fix。原 `overflow-visible`
588
+ 讓 tag 視覺越界蓋 chevron / +N indicator(useOverflowCount measurement 對但 CSS overflow 仍露)。
589
+ M10 violation root cause:2026-05-15 F1 Q3 只 fix display path,edit + Native(L518)沒同步。 */}
590
+ <div ref={tagAreaRef} className={cn('flex-1 min-w-0 flex items-center relative', nakedCellRowModeAlign, wrap ? 'flex-wrap' : 'overflow-hidden')} style={{ gap: GAP }}
591
+ onClick={(e) => { if (e.target === e.currentTarget) { selectRef.current?.showPicker?.(); selectRef.current?.focus() } }}>
592
+ <OverflowTagList containerRef={tagAreaRef} items={items} size={size} wrap={wrap}
593
+ renderTag={(item) => (
594
+ <Tag size={size} className="shrink-0 relative z-10" onClick={() => { selectRef.current?.showPicker?.(); selectRef.current?.focus() }}
595
+ onDismiss={() => handleRemove(item.value)}>{item.label}</Tag>
596
+ )} onRemove={handleRemove} trailing={value.length === 0 ? selectDropdown : undefined} />
597
+ </div>
598
+ {value.length > 0 && selectDropdown}
599
+ <ItemSuffix className={cn('relative z-10 pointer-events-none', wrap && 'self-start')}
600
+ style={wrap ? { height: tagHeight } : undefined}>
601
+ {showClear && (
602
+ <span className="pointer-events-auto">
603
+ <ItemInlineAction
604
+ size={size ?? 'md'}
605
+ action={{ icon: X, label: '清除全部', onClick: () => onChange?.([]) }} // i18n-allow: DS default inline-action label
606
+ />
607
+ </span>
608
+ )}
609
+ <ChevronDown size={iconSize} className="shrink-0 text-fg-muted pointer-events-none" aria-hidden />
610
+ </ItemSuffix>
611
+ </div>
612
+ )
613
+ }
614
+
615
+ // ── Custom Combobox (desktop — consumes SelectMenu) ───────────────────
616
+
617
+ function CustomCombobox({
618
+ mode = 'edit', variant: variantProp, error: errorProp = false, size = 'md', options, value = [], onChange, placeholder,
619
+ className, disabled: disabledProp, wrap = false, clearable = false, searchable = false, loading, searchIn = 'menu',
620
+ searchPlaceholder = '搜尋…', // i18n-allow: DS default
621
+ searchAriaLabel = '搜尋選項', // i18n-allow: DS default
622
+ emptyPlaceholder = '選擇…', // i18n-allow: DS default
623
+ defaultOpen = false,
624
+ onOpenChange,
625
+ __triggerRef,
626
+ tagRenderer,
627
+ renderHiddenTag,
628
+ tagWrapperClassName,
629
+ overflowWrapperClassName,
630
+ tagAreaGapPx,
631
+ visibleCountOverride,
632
+ tagAreaPaddingLeftPx,
633
+ overflowShape,
634
+ showDisplayEndIcon = false,
635
+ 'aria-label': ariaLabel,
636
+ }: ComboboxInternalProps) {
637
+ const tagAreaGap = tagAreaGapPx ?? GAP
638
+ const fieldCtx = useFieldContext()
639
+ const error = errorProp || (fieldCtx?.invalid ?? false)
640
+ const disabled = disabledProp ?? fieldCtx?.disabled
641
+ const resolvedMode = disabled ? 'disabled' : mode
642
+ const variant: FieldVariant = variantProp ?? fieldCtx?.variant ?? 'default'
643
+ const iconSize = getIconSize(size)
644
+ const showClear = clearable && value.length > 0 && resolvedMode === 'edit'
645
+ const [open, setOpen] = React.useState(defaultOpen)
646
+ const [search, setSearch] = React.useState('')
647
+ // 2026-05-12 Q3 fix:trigger 內 inline 搜尋 input ref,onOpenAutoFocus 時 explicit focus
648
+ // 讓 user 看到 cursor 知道可 inline search(跟 Select inputRef SSOT 同模式)。
649
+ const inputRef = React.useRef<HTMLInputElement>(null)
650
+ // a11y: 為 listbox 容器(SelectMenu 內 PopoverContent)建立穩定 id,讓 trigger 的
651
+ // aria-controls 能指向它(WAI-ARIA combobox pattern 要求)。React.useId 在 SSR/CSR 都穩定。
652
+ const listboxId = React.useId()
653
+
654
+ React.useEffect(() => { if (!open) setSearch('') }, [open])
655
+
656
+ if (resolvedMode !== 'edit') {
657
+ return <ReadonlyMultiSelect mode={resolvedMode} variant={variant} size={size} options={options} value={value} wrap={wrap} className={className} showDisplayEndIcon={showDisplayEndIcon} />
658
+ }
659
+
660
+ const items = React.useMemo(
661
+ () => value.map(v => ({ value: v, label: options.find(o => o.value === v)?.label ?? v })),
662
+ [value, options]
663
+ )
664
+ const tagAreaRef = React.useRef<HTMLDivElement>(null)
665
+ const tagHeight = size === 'sm' ? 20 : 24
666
+
667
+ const handleRemove = (v: string) => onChange?.(value.filter(x => x !== v))
668
+
669
+ // searchIn='trigger' 時由 trigger input 過濾,不走 SelectMenu 內建搜尋
670
+ const filteredOptions = React.useMemo(
671
+ () => (searchable && searchIn === 'trigger' && search
672
+ ? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
673
+ : options),
674
+ [searchable, searchIn, search, options]
675
+ )
676
+
677
+ // 轉換 SelectOption → SelectMenuOption
678
+ // 2026-05-10 post-Issue-4 follow-up:forward 全 SelectMenuOption surface(avatar / description /
679
+ // disabled / icon / group)— 修先前 PeoplePicker multi-mode dropdown 漏 avatar drift bug。
680
+ const menuOptions: SelectMenuOption[] = React.useMemo(
681
+ () => filteredOptions.map(opt => ({
682
+ value: opt.value,
683
+ label: opt.label,
684
+ icon: opt.icon,
685
+ avatar: opt.avatar,
686
+ description: opt.description,
687
+ disabled: opt.disabled,
688
+ group: opt.group,
689
+ })),
690
+ [filteredOptions]
691
+ )
692
+
693
+ const chevronEl = <ChevronDown size={iconSize} className={cn('shrink-0 text-fg-muted transition-transform', open && 'rotate-180')} aria-hidden />
694
+
695
+ const trigger = (
696
+ <div
697
+ ref={__triggerRef}
698
+ id={fieldCtx?.id}
699
+ role="combobox" aria-expanded={open} aria-controls={listboxId} tabIndex={0}
700
+ aria-label={ariaLabel}
701
+ aria-invalid={error || undefined}
702
+ aria-required={fieldCtx?.required || undefined}
703
+ aria-describedby={fieldCtx?.descriptionId}
704
+ aria-errormessage={error ? fieldCtx?.errorId : undefined}
705
+ className={cn(fieldWrapperStyles({ mode: 'edit', variant: variant, size }), value.length > 0 && tagPadding[size], 'relative cursor-pointer',
706
+ wrap && 'items-start py-1',
707
+ // 2026-05-06 v13.3 SSOT retire:per-control `open && 'border-primary'` 移除。Field default
708
+ // 統一處理 — open=灰深(data-state)/ focus=藍(focus-within !important)。改一處全 control 跟動。
709
+ error && ['border-error hover:border-error-hover', 'focus-within:border-error focus-within:hover:border-error'], className)}
710
+ style={{ paddingRight: '0.75rem', ...(wrap ? { height: 'auto' } : undefined) }}
711
+ data-field-mode="edit" data-error={error ? '' : undefined}>
712
+ {/* 2026-05-18 #6A Round 1 Step 2/4(per user 拍板「決策6選a」+ codex M31 Step 5 verdict cite combobox.tsx:648):
713
+ CustomCombobox edit non-wrap tagArea 對齊 L293 display + L451 readonly + L518 native edit 已 ship 的 overflow-hidden fix。
714
+ 原 overflow-visible 讓 tag 越界蓋 chevron / +N indicator(user 圖三)。M10 propagation 完整 4-path align。 */}
715
+ <div ref={tagAreaRef} className={cn('flex-1 min-w-0 flex items-center relative', nakedCellRowModeAlign, wrap ? 'flex-wrap' : 'overflow-hidden')} style={{ gap: tagAreaGap, paddingLeft: tagAreaPaddingLeftPx }}>
716
+ {value.length > 0 ? (
717
+ <OverflowTagList containerRef={tagAreaRef} items={items} size={size} wrap={wrap}
718
+ tagWrapperClassName={tagWrapperClassName}
719
+ overflowWrapperClassName={overflowWrapperClassName}
720
+ gap={tagAreaGap}
721
+ overflowShape={overflowShape}
722
+ visibleCountOverride={visibleCountOverride}
723
+ renderTag={(item) => (
724
+ tagRenderer
725
+ ? tagRenderer(item, () => handleRemove(item.value))
726
+ : <Tag size={size} className="shrink-0 relative z-10"
727
+ onDismiss={() => handleRemove(item.value)}>{item.label}</Tag>
728
+ )}
729
+ renderHiddenTag={renderHiddenTag}
730
+ onRemove={handleRemove}
731
+ trailing={searchable && searchIn === 'trigger' ? (
732
+ <input ref={inputRef} value={search} onChange={(e) => setSearch(e.target.value)}
733
+ // 2026-05-15 Drift A fix(per user verbatim SSOT clarification「未選 → placeholder 顯示請選擇之類」):
734
+ // items.length === 0(empty selection)→ 用 `placeholder` trigger empty prop(「請選擇…」),
735
+ // **不**用 `searchPlaceholder`(「搜尋…」);後者僅在 panel-top search input 場景才合理。
736
+ // items.length > 0(已選)→ no placeholder,純 cursor(對齊 Combobox empty cursor SSOT)。
737
+ // SSOT 對齊 select.tsx:185 `placeholder={selectedLabel || placeholder || '搜尋…'}`
738
+ // empty-state fallback to trigger placeholder canonical。
739
+ placeholder={items.length === 0 ? placeholder : ''} onClick={(e) => { e.stopPropagation(); setOpen(true) }}
740
+ aria-label={searchAriaLabel}
741
+ className="flex-1 min-w-[60px] bg-transparent outline-none text-body leading-compact relative z-10" />
742
+ ) : undefined} />
743
+ ) : (
744
+ /* 2026-05-12 Stream C Issue 3 fix(codex Q3 Cluster C):placeholder span 必 flex-1 min-w-0
745
+ truncate,narrow container 時單行省略(對齊 Combobox text-tag truncate canonical)。
746
+ 原 hardcode wraps in narrow trigger → user 抓「placeholder 文字 wrap multi-line」。 */
747
+ <span className="flex-1 min-w-0 truncate text-fg-muted">{placeholder ?? emptyPlaceholder}</span>
748
+ )}
749
+ </div>
750
+ <ItemSuffix className={cn('relative z-10 pointer-events-none', wrap && 'self-start')}
751
+ style={wrap ? { height: tagHeight } : undefined}>
752
+ {showClear && (
753
+ <span className="pointer-events-auto">
754
+ <ItemInlineAction
755
+ size={size ?? 'md'}
756
+ action={{
757
+ icon: X,
758
+ label: '清除全部', // i18n-allow: DS default inline-action label
759
+ onClick: (e) => { e?.stopPropagation(); onChange?.([]) },
760
+ }}
761
+ />
762
+ </span>
763
+ )}
764
+ {chevronEl}
765
+ </ItemSuffix>
766
+ </div>
767
+ )
768
+
769
+ return (
770
+ <SelectMenu
771
+ loading={loading}
772
+ options={menuOptions}
773
+ value={value}
774
+ onValueChange={onChange as (value: string | string[]) => void}
775
+ multiple
776
+ searchable={searchable && searchIn === 'menu'}
777
+ searchPlaceholder={searchPlaceholder}
778
+ size={size}
779
+ open={open}
780
+ onOpenChange={(o) => { setOpen(o); onOpenChange?.(o) }}
781
+ // 2026-05-12 Q3 fix(user 抓「inline-searchable 開浮層應出現 cursor」)— 跟 Select line 550
782
+ // 同 SSOT pattern:preventDefault Radix default focus + 顯式 focus inline input → 開時
783
+ // cursor 直接 visible,user 知道可 inline search。
784
+ onOpenAutoFocus={searchIn === 'trigger' ? (e) => { e.preventDefault(); inputRef.current?.focus() } : undefined}
785
+ contentId={listboxId}
786
+ >
787
+ {trigger}
788
+ </SelectMenu>
789
+ )
790
+ }
791
+
792
+ // ── Public component ────────────────────────────────────────────────────────
793
+
794
+ const Combobox = React.forwardRef<HTMLDivElement, ComboboxProps>(
795
+ (props, ref) => {
796
+ // 2026-05-16 真 root cause fix:之前用 `_ref` drop ref。修為 forward 給 internal
797
+ // `__triggerRef`,讓 PeoplePicker stack 透過 ref 量 trigger DOM(visibleCountOverride
798
+ // 才生效)。對齊 React forwardRef public-API canonical(MUI Autocomplete / Radix
799
+ // Popover.Trigger 共識)+ codex M31 Step 5 比稿 verdict + DS-wide ref-drop iceberg audit。
800
+ const isMobile = useIsTouchDevice()
801
+ if (isMobile) return <NativeCombobox {...props} __triggerRef={ref} />
802
+ return <CustomCombobox {...props} __triggerRef={ref} />
803
+ }
804
+ )
805
+ Combobox.displayName = 'Combobox'
806
+
807
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
808
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
809
+ export const comboboxMeta = {
810
+ component: 'Combobox',
811
+ family: 4,
812
+ variants: {
813
+
814
+ },
815
+ sizes: {
816
+
817
+ },
818
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
819
+ tokens: {
820
+ bg: ['bg-disabled', 'bg-transparent'],
821
+ fg: ['text-fg-disabled', 'text-fg-muted'],
822
+ ring: [],
823
+ },
824
+ } as const
825
+
826
+ export { Combobox }