@qijenchen/design-system 0.1.0-beta.111 → 0.1.0-beta.113

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 (32) hide show
  1. package/dist/components/Combobox/combobox.d.ts.map +1 -1
  2. package/dist/components/Combobox/combobox.js +25 -6
  3. package/dist/components/Combobox/combobox.js.map +1 -1
  4. package/dist/components/PeoplePicker/people-picker.d.ts +7 -0
  5. package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -1
  6. package/dist/components/PeoplePicker/people-picker.js +12 -5
  7. package/dist/components/PeoplePicker/people-picker.js.map +1 -1
  8. package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
  9. package/dist/components/PeoplePicker/person-display.js +4 -1
  10. package/dist/components/PeoplePicker/person-display.js.map +1 -1
  11. package/dist/components/Select/select.d.ts.map +1 -1
  12. package/dist/components/Select/select.js +21 -3
  13. package/dist/components/Select/select.js.map +1 -1
  14. package/dist/patterns/element-anatomy/item-anatomy.js +1 -1
  15. package/ds-canonical/fork/consumer/lock.json +6 -6
  16. package/ds-canonical/fork/consumer/managed-files/0235cf6b3fd402a3717881dc205b5bf77aed7632e7d7d6f1811a40e3033bd816.blob +2 -2
  17. package/ds-canonical/fork/consumer/managed-files/9791c85388d2e2e2bd564e2550fadc3351c4c960a47ce838098afcba7544eb90.blob +11 -2
  18. package/ds-canonical/fork/governance.lock +3 -3
  19. package/ds-canonical/fork/manifest.json +10 -10
  20. package/ds-story-manifest.json +5 -1
  21. package/package.json +2 -2
  22. package/src/components/Combobox/combobox.spec.md +2 -0
  23. package/src/components/Combobox/combobox.tsx +42 -7
  24. package/src/components/Field/field.spec.md +10 -0
  25. package/src/components/FileUpload/file-upload.stories.tsx +15 -4
  26. package/src/components/PeoplePicker/people-picker.spec.md +24 -0
  27. package/src/components/PeoplePicker/people-picker.stories.tsx +51 -2
  28. package/src/components/PeoplePicker/people-picker.tsx +32 -5
  29. package/src/components/PeoplePicker/person-display.tsx +4 -1
  30. package/src/components/Select/select.spec.md +3 -1
  31. package/src/components/Select/select.tsx +26 -2
  32. package/src/components/Steps/steps.stories.tsx +4 -2
@@ -18,6 +18,15 @@ import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
18
18
 
19
19
  const GAP = 4
20
20
 
21
+ // 非-wrap tag 列 clip SSOT(4-path 共用:OverflowTagList base / readonly / native edit / custom edit):
22
+ // 2026-05-18 F2 以 overflow-hidden 修「窄容器 tags 水平越界蓋 chevron / +N」,但雙軸 hidden 副作用
23
+ // 是垂直同裁 — PeoplePicker stack hover 的 AvatarDismissOverlay(`-top-px` + 2px ring,
24
+ // person-display.tsx v15.15,早於 F2)上緣被切(user 2026-08-05 抓「曾經沒有這問題」= F2 前確實沒有)。
25
+ // 雙贏 = overflow-x-clip:水平維持 clip(F2 bug 不回歸;`clip` 不建 scroll container),垂直 visible
26
+ // (overlay / avatar ring 完整)。**不可**改 overflow-x-hidden:單軸 hidden 令另一軸 visible 計算成
27
+ // auto → 生 scroll container,垂直反而可捲。
28
+ const tagRowOverflowClass = 'overflow-x-clip'
29
+
21
30
  const tagPadding: Record<string, string> = {
22
31
  sm: 'px-[calc((var(--field-height-sm)_-_1.25rem)_/_2)]',
23
32
  md: 'px-[calc((var(--field-height-md)_-_1.5rem)_/_2)]',
@@ -355,7 +364,7 @@ function ComboboxTagStack({
355
364
  // `data-table.spec.md:233`「禁硬裁無 ellipsis」+ MUI X / Ant Table column.ellipsis 共識。
356
365
  // (2026-05-14 nakedCellRowModeAlign 同保留 — autoRowHeight cell first-line align canonical。)
357
366
  return (
358
- <div ref={ownRef} className={cn('flex-1 min-w-0 flex items-center', nakedCellRowModeAlign, wrap ? 'flex-wrap' : 'overflow-hidden')} style={{ gap: GAP }}>
367
+ <div ref={ownRef} className={cn('flex-1 min-w-0 flex items-center', nakedCellRowModeAlign, wrap ? 'flex-wrap' : tagRowOverflowClass)} style={{ gap: GAP }}>
359
368
  {content}
360
369
  </div>
361
370
  )
@@ -551,7 +560,7 @@ function ReadonlyMultiSelect({
551
560
  // M10 propagation:原 overflow-visible 讓 readonly tag 越界蓋 indicator,跟 view 不對稱。
552
561
  // 2026-06-27 對齊 edit path(L598-617):wrap 時 items-start + chevron self-start/tagHeight 鎖第一行;
553
562
  // paddingRight: var(--field-px) re-assert 右緣 12px(tagPadding 對稱 calc 會吃掉右緣,跟 edit 一致)。
554
- wrap ? 'flex-wrap items-start py-1' : 'overflow-hidden', className)}
563
+ wrap ? 'flex-wrap items-start py-1' : tagRowOverflowClass, className)}
555
564
  style={{ gap: GAP, paddingRight: 'var(--field-px)', ...(wrap ? { height: 'auto' } : undefined) }} data-field-mode={resolvedMode}
556
565
  aria-disabled={resolvedMode === 'disabled' ? true : undefined}>
557
566
  {hasTags ? (
@@ -614,6 +623,11 @@ function focusAfterTagRemoval(container: HTMLElement | null, owner: HTMLElement
614
623
  function NativeCombobox({
615
624
  mode, variant: variantProp, width, error = false, size = 'md', options, value = [], onChange, placeholder,
616
625
  className, disabled: disabledProp, wrap = false, clearable = false, showDisplayEndIcon = false,
626
+ // Display-layer parity(2026-08-05 user 拍板):renderer / overflow props 必雙分支(custom/native)
627
+ // 同消費 — 原 native 硬編碼 <Tag> 令 PeoplePicker 手機 edit 掉回文字 pill、avatar stack 全滅
628
+ // (touch 分支從未接 display 層的病根)。新增 renderer-affecting prop 時必同步本 destructure。
629
+ tagRenderer, renderHiddenTag, tagWrapperClassName, overflowWrapperClassName,
630
+ overflowShape, visibleCountOverride, tagAreaGapPx, tagAreaPaddingLeftPx,
617
631
  __triggerRef,
618
632
  'aria-label': ariaLabel,
619
633
  }: ComboboxInternalProps) {
@@ -646,6 +660,7 @@ function NativeCombobox({
646
660
  const items = value.map(v => ({ value: v, label: options.find(o => o.value === v)?.label ?? v }))
647
661
  const unselected = options.filter(o => !value.includes(o.value))
648
662
  const tagHeight = size === 'sm' ? 20 : 24
663
+ const tagAreaGap = tagAreaGapPx ?? GAP
649
664
 
650
665
  const selectDropdown = unselected.length > 0 ? (
651
666
  <select ref={selectRef} value="" onChange={(e) => handleAdd(e.target.value)}
@@ -675,13 +690,33 @@ function NativeCombobox({
675
690
  edit path tagArea 對齊 view path L293 已 ship 的 `overflow-hidden` fix。原 `overflow-visible`
676
691
  讓 tag 視覺越界蓋 chevron / +N indicator(useOverflowCount measurement 對但 CSS overflow 仍露)。
677
692
  M10 violation root cause:2026-05-15 F1 Q3 只 fix display path,edit + Native(L518)沒同步。 */}
678
- <div ref={tagAreaRef} className={cn('flex-1 min-w-0 flex items-center relative', nakedCellRowModeAlign, wrap ? 'flex-wrap' : 'overflow-hidden')} style={{ gap: GAP }}
693
+ {/* Display-layer parity(2026-08-05):OverflowTagList 消費與 CustomCombobox(L859-867)同一組
694
+ renderer / overflow props — tagRenderer 存在(PeoplePicker avatar stack 等)→ 手機 edit 與
695
+ 桌機同視覺;未傳 → 原 <Tag> 文字 pill 預設不變。 */}
696
+ <div ref={tagAreaRef} className={cn('flex-1 min-w-0 flex items-center relative', nakedCellRowModeAlign, wrap ? 'flex-wrap' : tagRowOverflowClass)} style={{ gap: tagAreaGap, paddingLeft: tagAreaPaddingLeftPx }}
679
697
  onClick={(e) => { if (e.target === e.currentTarget) { selectRef.current?.showPicker?.(); selectRef.current?.focus() } }}>
680
698
  <OverflowTagList containerRef={tagAreaRef} items={items} size={size} wrap={wrap}
699
+ tagWrapperClassName={tagWrapperClassName}
700
+ // Review fix(2026-08-05 F2):+N 抬到 overlay 上(可見不被壓)且 pointer 穿透 —
701
+ // touch 無 hover 開不了 HoverCard,tap 穿透開原生 picker(原生多選單列全員含 hidden,
702
+ // 即 touch 的 overflow 檢視/增刪路徑)。桌機 CustomCombobox 呼叫端不變。
703
+ overflowWrapperClassName={cn(overflowWrapperClassName, 'relative z-10 pointer-events-none')}
704
+ gap={tagAreaGap}
705
+ overflowShape={overflowShape}
706
+ visibleCountOverride={visibleCountOverride}
681
707
  renderTag={(item) => (
682
- <Tag size={size} className="shrink-0 relative z-10" onClick={() => { selectRef.current?.showPicker?.(); selectRef.current?.focus() }}
683
- onRemove={() => handleRemove(item.value)}>{item.label}</Tag>
684
- )} onRemove={handleRemove} trailing={value.length === 0 ? selectDropdown : undefined} />
708
+ tagRenderer
709
+ // Review fix(2026-08-05 F1/F3):renderer 輸出必套「抬升+穿透+按鈕回收」三件套 —
710
+ // relative z-10 抬到透明 native <select> overlay(z-0)之上(對齊 default <Tag> 的
711
+ // z-10;wrap 分支 OverflowTagList 無 wrapper div 時尤其必要);pointer-events-none 讓
712
+ // 非互動顯示區 tap 穿透 overlay(spec「點擊任何位置喚起原生 picker」);[&_button]:auto
713
+ // 回收 remove X / Tag onRemove 點擊。
714
+ ? <span className="relative z-10 min-w-0 flex-1 inline-flex items-center pointer-events-none [&_button]:pointer-events-auto">{tagRenderer(item, () => handleRemove(item.value))}</span>
715
+ : <Tag size={size} className="shrink-0 relative z-10" onClick={() => { selectRef.current?.showPicker?.(); selectRef.current?.focus() }}
716
+ onRemove={() => handleRemove(item.value)}>{item.label}</Tag>
717
+ )}
718
+ renderHiddenTag={renderHiddenTag}
719
+ onRemove={handleRemove} trailing={value.length === 0 ? selectDropdown : undefined} />
685
720
  </div>
686
721
  {value.length > 0 && selectDropdown}
687
722
  <ItemSuffix className={cn('relative z-10 pointer-events-none', wrap && 'self-start')}
@@ -844,7 +879,7 @@ function CustomCombobox({
844
879
  {/* 2026-05-18 #6A Round 1 Step 2/4(per user 拍板「決策6選a」+ codex M31 Step 5 verdict cite combobox.tsx:648):
845
880
  CustomCombobox edit non-wrap tagArea 對齊 L293 view + L451 readonly + L518 native edit 已 ship 的 overflow-hidden fix。
846
881
  原 overflow-visible 讓 tag 越界蓋 chevron / +N indicator(user 圖三)。M10 propagation 完整 4-path align。 */}
847
- <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 }}>
882
+ <div ref={tagAreaRef} className={cn('flex-1 min-w-0 flex items-center relative', nakedCellRowModeAlign, wrap ? 'flex-wrap' : tagRowOverflowClass)} style={{ gap: tagAreaGap, paddingLeft: tagAreaPaddingLeftPx }}>
848
883
  {value.length > 0 ? (
849
884
  <OverflowTagList containerRef={tagAreaRef} items={items} size={size} wrap={wrap}
850
885
  tagWrapperClassName={tagWrapperClassName}
@@ -59,6 +59,16 @@ Field 和 Field Controls(Input / NumberInput / DatePicker / Select / Combobox
59
59
 
60
60
  子元件寫法採**扁平結構**(直接作為 Field children),Field 內部用 `displayName` 判別 slot 類型並自動組合。consumer 不需要包 wrapper。
61
61
 
62
+ ### Field 次要 action 的預留位置(canonical,尚未實作)
63
+
64
+ Field 若需要次要 action(如「重設」「全選」「從範本帶入」),**唯一預留位置 = label 行最右端**(label 與 action 兩端對齊):
65
+
66
+ - 形式:超連結樣文字(link-like text),**字級與行高皆同 FieldLabel**(`text-body` 14px、同 label 的 leading)——同字級同行高即天然等高,這是「不增高」的機制核心(user 2026-08-05 確認)。
67
+ - 硬約束:**不得增加 label 行高度**(避免 field 被撐高)。等高機制之外還需三個盒模型條件缺一不可:(1) **零垂直 padding、無固定高度盒**——禁用 DS `Button`(xs 也自帶 `h-field-xs` 固定盒 + 自身 leading,`button.tsx` xs variant),必須是純文字元素;(2) **單行 `whitespace-nowrap`**(換行即行盒 ×2);(3) **hit target 以 pseudo-element/負 margin 擴大**,不得用 padding 滿足 a11y 觸控目標(underline/focus ring 不佔佈局,無礙)。
68
+ - 此位置是 field 次要 action 的**專屬 slot**——禁止把次要 action 放在 control 下方、error 位置或 field 外圍(那些位置屬 description/error/表單層動作)。
69
+ - 對齊世界級:Polaris TextField `labelAction`(https://polaris.shopify.com/components/selection-and-input/text-field 的「With label action」example;2026-08-05 WebFetch 驗證 prop 存在)。
70
+ - 狀態:**2026-08-05 user 拍板記錄,尚未實作**。實作時走 `<FieldLabelAction>` 類 slot + `/component-quality-gate`;在那之前任何 story/產品範例**不得**自行發明次要 action 擺位(anchor:PeoplePicker 多人 story 曾把 play() 測試用「重設協作者」按鈕誤植為 control 下方的產品 UI,2026-08-05 移除)。
71
+
62
72
  ---
63
73
 
64
74
  ## 樣式規範(全部 codify 在 field.tsx,不依賴 consumer)
@@ -85,9 +85,7 @@ export const BulkImageUpload = {
85
85
  // 不傳 `files` 則自動回 pre-2026-04-24 consumer-composed pattern(無須 story 示範「不傳 prop」)。
86
86
 
87
87
  // 2026-04-24 canonical:內建 `files` prop — FileUpload own success state display
88
- export const WithFileList = {
89
- name: '內建 files 屬性',
90
- render: () => {
88
+ const FileListDemo = () => {
91
89
  type UploadItem = { id: string; name: string; size?: number; status?: 'uploading' | 'completed' | 'error'; progress?: number; description?: string }
92
90
  const [items, setItems] = useState<UploadItem[]>([
93
91
  { id: '1', name: '2026-Q1-report.pdf', size: 2_500_000, status: 'completed' },
@@ -118,7 +116,20 @@ export const WithFileList = {
118
116
  />
119
117
  </div>
120
118
  )
121
- },
119
+ }
120
+
121
+ export const WithFileList = {
122
+ name: '內建 files 屬性',
123
+ render: () => <FileListDemo />,
124
+ }
125
+
126
+ // Roving-focus 契約 probe:移除檔案後焦點落到下一個移除鈕。破壞性互動(移除
127
+ // 檔案)只屬測試,不得寄生在 reader-facing 展示 story(同 PeoplePicker
128
+ // 2026-08-05 anchor;story-rules「Technical probe visibility」canonical)。
129
+ export const FileListRemoveFocusContract = {
130
+ name: '移除焦點接力驗證',
131
+ tags: ['test-only'],
132
+ render: () => <FileListDemo />,
122
133
  play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
123
134
  const canvas = within(canvasElement)
124
135
  await userEvent.click(await canvas.findByRole('button', { name: '移除 2026-Q1-report.pdf' }))
@@ -81,6 +81,18 @@ PeoplePicker 永遠支援搜尋(內部使用 `Command` / cmdk)——因為
81
81
 
82
82
  ---
83
83
 
84
+ ## 一鍵清空(clearable)
85
+
86
+ `clearable`(2026-08-05 加)機械轉發 wrapped Select(single)/ Combobox(multi)——X 的渲染、位置與清空行為 SSOT 在基座與 family canonical,本元件不另定義:
87
+
88
+ - **幾何**:有值時 clear X 在 ChevronDown 左(`../Field/field-controls.spec.md`「下拉箭頭與類型身份 indicator」段)
89
+ - **單選**:清除後回 placeholder 態(`../Select/select.spec.md`「Clearable」);wrapper 把基座 clear 的空字串映射為 `onChange([])`,不進 people 回查(fallback 會產生空名假人員)
90
+ - **多選**:clear all 一次清除所有已選人員(`../Combobox/combobox.spec.md`「全部清除」),與 per-chip 移除並存
91
+ - **何時開**:繼承 select.spec.md「何時開 clearable」判準——「無選擇是有效狀態」(選填人員欄位如觀察者 / 可清的人員 filter)開;必須有人(assignee 必填)不開
92
+ - 只在 edit 模式顯示;readonly / disabled 不渲(基座行為)
93
+
94
+ ---
95
+
84
96
  ## 與 Select / Combobox 的分界
85
97
 
86
98
  | | PeoplePicker | Select | Combobox |
@@ -183,6 +195,18 @@ PeoplePicker 永遠支援搜尋(內部使用 `Command` / cmdk)——因為
183
195
 
184
196
  ---
185
197
 
198
+ ## 觸控裝置(native 分支)
199
+
200
+ 觸控裝置(`(pointer: coarse)`,`use-is-touch-device.ts`)上 wrapped Select / Combobox 切至 native 分支(下拉 = 原生 `<select>` picker)。**Display 層與桌機同 SSOT — 「Trigger display SSOT canonical table」§B-§D 對觸控同樣成立**(2026-08-05 user 拍板;先前 native 分支硬編碼純文字/文字 Tag,手機 edit 單人無 avatar、多人掉回矩形 pill = canonical 違反,已修):
201
+
202
+ - **單人 edit**:avatar + 人名(`selectedItemRenderer` 經 NativeSelect 透明 overlay pattern,見 `select.spec.md`「雙分支同 SSOT」);無 inline search,tap 開原生選單
203
+ - **多人 edit → 自動降階 pill(2026-08-05 user 拍板)**:觸控多選一律走既有 `multiDisplay='pill'`(**不是新型態**:Combobox tag SSOT + Tag `avatar` prop,見「多選顯示樣式(multiDisplay)」),每顆 pill 自帶 X = Combobox「個別移除」canonical。**理由**:stack 的移除鈕是 hover-reveal,觸控無 hover;而讓每個 avatar 恆顯 X 視覺過載(user 否決前一版)。consumer 顯式傳 `'pill'` 時此降階為 no-op;**桌機(fine pointer)完全不受影響** — 仍是 avatar stack + 圓形 +N + hover X。實作 `people-picker.tsx` `effectiveMultiDisplay`(`useIsTouchDevice`)
204
+ - **顯示層 pointer 契約**:renderer 輸出套「抬升 z-10 + pointer-events-none + 按鈕 `[&_button]` 回收」三件套(NativeCombobox 消費同一組 display props,見 `combobox.spec.md`「Display 層雙分支同 SSOT」)— pill 本體 tap 穿透喚起原生 picker(新增成員),pill 上的 X 照常可點(移除成員)
205
+ - **選單**:觸控走原生 `<select>` picker(Combobox 觸控 SSOT,平台 a11y 最佳);原生選項無法渲 avatar 屬平台限制,桌機選單維持 PeoplePicker avatar + description 選項不變
206
+ - 一鍵清空 X(`clearable`)由基座 native 分支自行渲染,觸控同樣可用
207
+
208
+ ---
209
+
186
210
  ## PersonValue 型別
187
211
 
188
212
  ```tsx
@@ -122,7 +122,6 @@ const MultiPicker = () => {
122
122
  <div>
123
123
  <h3 className="text-body font-bold text-foreground mb-2">edit(可互動,多選)</h3>
124
124
  <PeoplePicker value={val} people={samplePeople} onChange={setVal} aria-label="專案協作者(edit multi demo)" />
125
- <Button variant="text" size="xs" onClick={() => setVal(samplePeople.slice(0, 4))}>重設協作者</Button>
126
125
  </div>
127
126
  <div>
128
127
  <h3 className="text-body font-bold text-foreground mb-2">readonly</h3>
@@ -135,6 +134,17 @@ const MultiPicker = () => {
135
134
  export const Multi: Story = {
136
135
  name: '多人',
137
136
  render: () => <MultiPicker />,
137
+ }
138
+
139
+ // Roving-focus 契約 probe:逐一移除 chip 時焦點依序落到下一個移除鈕,移除
140
+ // 最後一人後回到 combobox。破壞性互動(清空全員)只屬測試,不得寄生在
141
+ // reader-facing 展示 story(anchor:2026-08-05 user 抓「多人一打開就自己清空」;
142
+ // 前身還為此加過「重設協作者」假 UI,ccf83b24)。story-rules「Technical probe
143
+ // visibility」canonical:標 test-only,自 sidebar/Autodocs 排除,test runner 照跑。
144
+ export const MultiRemoveFocusContract: Story = {
145
+ name: '移除焦點接力驗證',
146
+ tags: ['test-only'],
147
+ render: () => <MultiPicker />,
138
148
  play: async ({ canvasElement }) => {
139
149
  const canvas = within(canvasElement)
140
150
  await userEvent.click(await canvas.findByRole('button', { name: '移除 Alice Chen' }))
@@ -145,10 +155,49 @@ export const Multi: Story = {
145
155
  await waitFor(() => expect(canvas.getByRole('button', { name: '移除 Diana Huang' })).toHaveFocus())
146
156
  await userEvent.click(canvas.getByRole('button', { name: '移除 Diana Huang' }))
147
157
  await waitFor(() => expect(canvas.getByRole('combobox', { name: '專案協作者(edit multi demo)' })).toHaveFocus())
148
- await userEvent.click(canvas.getByRole('button', { name: '重設協作者' }))
149
158
  },
150
159
  }
151
160
 
161
+ // Stack 移除鈕完整性 probe:AvatarDismissOverlay(`-top-px` + 2px ring,person-display.tsx)
162
+ // 必須完整可見,不被 tag 列裁切(combobox.tsx tagRowOverflowClass 只裁水平;anchor:2026-08-05
163
+ // user 抓「hover X 上緣被裁」— 2026-05-18 F2 雙軸 overflow-hidden 副作用)。screenshot lane
164
+ // 無 hover 能力,以 keyboard focus 觸發 group-focus-within 顯示 overlay。
165
+ export const StackRemoveOverlayProbe: Story = {
166
+ name: '移除鈕完整性驗證',
167
+ tags: ['test-only'],
168
+ render: () => <MultiPicker />,
169
+ play: async ({ canvasElement }) => {
170
+ const canvas = within(canvasElement)
171
+ const btn = await canvas.findByRole('button', { name: '移除 Alice Chen' })
172
+ btn.focus()
173
+ },
174
+ }
175
+
176
+ /* ── 一鍵清空(選填欄位) ── */
177
+ // X 在 ChevronDown 左(family SSOT field-controls.spec.md「下拉箭頭」段)。「無選擇是有效
178
+ // 狀態」的選填欄位才開 clearable(select.spec.md「何時開」)——代理人 / 觀察者是典型選填人員欄位。
179
+ const ClearablePicker = () => {
180
+ const [delegate, setDelegate] = React.useState<PersonValue | null>(samplePeople[1])
181
+ const [watchers, setWatchers] = React.useState<PersonValue[]>(samplePeople.slice(2, 5))
182
+ return (
183
+ <div className="flex flex-col gap-6 max-w-xs">
184
+ <div>
185
+ <h3 className="text-body font-bold text-foreground mb-2">單人(代理人,選填)</h3>
186
+ <PeoplePicker clearable value={delegate} people={samplePeople} onChange={(v) => setDelegate(v[0] ?? null)} aria-label="代理人(選填)" />
187
+ </div>
188
+ <div>
189
+ <h3 className="text-body font-bold text-foreground mb-2">多人(觀察者,選填)</h3>
190
+ <PeoplePicker clearable value={watchers} people={samplePeople} onChange={setWatchers} aria-label="觀察者(選填)" />
191
+ </div>
192
+ </div>
193
+ )
194
+ }
195
+
196
+ export const Clearable: Story = {
197
+ name: '一鍵清空(選填欄位)',
198
+ render: () => <ClearablePicker />,
199
+ }
200
+
152
201
  /* ── 尺寸與 Button 對齊 ── */
153
202
  const SizePicker = ({ size }: { size: 'sm' | 'md' | 'lg' }) => {
154
203
  const [val, setVal] = React.useState<PersonValue | null>(samplePeople[0])
@@ -38,6 +38,7 @@ import {
38
38
  findPerson,
39
39
  } from './people-picker-helpers'
40
40
  import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
41
+ import { useIsTouchDevice } from '@/design-system/hooks/use-is-touch-device'
41
42
  export { PEOPLE_PICKER_LENGTH1_WRAPPER_CLASS, getPeoplePickerTagWrapperClass }
42
43
 
43
44
  // ── PeoplePicker ────────────────────────────────────────────────────────────
@@ -118,6 +119,13 @@ export interface PeoplePickerProps extends Omit<React.HTMLAttributes<HTMLDivElem
118
119
  * Single mode 永遠 inline-trigger(wrap Select searchable 直接走 inline),此 prop multi 才有意義。
119
120
  */
120
121
  searchIn?: 'menu' | 'trigger'
122
+ /**
123
+ * 一鍵清空(2026-08-05):X 在 ChevronDown 左,family SSOT → field-controls.spec.md「下拉箭頭」段。
124
+ * 機械轉發 wrapped Select(single)/ Combobox(multi)的 `clearable` — X 渲染與清空行為 SSOT 在基座
125
+ * (SelectClearButton / Combobox clear),本 wrapper 不另 render。何時開的判準繼承 select.spec.md
126
+ * 「Clearable」:「無選擇是有效狀態」(選填欄位 / 可清 filter)才開;必填欄位不開。
127
+ */
128
+ clearable?: boolean
121
129
  /**
122
130
  * View 態是否渲 ChevronDown + Field naked wrapper(D-path opt-in,2026-05-08)
123
131
  * — DataTable cell view↔edit 像素級對齊用。預設 false(裸 PersonDisplay,backward compat)。
@@ -149,6 +157,7 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
149
157
  pillShowAvatar = true,
150
158
  pillWrap = true,
151
159
  searchIn = 'menu',
160
+ clearable = false,
152
161
  showDisplayEndIcon = false,
153
162
  'aria-label': ariaLabel,
154
163
  ...rest
@@ -162,6 +171,14 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
162
171
  const resolvedVariant: FieldVariantInternal = useResolvedFieldVariant(variantProp)
163
172
  const isMulti = Array.isArray(value)
164
173
  const isEmpty = !value || (isMulti && value.length === 0)
174
+ // 觸控多選一律走既有 pill 型態(2026-08-05 user 拍板:「手機上自動把多選 people picker 改成
175
+ // combobox 的型態,所有樣式互動都是 combobox 的 SSOT,不要再造輪子」)。理由:stack 的移除鈕
176
+ // 是 hover-reveal,觸控沒有 hover;恆顯 X 又太亂(user 否決)。pill 是 DS 既有 canonical
177
+ // (`multiDisplay='pill'` + Tag `avatar` prop),每顆 pill 自帶 X = Combobox tag 移除 SSOT。
178
+ // 桌機(fine pointer)完全不受影響:仍是 avatar stack + 圓形 +N + hover X。
179
+ // consumer 顯式傳 'pill' 時本行為 no-op(同值)。hook 必在任何 early return 前呼叫(React #310)。
180
+ const isTouch = useIsTouchDevice()
181
+ const effectiveMultiDisplay = isTouch && isMulti ? 'pill' : multiDisplay
165
182
 
166
183
  // 2026-07-05 D3 P0 修:以下派生 + 4 hooks 原宣告在 view/readonly/single/pill 四個 early return
167
184
  // 之後 — 同一 mounted instance 的 resolvedMode 於 edit↔view/disabled 切換(<Field mode>/<Field
@@ -178,7 +195,7 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
178
195
  const stackContainerRef = React.useRef<HTMLDivElement | null>(null)
179
196
  const [stackVisibleCount, setStackVisibleCount] = React.useState<number | undefined>(undefined)
180
197
  React.useLayoutEffect(() => {
181
- if (resolvedMode !== 'edit' || !isMulti || multiDisplay !== 'stack' || selectedNames.length <= 1) {
198
+ if (resolvedMode !== 'edit' || !isMulti || effectiveMultiDisplay !== 'stack' || selectedNames.length <= 1) {
182
199
  setStackVisibleCount(undefined); return
183
200
  }
184
201
  const root = stackContainerRef.current
@@ -190,7 +207,11 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
190
207
  // Combobox DOM-based useOverflowCount(非 deterministic 那個算法)。修:用 root 自己當 trigger,
191
208
  // 從 root 內找 tagArea(flex-1 min-w-0 div)。
192
209
  const calc = () => {
193
- const trigger = root.matches('[role="combobox"]') ? root : root.querySelector<HTMLElement>('[role="combobox"]')
210
+ // 2026-08-05 native-parity fix(touch 實圖抓「多人只剩 +N」):NativeCombobox root 無
211
+ // role="combobox"(a11y 在隱藏 <select> 上)→ 原查法 trigger=null → available=0 → 全
212
+ // overflow。雙分支通用:查無 combobox role 時 root 自身就是 trigger(native __triggerRef
213
+ // 即 field wrapper root),tagArea(flex-1 min-w-0)兩分支同構。
214
+ const trigger = root.matches('[role="combobox"]') ? root : (root.querySelector<HTMLElement>('[role="combobox"]') ?? root)
194
215
  const tagArea = trigger?.querySelector<HTMLElement>('div[class*="flex-1"][class*="min-w-0"]')
195
216
  const available = tagArea?.clientWidth ?? trigger?.clientWidth ?? 0
196
217
  const visible = getAvatarStackVisibleCount({
@@ -205,7 +226,7 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
205
226
  const ro = new ResizeObserver(calc)
206
227
  ro.observe(root)
207
228
  return () => ro.disconnect()
208
- }, [resolvedMode, isMulti, multiDisplay, selectedNames.length, size])
229
+ }, [resolvedMode, isMulti, effectiveMultiDisplay, selectedNames.length, size])
209
230
  // Merge ref:forward to parent + capture for ResizeObserver
210
231
  const mergedStackRef = React.useCallback((el: HTMLDivElement | null) => {
211
232
  stackContainerRef.current = el
@@ -295,7 +316,9 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
295
316
 
296
317
  // ── single mode → wraps Select ────────────────────────────────────────────
297
318
  if (!isMulti) {
298
- const handleSingleChange = (name: string) => onChange?.([findPerson(people, name)])
319
+ // clearable X 清空時 Select emit onChange('') — 空字串必映射為空陣列,
320
+ // 不可進 findPerson(fallback 會回 '' 字串 = 假人員)。清除後回 placeholder 態(select.spec.md「Clearable」)。
321
+ const handleSingleChange = (name: string) => onChange?.(name ? [findPerson(people, name)] : [])
299
322
  return (
300
323
  <Select
301
324
  ref={ref as React.Ref<HTMLDivElement>}
@@ -308,6 +331,7 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
308
331
  value={selectedNames[0] ?? null}
309
332
  onChange={handleSingleChange}
310
333
  searchable
334
+ clearable={clearable}
311
335
  placeholder={placeholder}
312
336
  // 2026-05-12 Issue 4:placeholder = trigger empty。2026-07-04 Q4:emptyText 走 Select →
313
337
  // SelectMenu 接線(search-empty 語意,與 trigger-empty 分離)。
@@ -330,7 +354,8 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
330
354
  }
331
355
 
332
356
  // ── multi 'pill' → wraps Combobox(對齊 GitHub Reviewers / Combobox idiom)────
333
- if (multiDisplay === 'pill') {
357
+ // 觸控多選亦走此分支(effectiveMultiDisplay,見上方 isTouch 註解)。
358
+ if (effectiveMultiDisplay === 'pill') {
334
359
  const handleMultiChange = (next: string[]) => {
335
360
  onChange?.(next.map(name => findPerson(people, name)))
336
361
  }
@@ -347,6 +372,7 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
347
372
  // 2026-07-14 deep-audit fix:searchIn 補 forward(原漏傳 → pill 模式永遠 Combobox default 'menu',
348
373
  // documented multi opt-in `searchIn='trigger'` silently 無效;stack branch 已傳,對齊 M30 forward 全 surface)
349
374
  searchIn={searchIn}
375
+ clearable={clearable}
350
376
  searchPlaceholder={searchPlaceholder}
351
377
  searchAriaLabel={searchAriaLabel}
352
378
  // 2026-05-12 Stream C Issue 4(codex Q3):placeholder = trigger empty hint('請選擇人員')
@@ -413,6 +439,7 @@ const PeoplePicker = React.forwardRef<HTMLDivElement, PeoplePickerProps>(functio
413
439
  onChange={handleMultiChange}
414
440
  searchable
415
441
  searchIn={searchIn}
442
+ clearable={clearable}
416
443
  searchPlaceholder={searchPlaceholder}
417
444
  searchAriaLabel={searchAriaLabel}
418
445
  // 2026-05-12 Stream C Issue 4(codex Q3):placeholder = trigger empty('請選擇人員');emptyText = search-empty(僅 backward-compat forward 1 cycle)
@@ -348,7 +348,10 @@ function AvatarDismissOverlay({ onRemove, label }: { onRemove: () => void; label
348
348
  'bg-surface-strong text-on-emphasis hover:bg-surface-strong-hover',
349
349
  // a11y(codex P1 fix):opacity 而非 display:none — element 在 DOM/tab-order,
350
350
  // keyboard 可達。Hover / focus-within / focus-visible 三條件之一觸發。
351
- 'opacity-0 group-hover/avatar:opacity-100 group-focus-within/avatar:opacity-100 focus-visible:opacity-100',
351
+ // **不加 touch 恆顯**(2026-08-05 user 否決前一版 A 案:「一般狀態直接把所有成員 avatar
352
+ // 的 X 都秀出來,這樣看起來超亂」)。觸控的多人移除改由 PeoplePicker 自動降階為既有
353
+ // pill 型態(Combobox tag SSOT,每顆 pill 自帶 X)承擔 — 見 people-picker.spec.md
354
+ // 「觸控裝置(native 分支)」。本 overlay 維持 hover / focus 才顯的桌機語意。
352
355
  'transition-opacity duration-150 motion-reduce:duration-0',
353
356
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
354
357
  ].join(' ')}
@@ -241,9 +241,11 @@ Select 的值套用時機是**由 onChange handler 的副作用決定**,不是
241
241
 
242
242
  **`selectedItemRenderer` 優先於兩種顯示模式**(2026-07-08 A 案回歸修正):設定時已選值改渲 consumer renderer 輸出(status icon+text / PersonDisplay 等),且 **edit / view / readonly / disabled 4 mode 共享同一 renderer**(`field-controls.spec.md` 共享 contract (a),禁 edit-only)。renderer 輸出屬「值內容」— `mode="view"` 照常渲染(無 chrome 無 chevron);與 affordance(chevron/outline)分層見 `field.spec.md` L6。
243
243
 
244
+ **雙分支同 SSOT(2026-08-05 修)**:觸控 NativeSelect edit 有值時同樣渲 renderer 輸出為可見層(native `<select>` 沿 tag 模式既有透明 overlay pattern,tap 開原生 picker;空值走原生 placeholder)——先前 native edit 只顯 option 純文字,PeoplePicker 手機單人因此無 avatar = touch 分支漏接 display 層(canonical 違反)。**Root invariant:display-layer renderer props 必雙分支(Custom/Native)同消費;新增 renderer-affecting prop 必同步 NativeSelect。**
245
+
244
246
  ### plain 模式
245
247
 
246
- - 桌面使用 custom trigger + SelectMenu;觸控裝置使用 NativeSelect 的純文字 + ChevronDown
248
+ - 桌面使用 custom trigger + SelectMenu;觸控裝置使用 NativeSelect 的純文字 + ChevronDown(`selectedItemRenderer` 設定時觸控同渲 renderer 輸出,見「雙分支同 SSOT」段)
247
249
  - **Icon-binding 矩陣**(icon kind canonical,2026-05-09 clarified):
248
250
 
249
251
  | Icon 角色 | Prop | 語意 | 色彩 | 優先序 |
@@ -526,6 +526,7 @@ const NativeSelect = React.forwardRef<HTMLSelectElement, SelectProps>(
526
526
  return <ReadonlyDisplay mode={resolvedMode} variant={variant} width={width} size={size} options={options} value={value} display={display} startIcon={StartIcon} className={className} placeholder={placeholder} showDisplayEndIcon={showDisplayEndIcon} selectedItemRenderer={selectedItemRenderer} />
527
527
  }
528
528
 
529
+ const selectedOpt = options?.find(o => o.value === value)
529
530
  const selectEl = (
530
531
  <select
531
532
  ref={setSelectRef}
@@ -537,7 +538,7 @@ const NativeSelect = React.forwardRef<HTMLSelectElement, SelectProps>(
537
538
  aria-required={fieldCtx?.required || undefined}
538
539
  aria-describedby={ariaDescribedByProp ?? fieldCtx?.descriptionId}
539
540
  aria-errormessage={ariaErrorMessageProp ?? (error ? fieldCtx?.errorId : undefined)}
540
- className={cn(bareInputStyles, 'cursor-pointer appearance-none', !value && 'text-fg-muted', !isTextDisplay && value && 'absolute inset-0 w-full h-full opacity-0 z-0')}
541
+ className={cn(bareInputStyles, 'cursor-pointer appearance-none', !value && 'text-fg-muted', value && (!isTextDisplay || (selectedItemRenderer && selectedOpt)) && 'absolute inset-0 w-full h-full opacity-0 z-0')}
541
542
  {...props}
542
543
  >
543
544
  {placeholder && <option value="" disabled>{placeholder}</option>}
@@ -555,11 +556,34 @@ const NativeSelect = React.forwardRef<HTMLSelectElement, SelectProps>(
555
556
  <ChevronDown size={iconSize} className="text-fg-muted" aria-hidden />
556
557
  </ItemSuffix>
557
558
  )
558
- const selectedOpt = options?.find(o => o.value === value)
559
559
  const label = selectedOpt?.label ?? value
560
560
  const nativeTagVariant = selectedOpt?.tagVariant as 'blue' | 'green' | 'red' | 'yellow' | 'neutral' | undefined
561
561
  const SelectedOptIcon = selectedOpt?.icon
562
562
 
563
+ // Display-layer parity(2026-08-05 user 拍板):selectedItemRenderer 有值 → 渲 renderer 輸出為
564
+ // 可見層(PeoplePicker 單人手機 = avatar+人名,與桌機同 SSOT),native <select> 沿 tag 模式既有
565
+ // pattern 作透明 overlay(tap 開原生 picker)。原 native edit 只顯 option 純文字 = touch 分支
566
+ // 從未接 display 層的病根。空值 fall through 原分支(native placeholder)。selectedOpt 查無
567
+ // (async options 未到)→ fallback {value,label:value},renderer 端自行降級(initials)。
568
+ if (selectedItemRenderer && value && selectedOpt) {
569
+ return (
570
+ <div className={cn(fieldWrapperStyles({ mode: 'edit', variant: variant, width, size, error }), 'relative', className)}
571
+ style={{ paddingRight: 'var(--field-px)' }} data-field-mode="edit" data-error={error ? '' : undefined}>
572
+ {/* Review fix(2026-08-05 F5/F6):StartIcon + nakedCellRowModeAlign 與桌機 renderer 分支
573
+ (CustomSelect,gate 同為 selectedOpt truthy — renderer 公開契約只收 options 真實 entry,
574
+ async options 未到 fall through 原生文字分支,與桌機 fallback 對稱)逐項對齊;
575
+ pointer-events-none → tap 穿透 select overlay 開原生 picker。 */}
576
+ {StartIcon && <ItemPrefix><StartIcon size={iconSize} className="text-fg-muted pointer-events-none" aria-hidden /></ItemPrefix>}
577
+ <span className={cn('relative z-10 pointer-events-none flex-1 min-w-0 inline-flex items-center', nakedCellRowModeAlign)}>
578
+ {selectedItemRenderer(selectedOpt)}
579
+ </span>
580
+ {selectEl}
581
+ {clearEl}
582
+ {chevronEl}
583
+ </div>
584
+ )
585
+ }
586
+
563
587
  if (!isTextDisplay) {
564
588
  return (
565
589
  <div className={cn(fieldWrapperStyles({ mode: 'edit', variant: variant, width, size, error }), value && tagPadding[size], 'relative',
@@ -58,8 +58,10 @@ export const Default: Story = {
58
58
  }
59
59
 
60
60
  // ── Pattern A:submit 在最後一步,送出後整個 wizard 替換成 success banner ──
61
- // 世界級 wizard UX(Ant / Linear / Stripe 常見):最後一步的 submit 按下後,
62
- // stepper 不再顯示,改成一個 success card(+ 重置 button for demo 用)。
61
+ // Pattern A:最後一步 submit 按下後 stepper 不再顯示,整個 wizard 替換成
62
+ // success card;「再填一份申請」是成功頁的真實產品 action(Google Forms
63
+ // 「Submit another response」同款,https://support.google.com/docs/answer/2839588),
64
+ // 非 demo 治具。
63
65
  if (submitted) {
64
66
  return (
65
67
  <div className="w-[480px] flex flex-col gap-4 p-6 bg-muted rounded-md border border-border">