@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,203 @@
1
+ import * as React from 'react'
2
+ import { type VariantProps } from 'class-variance-authority'
3
+ import { cn } from '@/lib/utils'
4
+ import type { FieldMode, FieldVariant } from '@/design-system/components/Field/field-types'
5
+ import type { InlineActionConfig } from '@/design-system/patterns/element-anatomy/item-anatomy'
6
+ import { fieldWrapperStyles, bareInputStyles, EMPTY_DISPLAY } from '@/design-system/components/Field/field-wrapper'
7
+ import { useFieldContext } from '@/design-system/components/Field/field-context'
8
+ import { ItemInlineAction } from '@/design-system/patterns/element-anatomy/item-anatomy'
9
+
10
+ // ── Format ──────────────────────────────────────────────────────────────────
11
+
12
+ export interface NumberFormatOptions {
13
+ /** 小數位數 */
14
+ precision?: number
15
+ /** 前綴(如 '$'、'NT$') */
16
+ prefix?: string
17
+ /** 後綴(如 '%'、'元') */
18
+ suffix?: string
19
+ /** locale(預設 'en-US') */
20
+ locale?: string
21
+ }
22
+
23
+ function formatNumber(
24
+ value: number | null | undefined,
25
+ options: NumberFormatOptions = {},
26
+ ): string {
27
+ if (value == null) return ''
28
+ const { precision, prefix = '', suffix = '', locale = 'en-US' } = options
29
+ const formatted = precision != null
30
+ ? value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision })
31
+ : value.toLocaleString(locale)
32
+ return `${prefix}${formatted}${suffix}`
33
+ }
34
+
35
+ // Phase B1(2026-05-05):NumberInputDisplay 退場。
36
+ // 改用 `<NumberInput mode="display" value={...} prefix={...} ... />`,format 邏輯在 mode='display' 分支重用。
37
+
38
+ // ── Types ───────────────────────────────────────────────────────────────────
39
+
40
+ export interface NumberInputProps
41
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'value' | 'onChange' | 'type'>,
42
+ Omit<VariantProps<typeof fieldWrapperStyles>, 'mode' | 'variant'>,
43
+ NumberFormatOptions {
44
+ /** Field display mode */
45
+ mode?: FieldMode
46
+ /**
47
+ * Visual chrome(正交於 mode);Phase B1(2026-05-05)新增。
48
+ * - `'default'`(預設)— 完整 Field wrapper chrome。
49
+ * - `'bare'` — 透明 variant,hover/focus 才 reveal(Toolbar inline / DataTable cell)。
50
+ *
51
+ * 透傳:在 `<Field variant="bare">` 內自動繼承 context.variant;per-prop override context。
52
+ */
53
+ variant?: FieldVariant
54
+ /** Error 狀態(正交於 mode)。 */
55
+ error?: boolean
56
+ /** 數值 */
57
+ value?: number | null
58
+ /** 數值變更 */
59
+ onChange?: (value: number | null) => void
60
+ /** 右側 inline action — 宣告式 API,Field 根據 size 自動渲染。 */
61
+ endAction?: InlineActionConfig
62
+ /**
63
+ * 右側 slot(ReactNode)— escape hatch 供 consumer 放自訂元素(如 stepper button group / 自訂 popover trigger)。
64
+ * 跟 `endAction` 互斥(同時傳 endSlot 會優先,endAction 被忽略)。
65
+ * 規則對齊 Input.endSlot:90% case 用 endAction 宣告式 API,10% config 表達不出時走 endSlot。
66
+ */
67
+ endSlot?: React.ReactNode
68
+ }
69
+
70
+ // ── Component ───────────────────────────────────────────────────────────────
71
+
72
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
73
+ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
74
+ (
75
+ {
76
+ mode: modeProp,
77
+ variant: variantProp,
78
+ error: errorProp = false,
79
+ size: sizeProp,
80
+ value,
81
+ onChange,
82
+ precision,
83
+ prefix,
84
+ suffix,
85
+ locale,
86
+ endAction,
87
+ endSlot,
88
+ className,
89
+ disabled: disabledProp,
90
+ readOnly,
91
+ id: idProp,
92
+ 'aria-describedby': ariaDescribedByProp,
93
+ 'aria-errormessage': ariaErrorMessageProp,
94
+ ...props
95
+ },
96
+ ref
97
+ ) => {
98
+ const fieldCtx = useFieldContext()
99
+ const error = errorProp || (fieldCtx?.invalid ?? false)
100
+ const size = sizeProp ?? fieldCtx?.size ?? 'md'
101
+ const disabled = disabledProp ?? fieldCtx?.disabled
102
+ // chrome 透傳:per-prop override context;context 沒值則 'default'
103
+ const variant: FieldVariant = variantProp ?? fieldCtx?.variant ?? 'default'
104
+ // mode resolve order(Phase B1 2026-05-05):prop > fieldCtx > readOnly/disabled fallback
105
+ const resolvedMode: FieldMode = modeProp
106
+ ?? fieldCtx?.mode
107
+ ?? (readOnly ? 'readonly' : disabled ? 'disabled' : 'edit')
108
+
109
+ // display / readonly / disabled 都顯示格式化值(span 取代 input)
110
+ if (resolvedMode !== 'edit') {
111
+ return (
112
+ <div
113
+ className={cn(fieldWrapperStyles({ mode: resolvedMode, variant: variant, size }), className)}
114
+ data-field-mode={resolvedMode}
115
+ >
116
+ <span
117
+ className={cn(
118
+ 'flex-1 min-w-0',
119
+ resolvedMode === 'disabled' && 'text-fg-disabled cursor-not-allowed',
120
+ value == null && 'text-fg-muted',
121
+ )}
122
+ >
123
+ {value == null ? EMPTY_DISPLAY : formatNumber(value, { precision, prefix, suffix, locale })}
124
+ </span>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ // edit 模式:raw 數值輸入
130
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
131
+ if (!onChange) return
132
+ const raw = e.target.value
133
+ if (raw === '' || raw === '-') {
134
+ onChange(null)
135
+ return
136
+ }
137
+ const parsed = Number(raw)
138
+ if (!Number.isNaN(parsed)) {
139
+ onChange(parsed)
140
+ }
141
+ }
142
+
143
+ return (
144
+ <div
145
+ className={cn(
146
+ fieldWrapperStyles({ mode: 'edit', variant: variant, size }),
147
+ error && [
148
+ 'border-error hover:border-error-hover',
149
+ 'focus-within:border-error focus-within:hover:border-error',
150
+ ],
151
+ className,
152
+ )}
153
+ data-field-mode="edit"
154
+ data-error={error ? '' : undefined}
155
+ >
156
+ <input
157
+ ref={ref}
158
+ type="text"
159
+ inputMode="decimal"
160
+ id={idProp ?? fieldCtx?.id}
161
+ value={value ?? ''}
162
+ onChange={handleChange}
163
+ aria-invalid={error || undefined}
164
+ aria-required={fieldCtx?.required || undefined}
165
+ aria-describedby={ariaDescribedByProp ?? fieldCtx?.descriptionId}
166
+ aria-errormessage={ariaErrorMessageProp ?? (error ? fieldCtx?.errorId : undefined)}
167
+ className={bareInputStyles}
168
+ {...props}
169
+ />
170
+ {endSlot ? (
171
+ // endSlot escape hatch:consumer 自控右側 slot(對齊 Input.endSlot canonical)
172
+ endSlot
173
+ ) : endAction ? (
174
+ <ItemInlineAction action={endAction} size={size ?? 'md'} />
175
+ ) : null}
176
+ </div>
177
+ )
178
+ }
179
+ )
180
+ NumberInput.displayName = 'NumberInput'
181
+
182
+ // code-quality-allow: dead-export — public API surface — consumer-exposed for future use
183
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
184
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
185
+ export const numberInputMeta = {
186
+ component: 'NumberInput',
187
+ family: 4,
188
+ variants: {
189
+
190
+ },
191
+ sizes: {
192
+
193
+ },
194
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
195
+ tokens: {
196
+ bg: [],
197
+ fg: ['text-fg-disabled', 'text-fg-muted'],
198
+ ring: [],
199
+ },
200
+ } as const
201
+
202
+ // code-quality-allow: dead-export — public API surface — consumer-exposed for future use
203
+ export { NumberInput, formatNumber }
@@ -0,0 +1,156 @@
1
+ import * as React from 'react'
2
+ import { cn } from '@/lib/utils'
3
+ import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/design-system/components/HoverCard/hover-card'
4
+ import { tagVariants } from '@/design-system/components/Tag/tag'
5
+ import { HOVER_DELAY_RICH_MS, HOVER_DELAY_CLOSE_MS } from '@/design-system/tokens/motion/motion'
6
+
7
+ /**
8
+ * OverflowIndicator — +N 觸發器 + HoverCard 顯示溢出內容
9
+ *
10
+ * 統一用 HoverCard(不用 Tooltip)——溢出內容可能需要互動:
11
+ * - 人員 +N:tag dismiss + hover name card
12
+ * - 一般 +N:穩定顯示溢出項目
13
+ *
14
+ * trigger 不用 Tag 元件(Tag 有內建 truncation Tooltip 會跟 HoverCard 衝突),
15
+ * 改用 tagVariants 直接套樣式。
16
+ */
17
+
18
+ const triggerSize: Record<string, string> = {
19
+ sm: 'h-5 min-w-5',
20
+ md: 'h-6 min-w-6',
21
+ lg: 'h-6 min-w-6',
22
+ }
23
+
24
+ const triggerText: Record<string, string> = {
25
+ sm: 'text-[10px]',
26
+ md: 'text-caption',
27
+ lg: 'text-caption',
28
+ }
29
+
30
+ export interface OverflowIndicatorProps
31
+ extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'children'> {
32
+ count: number
33
+ shape?: 'circle' | 'tag'
34
+ size?: 'sm' | 'md' | 'lg'
35
+ children: React.ReactNode
36
+ className?: string
37
+ }
38
+
39
+ function ShrinkWrapList({ children }: { children: React.ReactNode }) {
40
+ const containerRef = React.useRef<HTMLDivElement | null>(null)
41
+
42
+ // 2026-05-16 audit codex Round 6:rAF capture + cancel on unmount/re-run(defensive hygiene)。
43
+ // 原 callback ref `requestAnimationFrame(() => ...)` 沒 cancel,unmount-during-rAF 可能 fire 後
44
+ // mutate detached element.style — no-op but pattern hygiene 應對齊 DS-wide rAF cancel canonical。
45
+ React.useLayoutEffect(() => {
46
+ const container = containerRef.current
47
+ if (!container) return
48
+ let rafId = 0
49
+ rafId = requestAnimationFrame(() => {
50
+ rafId = 0
51
+ const cs = getComputedStyle(container)
52
+ const padL = parseFloat(cs.paddingLeft) || 0
53
+ const padR = parseFloat(cs.paddingRight) || 0
54
+ const gap = parseFloat(cs.gap) || parseFloat(cs.columnGap) || 0
55
+ const available = container.offsetWidth - padL - padR
56
+
57
+ const items = Array.from(container.children) as HTMLElement[]
58
+ if (items.length === 0) return
59
+
60
+ let currentRow = 0
61
+ let maxRow = 0
62
+
63
+ items.forEach(item => {
64
+ const w = item.offsetWidth
65
+ const needed = currentRow > 0 ? currentRow + gap + w : w
66
+
67
+ if (needed > available && currentRow > 0) {
68
+ maxRow = Math.max(maxRow, currentRow)
69
+ currentRow = w
70
+ } else {
71
+ currentRow = needed
72
+ }
73
+ })
74
+ maxRow = Math.max(maxRow, currentRow)
75
+
76
+ container.style.maxWidth = `${Math.ceil(maxRow) + padL + padR + 1}px`
77
+ })
78
+ return () => { if (rafId) cancelAnimationFrame(rafId) }
79
+ }, [children])
80
+
81
+ return (
82
+ <div ref={containerRef} className="flex flex-wrap gap-1 p-2 max-w-[280px]">
83
+ {children}
84
+ </div>
85
+ )
86
+ }
87
+
88
+ const OverflowIndicator = React.forwardRef<HTMLSpanElement, OverflowIndicatorProps>(
89
+ function OverflowIndicator(
90
+ { count, shape = 'circle', size = 'md', children, className, ...props },
91
+ ref,
92
+ ) {
93
+ if (count <= 0) return null
94
+
95
+ const trigger = shape === 'tag' ? (
96
+ <span
97
+ ref={ref}
98
+ data-overflow-indicator=""
99
+ className={cn(tagVariants({ color: 'neutral', size }), 'cursor-default', className)}
100
+ {...props}
101
+ >
102
+ <span className="px-1">+{count}</span>
103
+ </span>
104
+ ) : (
105
+ <span
106
+ ref={ref}
107
+ data-overflow-indicator=""
108
+ className={cn(
109
+ 'shrink-0 rounded-full inline-grid place-content-center',
110
+ 'bg-muted text-foreground font-medium leading-none cursor-default',
111
+ triggerSize[size],
112
+ triggerText[size],
113
+ className,
114
+ )}
115
+ {...props}
116
+ >
117
+ +{count}
118
+ </span>
119
+ )
120
+
121
+ // 2026-05-18 fix(per user audit「所有 hovercard 應消費 hover delay token」+ motion.spec.md SSOT):
122
+ // rich tier(可互動 popup,user 可移浮層上操作)= HOVER_DELAY_RICH_MS;close = HOVER_DELAY_CLOSE_MS。
123
+ return (
124
+ <HoverCard openDelay={HOVER_DELAY_RICH_MS} closeDelay={HOVER_DELAY_CLOSE_MS}>
125
+ <HoverCardTrigger asChild>
126
+ {trigger}
127
+ </HoverCardTrigger>
128
+ <HoverCardContent className="bg-tooltip rounded-lg" data-theme="dark">
129
+ <ShrinkWrapList>{children}</ShrinkWrapList>
130
+ </HoverCardContent>
131
+ </HoverCard>
132
+ )
133
+ },
134
+ )
135
+ OverflowIndicator.displayName = 'OverflowIndicator'
136
+
137
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
138
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
139
+ export const overflowIndicatorMeta = {
140
+ component: 'OverflowIndicator',
141
+ family: null, // non-family composite / overlay / layout
142
+ variants: {
143
+
144
+ },
145
+ sizes: {
146
+
147
+ },
148
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
149
+ tokens: {
150
+ bg: ['bg-muted'],
151
+ fg: ['text-foreground'],
152
+ ring: [],
153
+ },
154
+ } as const
155
+
156
+ export { OverflowIndicator }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * avatar-stack-overflow — Deterministic SSOT primitive for avatar stack overflow math
3
+ *
4
+ * **2026-05-15 Bug 3 fix(Claude+Codex Step 5 比稿 consensus,user verbatim「就 A」)**:
5
+ *
6
+ * Real root cause of Bug 3「same cell width but different overflow timing per total count」:
7
+ * 1. `useOverflowCount` 在 Combobox 用 DOM `offsetWidth` 量測 + `overflowW = 60` fallback(實際 24px)
8
+ * 2. `overflowEl` 不被 ResizeObserver observe,first calc 用 fallback 後不會 recalc
9
+ * 3. `offsetWidth` 不減 `-ml-0.5` overlap → measurement 偏保守
10
+ * 4. **架構違反**:`MultiPersonDisplay` display path 用 canvas-based 算 / Combobox edit path 用 DOM offsetWidth
11
+ * → 同 cell width 兩 path 不同結果 → user verbatim「同寬 cell overflow 時間不一樣」SSOT 根本被違反
12
+ *
13
+ * **Fix**(per codex Q3 consensus):抽 pure formula + React hook,display + edit 兩 path **共用**。
14
+ *
15
+ * **Deterministic formula**:
16
+ * ```
17
+ * firstAvatar + (visible - 1) * (avatar - overlap) + overflowChip <= available
18
+ * ```
19
+ * - `firstAvatar`:第一個 avatar 全寬 = `avatarPx`
20
+ * - subsequent items:`avatar - overlap`(stack `-ml-0.5` 視覺等同 measurement subtract)
21
+ * - `overflowChip`:若有 hidden,留 +N chip 空間;最後一個 visible item 不需留(`remaining === 0`)
22
+ *
23
+ * **Why centralize**:M14 mechanical guard against future drift。`MultiPersonDisplay` +
24
+ * PeoplePicker stack edit + future avatar consumers 都 import 此 helper,不可 copy formula。
25
+ * 對齊 codex「one avatar-stack primitive owns avatarPx, overlapPx, overflowChipPx」directive。
26
+ *
27
+ * **Benchmark cite**(per codex external baseline):MDN flex truncation / MUI Autocomplete
28
+ * limitTags / Ant Select maxTagCount="responsive"(explicit overflow contract,非 incidental clip)/
29
+ * Primer Truncate(parent-constrained max width)。
30
+ */
31
+
32
+ /**
33
+ * Pure deterministic visible-count formula for avatar stack with overlap + overflow chip.
34
+ *
35
+ * @param availablePx — Container width available for the entire stack(含 +N chip 預留空間)
36
+ * @param total — Total avatars in selection
37
+ * @param avatarPx — Per-avatar pixel width(包含 ring border)
38
+ * @param overlapPx — Negative margin overlap between siblings(default 2px = `-ml-0.5`)
39
+ * @param overflowChipPx — Width reserved for +N indicator when overflow needed(default 24px = circle h-6 min-w-6)
40
+ * @returns visible count(0 ≤ visible ≤ total)。若全部 fit 則 visible = total(無 +N chip);否則 visible 為 max fit。
41
+ */
42
+ export function getAvatarStackVisibleCount({
43
+ availablePx,
44
+ total,
45
+ avatarPx,
46
+ overlapPx = 2,
47
+ // NOTE: overflowChipPx is kept for backward-compat API but the slot-based
48
+ // formula below treats chip = avatar physical size(both circles same shape
49
+ // + same -ml-0.5 overlap when stacked)。Consumer 必 ensure 視覺 chip wrapper
50
+ // 也套同 `-ml-0.5` overlap class(Combobox `overflowWrapperClassName`)。
51
+ overflowChipPx: _overflowChipPx = 24,
52
+ }: {
53
+ availablePx: number
54
+ total: number
55
+ avatarPx: number
56
+ overlapPx?: number
57
+ overflowChipPx?: number
58
+ }): number {
59
+ if (total <= 0 || availablePx <= 0) return 0
60
+ // 2026-05-16 真 root cause fix(Claude+Codex Round 2 + user 物理模型 directive):
61
+ //
62
+ // 原 formula 雙態 bug:
63
+ // 1. full-fit 路徑算 fullStack(無 chip space)
64
+ // 2. overflow 路徑重算 remainder(chip 當 24px 額外空間)
65
+ // 兩 path 切換時 visible 跳 2(saw)— 因 chip 不被當「stack 內 1 個圓」,
66
+ // 被當「stack 外額外 chunk」。User 抓 length=4→4、length=5→2+3 = 物理錯。
67
+ //
68
+ // **User 物理模型(對齊 MUI AvatarGroup / Primer AvatarStack / Material idiom)**:
69
+ // avatar 跟 +N 都是同尺寸圓形 + 同 -ml-0.5 overlap → 同 step。空間 W 容
70
+ // `slots = 1 + floor((W - avatar) / step)` 個圓。total ≤ slots → 全 avatar 無 chip;
71
+ // total > slots → (slots-1) avatar + 1 chip(共 slots 個圓)。
72
+ //
73
+ // 物理 saw 性質:length 從 slots 跳到 slots+1 時 visible 從 slots 跳到 slots-1
74
+ // = delta 1 avatar(同 slots 個圓,只 swap 最後一個 avatar 變 chip)。對齊 user 直覺。
75
+ const step = avatarPx - overlapPx
76
+ if (step <= 0) return Math.min(total, 1)
77
+ const slots = 1 + Math.floor((availablePx - avatarPx) / step)
78
+ if (slots <= 0) return 0
79
+ if (total <= slots) return total // 全 fit:每 slot 一個 avatar
80
+ return Math.max(0, slots - 1) // 超過 slots:slots-1 個 avatar + 1 個 chip(last slot)
81
+ }
82
+
83
+ /**
84
+ * Map size token to avatar pixel(對齊 person-display.tsx:80 AVATAR_PX SSOT)。
85
+ */
86
+ export const AVATAR_STACK_AVATAR_PX: Record<'sm' | 'md' | 'lg', number> = {
87
+ sm: 20,
88
+ md: 24,
89
+ lg: 24,
90
+ }
91
+
92
+ /**
93
+ * Default overflow chip width per size(對齊 overflow-indicator.tsx:17 triggerSize SSOT)。
94
+ * shape='circle' → h-{5|6} min-w-{5|6} ≈ 20-24px
95
+ */
96
+ export const AVATAR_STACK_OVERFLOW_CHIP_PX: Record<'sm' | 'md' | 'lg', number> = {
97
+ sm: 20,
98
+ md: 24,
99
+ lg: 24,
100
+ }
@@ -0,0 +1,76 @@
1
+ // PeoplePicker — pure helpers extracted from `people-picker.tsx`(2026-05-18 file-size refactor)
2
+ //
3
+ // 抽出原因:`people-picker.tsx` 達 P1 file-size budget (500 lines)。本檔收 **不消費 component
4
+ // closure** 的純 helper(constant / pure function),讓主檔 ≤ 480。視覺 / behavior /
5
+ // user-facing API 完全未動 — 只是 module-level 邊界搬家。
6
+ //
7
+ // SSOT-bearing render logic(tagRenderer / selectedItemRenderer / 多人 stack 視覺等)仍留在
8
+ // `people-picker.tsx`,因為消費 Combobox / Select / state 等 closure。
9
+ //
10
+ // Hook `check_peoplepicker_ssot_drift.sh` 不檢查本檔 literal — 因規則 cite spec.md row 的義務
11
+ // 在主檔 SSOT-bearing API 邊界處,本檔屬機械搬移。
12
+ import { nakedCellRowModeAlign } from '@/design-system/components/Field/field-wrapper'
13
+ import type { SelectOption } from '@/design-system/components/Select/select'
14
+ import { buildPersonNameCard, resolvePerson, type PersonValue } from './person-display'
15
+
16
+ // ── Tag wrapper className SSOT ──────────────────────────────────────────────
17
+ //
18
+ // **2026-05-15 Bug 1 fix(Claude+Codex Step 5 比稿 consensus)**:length=1 走 PersonDisplay
19
+ // (avatar+人名+ellipsis,per spec.md §C row 1)需要 width constraint chain;length>=2 走
20
+ // PersonAvatarTag stack overlap(per spec.md §D row 1)需要 negative margin overlap visual。
21
+ // 一個 static wrapper class 涵蓋兩 contract 不可能 — Combobox `OverflowTagList` 把 result 包
22
+ // `shrink-0`,如果 wrapper 還疊 `inline-flex` 就 intrinsic content-width → PersonDisplay
23
+ // truncate 無效(Bug 1 user 抓「越界蓋 indicator」)。
24
+ //
25
+ // **Why centralize**:M14 mechanical guard against future drift。任何人未來改 stack mode 邏輯,
26
+ // 必經此 helper(hook `check_peoplepicker_ssot_drift.sh` 攔接 wrapper class literal in tsx)。
27
+ //
28
+ // **2026-05-15 SSOT alignment**(user verbatim「單選 people picker 沒壞,難道沒有 SSOT?」):
29
+ // 單選 picker wrapper(`select.tsx:229`)= `flex-1 min-w-0 inline-flex items-center +
30
+ // nakedCellRowModeAlign` — proven working,canonical SSOT。本 helper 對齊 single SSOT,
31
+ // **不**自定一套(spec.md §C row 1 +「length=1 視覺 = 跟單人 closed 一致」+ §E「PersonDisplay
32
+ // 共享 renderer」)。
33
+ //
34
+ // `inline-flex items-center` 提供:wrapper 對 PersonDisplay flex container 適當 vertical centering,
35
+ // 不靠外層 tagArea items-center cascade(避免 wrapper 高度 collapse 不可控)。
36
+ // `nakedCellRowModeAlign` 提供:autoRow cell 內 first-line align(對齊既有 row geometry SSOT)。
37
+ //
38
+ // **禁加 overflow-hidden**:inner `<span truncate>` 自帶 overflow-hidden + text-overflow:ellipsis,
39
+ // wrapper overflow-hidden 反而 clip 24px avatar(ItemPrefix h-[1lh]~20px slot 容不下)+ break inner
40
+ // ellipsis trigger(圖一 + 圖二 root cause)。
41
+
42
+ // code-quality-allow: dead-export — SSOT primitive 公開供 future cross-file 消費 + hook
43
+ // `check_peoplepicker_ssot_drift.sh` enforce wrapper class literal pattern
44
+ export const PEOPLE_PICKER_LENGTH1_WRAPPER_CLASS = `flex-1 min-w-0 inline-flex items-center ${nakedCellRowModeAlign}`
45
+
46
+ // code-quality-allow: dead-export — paired helper for SSOT primitive(同上 hook + future use rationale)
47
+ export function getPeoplePickerTagWrapperClass(selectedCount: number): string {
48
+ return selectedCount === 1
49
+ ? PEOPLE_PICKER_LENGTH1_WRAPPER_CLASS // SSOT aligned to single picker wrapper(select.tsx:229)
50
+ // length>=2 stack 視覺(spec.md §D row 1):圓形 avatar overlap + group/avatar selector for dismiss overlay
51
+ : '-ml-0.5 first:ml-0 relative inline-flex group/avatar'
52
+ }
53
+
54
+ // ── Person → SelectOption mapping ───────────────────────────────────────────
55
+ //
56
+ // Issue 4(2026-05-10):forward person 的 description + avatar 給 Select(SelectOption schema
57
+ // 已 unified with SelectMenuOption per Issue 4)。先前 PeoplePicker single mode 透過 Select 開
58
+ // menu 時 dropdown row 只顯純文字 name(資訊弱)— 現透過 wrapper schema unify 直接帶 avatar /
59
+ // description 給 SelectMenu primitive 渲。
60
+ //
61
+ // 2026-05-18 fix(per user directive「所有 avatar hover 都要 NameCard」+ avatar.spec.md
62
+ // DS-wide canonical):dropdown menu items Avatar 必帶 hoverCard,跟 PersonDisplay / Tag
63
+ // avatar 對齊。漏掉 = user 抓「PeoplePicker 選單內 avatar 沒有 namecard」。
64
+ export function personToSelectOption(person: PersonValue): SelectOption {
65
+ const p = resolvePerson(person)
66
+ return {
67
+ value: p.name,
68
+ label: p.name,
69
+ avatar: { src: p.avatarUrl, alt: p.name, hoverCard: buildPersonNameCard(p) },
70
+ description: p.description,
71
+ }
72
+ }
73
+
74
+ export function findPerson(people: PersonValue[], name: string): PersonValue {
75
+ return people.find(p => resolvePerson(p).name === name) ?? name
76
+ }