@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.
- package/dist/components/Combobox/combobox.d.ts.map +1 -1
- package/dist/components/Combobox/combobox.js +25 -6
- package/dist/components/Combobox/combobox.js.map +1 -1
- package/dist/components/PeoplePicker/people-picker.d.ts +7 -0
- package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -1
- package/dist/components/PeoplePicker/people-picker.js +12 -5
- package/dist/components/PeoplePicker/people-picker.js.map +1 -1
- package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
- package/dist/components/PeoplePicker/person-display.js +4 -1
- package/dist/components/PeoplePicker/person-display.js.map +1 -1
- package/dist/components/Select/select.d.ts.map +1 -1
- package/dist/components/Select/select.js +21 -3
- package/dist/components/Select/select.js.map +1 -1
- package/dist/patterns/element-anatomy/item-anatomy.js +1 -1
- package/ds-canonical/fork/consumer/lock.json +6 -6
- package/ds-canonical/fork/consumer/managed-files/0235cf6b3fd402a3717881dc205b5bf77aed7632e7d7d6f1811a40e3033bd816.blob +2 -2
- package/ds-canonical/fork/consumer/managed-files/9791c85388d2e2e2bd564e2550fadc3351c4c960a47ce838098afcba7544eb90.blob +11 -2
- package/ds-canonical/fork/governance.lock +3 -3
- package/ds-canonical/fork/manifest.json +10 -10
- package/ds-story-manifest.json +5 -1
- package/package.json +2 -2
- package/src/components/Combobox/combobox.spec.md +2 -0
- package/src/components/Combobox/combobox.tsx +42 -7
- package/src/components/Field/field.spec.md +10 -0
- package/src/components/FileUpload/file-upload.stories.tsx +15 -4
- package/src/components/PeoplePicker/people-picker.spec.md +24 -0
- package/src/components/PeoplePicker/people-picker.stories.tsx +51 -2
- package/src/components/PeoplePicker/people-picker.tsx +32 -5
- package/src/components/PeoplePicker/person-display.tsx +4 -1
- package/src/components/Select/select.spec.md +3 -1
- package/src/components/Select/select.tsx +26 -2
- 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' :
|
|
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' :
|
|
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
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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' :
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
//
|
|
62
|
-
//
|
|
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">
|