appfuse-web Changelog
Framework Library 變更日誌(lib/ 框架層)。
- 格式遵循 Keep a Changelog
- 版本策略遵循
appfuse-docs/docs-guides/reference/versioning.md- Removed / Deprecated / Breaking Changes 三段格式遵循
m-changelog-format.md
[Unreleased]
Added
Data Input / MediaInput (lib/components/data-input/media-input/)
MediaInput新增 container-basedcolumns、thumbnailObjectFit、非同步beforeRemove、renderItemActions、itemActionsVisibility、getItemKey與具接受/回復語意的onOrderChange; 排序支援 Alt + 方向鍵並以 live region 公告成功/回復結果,非同步持久化失敗會回復原順序。 桌面 hover/focus actions 以單一縮圖為觸發範圍,不會因整個 MediaInput 取得 hover/focus 而全部顯示。FileDescriptor.fileId現在優先作為 React/DnD 穩定 identity;同步公開MediaInputSource、MediaInputColumns、action/ordering context 與相關型別,以及DEFAULT_MEDIA_INPUT_COLUMNS; 增量加入相同來源時會避開既有 item key,維持 React、刪除及 DnD identity 唯一。MediaInput新增collapseToSingleBelow、showAddTile/renderAddTile、capacitySlots、removable與thumbnailContainerHeight,支援不收合的窄容器網格、固定容量位置、網格內新增卡片, 以及「可替換但不可刪除」的主圖組合;只設定capacitySlots且尚無媒體時仍保留上傳 dropzone。renderAddTile只渲染視覺內容,可點擊的role="option"根節點由框架建立。MediaViewer同步公開 可停用的自動收合門檻與bottomActionsSlot;關閉收合不影響獨立的 400px 小螢幕控制項門檻。 已有媒體且沒有網格新增卡片時,MediaInput 的上傳按鈕改列於右下角下載按鈕左側。
Overlays / Popover (lib/components/overlays/popover/)
Popover新增closeOnSelect:點擊內容中的有效 action 後關閉並把焦點返回 trigger;容器空白及 disabled/aria-disabledaction 不會誤關閉。適合以任意 action node 組成的 compact 操作面板。
Navigation (lib/components/navigation/tabs/)
TabList distribution="content" | "equal"— 預設content維持既有依內容寬度靠起點排列;equal讓直接子層 Tab 等寬填滿容器,適合數量固定且重要性相同的頁籤。同步公開TabListDistribution與既有但先前未由 Tabs entry point 匯出的TabListOverflow型別。equal與保留內容寬度的overflow="scroll"語意互斥:型別會拒絕此組合;動態或 JavaScript 呼叫則記錄錯誤並降級為overflow="wrap",避免在 render 期間拋錯而卸載 React tree。
Routing (@appfuse/appfuse-web/routing)
- 新增 routing entry point,提供深入頁面(Detail/Editor/Viewer)的情境式返回能力, 取代頁面自行寫死返回 URL 或以 query param 傳遞來源的作法(Web ADR-009)。
useReturnAction()— 回傳goBack()與解析結果(kind/to/label)。解析順序為 獨立視窗關閉 → 應用內上一頁(navigate(-1))→ 最近的<ReturnBoundary>落點 →fallback(預設/);邊界與 fallback 一律replace,返回不在歷史堆疊留下深入頁面。<ReturnBoundary path label>與useReturnBoundary()— 由 collection/applet root 宣告返回落點。 以 Context 而非 routehandle解析,因此同時涵蓋 data router route objects 與 applet 內的 宣告式<Routes>;巢狀邊界取最近者。useUnsavedChangesGuard()— 未儲存內容的統一離開攔截,同時涵蓋應用內導航(React Router blocker)與整頁離開(beforeunload);需 data router。hasInAppHistory()— 以window.history.state.idx判定是否存在應用內上一頁,涵蓋應用內進入、 重新整理、直接連結與新分頁四種進入來源。openDetachedWindow()/isDetachedWindow()/closeDetachedWindow()/DETACHED_WINDOW_NAME— 獨立視窗的 opt-in 標記;只有經openDetachedWindow()開啟的視窗,返回才走關閉分支。
Vite (@appfuse/appfuse-web/vite)
mockAuthPlugin()— 由 Vite dev-server process 持有 Refresh Session,使用原生 HttpOnly Cookie 並支援 strict credential rotation、replay family revoke、expiration 與 remember-me,使 hard reload/direct deep link 不再因頁面 JavaScript context 重建而遺失 Session。預設 Cookie 名為APP_SERVER_REFRESH,亦匯出DEFAULT_MOCK_REFRESH_COOKIE_NAME與設定型別。
HTTP (lib/utils/http/)
createHttpClient的 refresh single-flight 現在再以同源 Web Lockappfuse:refresh-session包住, 跨分頁 refresh 會等待上一個分頁完成Set-Cookie後才送出,支援 Server strict rotation 且不把 合法多分頁競態誤判為 replay。等待鎖預設最多 60 秒(refreshLockTimeoutMs),取得鎖後 refresh callback 另有完整 15 秒期限(refreshTimeoutMs);callback 逾時保證釋放鎖,只發布 timeout availability 訊號,不會把內部 refresh 列為待使用者對帳的 business mutation。Browser 缺少 Web Locks 時會記一次 warning,避免非 secure context 靜默降級。createHttpClient({ withCredentials })— 支援跨 origin 攜帶 HttpOnly Refresh Session Cookie, 讓 Browser 不需把 refresh credential 暴露給 JavaScript。- 新增
classifyRefreshFailure()、requiresReauthenticationAfterRefresh()與REFRESH_SESSION_INVALID_*exports,集中判斷 stablerefresh-session-invalid;無 stable code 的 legacy Refresh 401 會記 warning 後要求重新認證,transport/timeout/5xx 則保留 Session。 createHttpClient({ systemEvents })— 將 Refresh Session 明確失效、網路/timeout/5xx 與 mutation 傳輸結果不確定分類成 typed system events,讓 apiClient 與 App Shell 呈現解耦。 主動 refresh 回null時也會在原請求送出前直接停止。HttpRequestConfig.authMode('session' | 'session-no-refresh' | 'none')— 以 request-level 契約 決定請求是否參與目前 session 的 Bearer 附加、主動 refresh 與 401 refresh/retry;預設session, login、refresh、Email OTP 等建立新 session 的公開認證請求指定none;logout 等必須附加目前 Bearer、但不得延長或恢復 Session 的操作指定session-no-refresh。
Utils (lib/utils/time/)
isValidTimeZone(timeZone)— 檢查目前 JavaScript runtime 是否支援指定 IANA zone,供設定資料在進入Intl.DateTimeFormat前走可呈現的驗證路徑。- 新增公開時區換算 API:
browserTimeZone()、instantToZonedDate()、instantToZonedWallClock()、zonedWallClockToInstant()、timeZoneOffsetMinutes()、todayInTimeZone()、timeZoneLocation(),以及TimeZoneDisambiguation型別。這些 API 可從@appfuse/appfuse-web/utils直接匯入;wallClockToInstant()是既有 API 的命名空間搬遷,見 Removed。
Utils (lib/utils/msw/)
reactivateMswInterception()(@appfuse/appfuse-web/utils)— 對控制本頁的 Service Worker 補送一則MOCK_ACTIVATE,把本 client 重新加回 MSW worker 的activeClientIds,使攔截即刻恢復、不需重整頁面。 回傳 worker 是否於逾時內回覆MOCKING_ENABLED(false= 無法自癒)。純瀏覽器 API,不依賴 msw 套件。createMswHealthProbe()+MSW_HEALTH_PATH/MSW_HEALTH_HEADER/MSW_HEALTH_MARKER(@appfuse/appfuse-web/utils)— 建立攔截健康探測函式:實際打一個 mock-only 端點,逐項驗證 HTTP status、content-type、marker header 與 payload marker,健康回null、失效回診斷資訊 (MswHealthIssue)。應用側以三行註冊對應 handler(常數由框架提供,兩側共用同一份定義)。createMswHealthCoordinator()(@appfuse/appfuse-web/utils)— 建立應用內單一 MSW 健康狀態來源: 合併並發探測(single-flight)、短暫快取健康與失效結果、失效時自癒後複探、狀態去重發布。 同一實例可交給createHttpClient({ mswHealth })與<MswHealthGuard health={...} />,使請求路徑與 app shell 不再各自維護探測、自癒與狀態。
Feedback (lib/components/feedback/msw-health-guard/)
MswHealthGuard改以必填health: MswHealthCoordinator消費框架協調器。守衛在初次掛載、分頁重新 可見與取得焦點時強制探測,也會訂閱createHttpClient在請求路徑發布的失效狀態;自癒失敗才顯示 提示條。runtimeenabled判定由協調器統一持有,避免 UI 與 HTTP client 旗標分歧。
Feedback (lib/components/feedback/server-availability-guard/)
ServerAvailabilityGuard/ServerAvailabilityPanel— Server 確認不可用後以不可關閉的全畫面 Dialog 暫停操作,同時保留原 route tree 與表單狀態;恢復後仍等待使用者確認,逐筆顯示結果不確定 mutation 的 method/path,但不保存或重放 request body。MSW 連續自癒失敗達門檻後,會先警告未儲存 內容可能遺失,再以二次確認提供重載逃生口(reloadAfterAttempts/onReload)。
Feedback primitives (@appfuse/appfuse-web/components)
- 新增
Progress與ProgressProps:以原生<progress>/progressbar 語意表達工作完成進度;提供value時為 determinate(max預設 100),省略時為不輸出 value/max 的 indeterminate。 - 新增
Meter與MeterProps:以原生<meter>語意表達有界量測值,支援min/max/必填value、會翻譯的 accessible label/value text,以及與 Progress 一致的 DaisyUIcolor。元件不內建 quota 等領域狀態、觀測時間或 color mapping;缺值必須由 consumer 顯示 no-data representation, 不得以 0 代替。
Messaging (lib/messaging/)
-
usePromptQueue({ severities, currentPath })— 自建 messaging 容器的入口,回傳{ messages, dismiss }。訂閱、severity 過濾、thread 隔離、去重更新與移除整包提供,MessageDialog/MessageToast本身即建構於此。要換佇列策略(一次顯示幾則)、換過濾 規則或換呈現,用它加上Dialog/Toast自行組合即可。 -
translatePromptMessage()/resolvePromptAction()/resolvePromptDismissal()/DEFAULT_PROMPT_TITLES— 佇列訊息與呈現層之間的轉譯與回接。佇列存的是翻譯鍵與 原始動作字串,呈現層要譯文,而prompt.confirm()的 promise 必須 resolve 回原始字串; 這條往返先前只存在於MessageDialog內部,自建容器得逆向工程,做錯的代價是await prompt.confirm()永遠不 resolve,或 resolve 出呼叫端認不得的譯文。 -
createSystemEventChannel()/useSystemEvent()— typed、可回放且保留各事件最新快照的全域系統 事件通道,讓 apiClient 與 Redux/Modal/Guard 解耦。 -
createServerAvailabilityCoordinator()— 訂閱連線可疑事件,以 single-flight health probe、指數 退避與 jitter 管理available → suspected → unavailable → recovered,直到使用者確認才回復可用; 並提供resolveUnknownRequests()讓應用層不受 availability state 限制地清除已完成對帳的 mutation。
Actions (lib/components/actions/)
ButtonLink、ButtonLinkProps、InternalButtonLinkProps與ExternalButtonLinkProps(@appfuse/appfuse-web/components)— 以mode+to/href提供 internal/external discriminated API。internal mode 省略mode即維持完整 React RouterLink行為;external mode 輸出原生<a href>,預設新分頁並強制補齊noopener noreferrer,且要求可見、會納入 accessible name 的externalLabel。兩種模式都保留 modified click、中鍵、右鍵選單與輔助科技 link 語意, 同時與Button共用variant、color、density、shape、hover、focus-visible、active 的 class composition。新增disabledrepresentation:仍要求to/href,但不向 DOM 輸出目的地、 internal router props 或 externaltarget/rel,並以role="link"、aria-disabled="true"、tabIndex={-1}與 Button disabled classes 呈現;click/中鍵不會導航或觸發 consumer handler。title/aria-label/externalLabel延續框架自動翻譯;仍不提供loading、confirmation或warning。URL 信任與 allowlist 仍由消費端/API 契約負責。FileDownloadLink(@appfuse/appfuse-web/components)— 提供受保護檔案下載用的 link 樣式 action。 內部 API URL 透過HttpClientProvider注入的 client 取得 Blob,自動沿用應用的 baseURL、Bearer token 與 401 refresh;外部/presigned/blob/data URL 則交由瀏覽器原生下載,不向第三方附加認證資訊。 支援檔名覆寫、載入/禁用狀態、DaisyUI 語義色、成功/失敗 callbacks,以及預設的全域錯誤通知。- 新增
request模式(與既有source二擇一),支援 GET params、POST body、headers、AbortSignal, 並依序以明確filename、回應Content-Disposition、fallbackFilename、URL 尾段決定檔名。 - 公開
FileDownloader、useFileDownloader()、FileDownloadTarget、FileDownloadRequest、FileDownloadError與進度型別等下載引擎 API,讓需先驗證/儲存再下載的業務操作與FileDownloadLink、FileInput、MediaInput共用同一實作。
- 新增
Data Input (lib/components/data-input/)
-
Input新增revealableprop(@appfuse/appfuse-web/components與/form兩版)——密碼欄位的 明碼顯示切換鈕(眼睛),補齊Input既有的 type-conditional 右側控制項家族(clearable的清除鈕、 date 的日曆鈕、number 的增減鈕),此前各應用需自行以rightAddon重造,含aria-label、tabIndex={-1}與 addon 內距計算等細節,已在 fleet 內出現無障礙標註不一致的分歧。- 僅
type="password"生效,其餘 type 忽略;readOnly/disabled時不顯示。 - 預設
false(opt-in):明碼顯示在高安全情境(如二次驗證 modal)可能是刻意不提供的能力, 故不隨type="password"自動開啟,升級不改變既有畫面外觀。 - 切換狀態由組件內部持有,只改 DOM 的
typeattribute(password⇄text),不動props.type,故表單層的資料轉換與驗證完全不受影響——此前應用層在 form 層Input上切換type,該值會流進useInput的轉換管線。 - 與
clearable及自訂rightAddon並存(不像日曆/數字按鈕會被自訂 addon 取代), 因為revealable是顯式 opt-in。 - 新增 nls terms
Show password/Hide password。
- 僅
-
日期時間 picker 新增公開辨識式 props 型別:
DatePickerInstantTime、InstantDatePickerProps、LocalDatePickerProps、InstantDateTimePickerProps、LocalDateTimePickerProps,可從@appfuse/appfuse-web/components匯入。 -
OtpInput(@appfuse/appfuse-web/components與/form兩版)——分格驗證碼輸入 (OTP / PIN / 交易代碼)。視覺為length個分格,底層是單一<input>透明覆蓋其上, 分格僅為aria-hidden的呈現層。- 為何不是 length 個輸入框:分成多格必須自行搬移焦點,且會讓「一次貼 6 碼」只落進第一格、
破壞作業系統的
autocomplete="one-time-code"自動填入、並讓螢幕閱讀器聽到 6 個無標籤欄位。 單一輸入框讓貼上、自動填入、IME 與螢幕閱讀器全部沿用原生行為。 - 貼上萃取:貼上含前後文的整段文字(如「您的驗證碼為 482731,10 分鐘內有效」)只取
獨立的
length位代碼,不會把其他數字串接進來;帶分隔符的482-731、482 731亦可還原。 萃取邏輯另匯出為extractOtp/sanitizeOtp供應用層重用。 onComplete接縫:輸滿位數時觸發(含貼上一次填滿)。自動送出屬應用層決策,框架不內建 送出;需要「輸滿即驗證、畫面不放送出按鈕」時在此送出即可。- 字元集:
type為numeric(預設,inputMode="numeric")或alphanumeric(交易代碼類, 預設轉大寫,可用uppercase覆寫)。 - 家族對齊:沿用
label/hideLabel/helperText/error/required/density/color/disabled/readOnly與自動data-testid。三處刻意偏離:variant值為filled/bordered(分格外觀)、標籤置於分格上方不做浮動動畫(分格無容納浮動標籤的內縮 空間)、無placeholder(分格本身即佔位)。內層 class 的 prop 為cellClassName。 - form 版以
useOtpInput綁定 react-hook-form(空值寫回null,對齊框架表單慣例), 並透傳onComplete。
- 為何不是 length 個輸入框:分成多格必須自行搬移焦點,且會讓「一次貼 6 碼」只落進第一格、
破壞作業系統的
-
MediaInput新增下載能力(單檔 / 下載全部):hover 媒體區右下角出現下載鈕——gallery 檢視為「下載全部」(打包 zip)、單檔檢視為「下載當前檔」(原檔名)。受保護媒體(經 httpClient 帶 token 抓 blob 顯示)無法用瀏覽器右鍵另存,此鈕填補該缺口;readOnly仍可下載(純讀取)。進度 / 成功 / 失敗狀態顯示於欄位下方 helperText 位置、訊息後自動消失(取代 helperText、消失後復現),失敗顯示後端 RFC 7807 訊息,任一檔失敗即中止(不產生殘缺 zip)。- 共用下載引擎:單檔 / 多檔 zip 下載、失敗即 throw(帶檔名 + 後端錯誤)、進度回報與內嵌狀態列抽為
data-input/file-download共用模組,由FileInput與MediaInput共用(單一 JSZip 副本),不重複實作。FileInput的下載改由此模組驅動(行為不變)。
- 共用下載引擎:單檔 / 多檔 zip 下載、失敗即 throw(帶檔名 + 後端錯誤)、進度回報與內嵌狀態列抽為
-
內層控制項 class——
Input/Textarea/TagInput/Select各新增一個選填 prop, 用於直接指定內層控制項的 class(@appfuse/appfuse-web/components與/form兩版皆適用):元件 新 prop 套用對象 InputinputClassName內層 <input>TextareatextareaClassName內層 <textarea>TagInputinputClassName內層輸入 <input>(tag chip 仍用badgeClassName)SelectcontrolClassName觸發框(顯示已選值、點擊開啟選單的那層) - 解決什麼:既有的
className套用對象是包住控制項 / label / notch 的外層 wrapper, 排版類 utility(text-center、tracking-*、字級)會經繼承散到浮動 label 與 notch legend 上—— label 被字距撐開、邊框缺口與 label 對不齊,而字級因控制項自帶text-base與 DaisyUI.input的font-size而根本不生效(典型受害場景:OTP 驗證碼欄位想要置中大字距)。 - 命名慣例:
className= 外層 wrapper、{內層元素}ClassName= 內層控制項,沿用既有badgeClassName的元素具名慣例;四個 prop 的 JSDoc 互相交叉引用,避免再誤用。 - 合併語意:以
cn()(tailwind-merge)接在元件內建 class 之後,故可直接覆寫內建的字級等 衝突 class,不需!important。 - picker 家族自動涵蓋:
DatePicker/TimePicker/DateTimePicker/InstantPicker以Input為欄位外殼並{...rest}展開,故inputClassName直接生效,不需各自轉送。 - 未變更者:
Checkbox/Radio/Switch的className本就直接套在控制項<input>上、RichTextEditor的className本就套在編輯器容器(label 為其兄弟節點,不受繼承影響), 皆無此問題;FileInput/MediaInput無承載輸入文字的內層控制項,不適用。 - 向後相容:純新增選填 prop;未傳時內層控制項的 class 組法與先前完全一致(不經 tailwind-merge),
className的既有語意與行為不變。同時補上四個元件className的 JSDoc,明示其作用於 wrapper。
- 解決什麼:既有的
Feedback (lib/components/feedback/)
-
variant(Dialog/MessageDialog,'solid' | 'soft',預設'solid')+DialogVariant型別 — 調的是主要面的響度:solid為實色標題列 + 實心 severity 主要鍵、soft為柔色標題列 + severity 色框線主要鍵。次要鍵不受影響(恆中性框線)。- 與
actionHierarchy={false}搭配是主要動機:扁平模式下全部動作鍵採主要樣式,solid會得到兩顆以上的實心 severity 色鈕(error 即兩顆飽和紅),視覺過於強烈;soft則為兩顆 severity 色框線鈕,扁平而不吵 - 與同版稍早移除的 variant 的關係:原三值軸(solid/soft/outline)因「對稱 Toast 而生、
兩值零消費」被移除;此處是證據驅動地把
soft單獨帶回(有具體病灶),outline不回歸 (見 Removed) - 實作註:soft 主要鍵用框線而非淡底——標題列已是柔色,主要鍵若也用淡底會與之糊成一片。
因此不使用
Button的淡底soft變體,而是明確選擇outline。
- 與
-
actionHierarchy(Dialog/MessageDialog,預設true)— 是否以樣式區分主次動作。 設false時全部動作鍵一律採主要樣式(severity 色實心),不作降階。- 給誰用:房子設計語言主張「對話框動作一律等重」的專案。此前這類專案只能整個
fork 掉對話框渲染——改用自訂的全域訊息渲染器只能解掉
prompt.*()那條路徑, 直接使用<Dialog>的地方仍受影響 - 只關樣式,不關語意:
actions[0]仍為主要(肯定選項)、MessageDialog的關閉 / ESC 仍 resolve 最後一項、左右順序仍為「最後宣告的在左、actions[0]在右」 - 為何是開關而非 per-tier 的 variant / color 覆寫:需求是相對的(「次要與主要一致」),
而主要的顏色隨 severity 變動。覆寫是絕對值,在單一
<MessageDialog>要服務所有 severity 的情況下表達不了這層關係(寫死error對 warning 訊息即為錯)。開關讓次要走與主要 同一條分支,跨所有 severity 皆正確。日後若出現「次要要用 ghost」這類絕對需求, 再加 per-tier 覆寫即可,兩者可疊加
- 給誰用:房子設計語言主張「對話框動作一律等重」的專案。此前這類專案只能整個
fork 掉對話框渲染——改用自訂的全域訊息渲染器只能解掉
-
AlertVariant/Alert的variantprop('solid' | 'soft' | 'outline',預設'solid')— 對應 daisyUI alert style modifier,與ToastVariant對齊(Alert 與 Toast 是同一個 daisyUI alert primitive 的兩種投遞方式,樣式軸不應分歧)。soft對就地提示尤其有用:混在表單 / drawer 內容中時,整片實色常過重
-
Alert(@appfuse/appfuse-web/components)— 就地提示。與Dialog(阻塞)、Toast(浮動) 共用同一組Severity,是該語彙的第三種投遞方式,差別在位置與生命:Alert 留在版面內、 貼著出錯的區域、條件成立期間持續顯示。- 選型判斷句:使用者需要邊看著提示邊修正(表單驗證摘要、區塊載入失敗、狀態提醒)
→
Alert;全域性的一次性事件 →prompt.*()。後者的兩個渲染器都會離開版面——prompt.error()/prompt.warning()是阻塞模態(得先關掉才看得到要修的表單)、prompt.success()/prompt.info()是會自動消失的 toast(使用者還沒改完就不見了) - 補上
12-notifications.md錯誤處理策略中「有表單 → Inline Alert」一列長期缺少的元件: 此前參考實作有 16 個檔案各自手貼 daisyUIalert alert-*class 與 inline SVG - 預設帶 severity 圖示(就地提示混在版面內容中,圖示是掃視時辨識嚴重程度的主要線索,
不像 Toast / Dialog 有獨立浮層或標題列可依靠);
icon={<Custom />}覆寫、icon={null}關閉 onClose給了才顯示關閉鈕,省略即為常駐提示(多數 inline 情境);可關閉與常駐兩者等高title給了則內容降為次要層級、圖示改頂端對齊;無title時圖示與單行文字置中對齊- 無障礙:
severity="error"為role="alert"+aria-live="assertive"(即時打斷), 其餘為role="status"+aria-live="polite"(不搶斷當前朗讀) - 配色與
Toast共用同一份 daisyUI severity 對應(confirmation沿用 info 樣式),不分歧
- 選型判斷句:使用者需要邊看著提示邊修正(表單驗證摘要、區塊載入失敗、狀態提醒)
→
Overlays (lib/components/overlays/)
-
模態家族的關閉鈕改為響應式觸控目標:手機 40px、
sm以上回到原本的 32px。 涵蓋Modal、Sheet、Dialog與FileInput的預覽對話框。density="tight"的 32px 在滑鼠下夠用,但手指按不準,而覆蓋層的關閉鈕一律貼在 panel 右上角、誤觸代價是 把填到一半的表單或正在看的預覽關掉。只放大命中區、不放大圖示,視覺重量不變。Alert/Toast不在此列——那是行內通知,關閉鈕不在角落且 Toast 會自動消失, 放大只會擠壓訊息本文。 -
Modal.description?: ReactNode— 標題下方的補充說明 slot,渲染於內容區頂端、children之上, 並自動以aria-describedby關聯到對話框。此關聯只有元件做得到——呼叫端自行在children裡 放一段說明文字,對輔助技術而言只是內容的一部分,讀不出「這是對話框的描述」。 未提供則整段不渲染,對既有用法零影響。 -
Tooltip.className— 提供 panel 樣式出口,可覆寫預設max-w-xs、text-xs、padding 與換行方式, 支援「比一句話稍長、但仍不可互動」的提示;需要互動內容時仍使用Popover。 -
Sheet size="wide"— 補上lg(right 32rem/bottom 80dvh)與full之間的尺寸階梯;right drawer 使用max(32rem, 66.667vw)承載多欄明細/編輯工作區,bottom sheet 對應 90dvh。既有尺寸不變。 -
Popover(@appfuse/appfuse-web/components)— 錨定在 trigger 旁、承載任意可互動內容的非模態role="dialog"浮層。支援 click/hover(hover 同時支援鍵盤 focus)、四種 placement、Portal、 auto flip + edge padding、點外與 Escape 關閉、Escape 後焦點還原,以及aria-expanded/aria-controls。 hover/focus 揭露不移動焦點;click panel 的 Tab 離開邊界會略過隱藏/inert 元素並回到 trigger 前後的邏輯順序,也涵蓋 contenteditable、iframe、media controls 與 summary。Escape 先交給 panel 內控制項消費,否則只關閉最上層 Popover,不連帶關閉巢狀 Popover 或外層 Modal/Sheet;若後開的 Modal/Sheet 疊在 Popover 上方,來自其aria-modal="true"容器的 Escape 會交由模態層處理。 React 19 callback ref cleanup 會完整轉發。 panel 的className可覆寫寬度與 padding;trigger 採cloneElement組合框架與 child handlers / ref, 不增加 DOM wrapper。Dropdown保持純 menu 語意,不以 props 混成 dialog。 -
Sheet(@appfuse/appfuse-web/components)— 貼邊任務型模態外殼;同一個 Headless UIDialoginstance 支援rightdrawer 與bottomsheet,並以純 CSS 的ResponsiveSheetPlacement(如{ base: 'bottom', md: 'right' })在 breakpoint 切換, 不需matchMedia、不重建 children,也不會同時產生兩個 Dialog。placement僅提供right | bottom,預設right;刻意不擴充 left / top,避免在尚無消費 證據時放大公開設計語彙與 class 組合面。size為sm | md | lg | wide | full(預設md),語意軸隨 placement 改變:right 映射寬度, bottom 映射最大高度;完整 placement × size × breakpoint class 皆以字面 mapping 發布, 供下游 Tailwind 靜態掃描。- backdrop、Escape 與關閉鈕共用
onClose;closeDisabled同時擋掉三條途徑,showCloseButton={false}只隱藏按鈕、不影響其餘允許的關閉方式。 - Headless UI 負責 Portal、focus trap / restoration、背景 inert 與 document scroll lock; body 獨立捲動使 header / footer 保持可見。
- DOM anatomy 不列為公開契約;內建
data-testid僅供框架測試,應用端樣式擴充使用className,不得依賴內部節點名稱。 - 方向性進出場動畫使用 Headless UI v2
transition+data-closed,並以motion-reduce移除動畫;顏色、圓角、陰影與 backdrop 全使用 DaisyUI / framework token。 - 框架只提供模態責任、貼邊排版與動畫;應用端特定的啟用 breakpoint、篩選狀態與內容模型 不進入 Sheet API。
-
Modal(@appfuse/appfuse-web/components)— 承載自訂內容的模態外殼,children與footer皆為 slot。補上20-components.md早已列出、實際卻不存在的元件。- 選型判斷句:需要顯示一則訊息、或要一個是/否的答案 →
Dialog(severity 驅動、content是字串、actions是資料陣列);需要放一個表單 →Modal - 為何
footer是 slot 而非actions: string[]:真實的任務型對話框,動作鍵需要圖示、 載入態與文字對調、綁表單狀態的 disabled、以及成對條件切換——皆非資料陣列可表達。 此前參考實作的四個對話框(取消訂單 / 上傳照片 / 草稿衝突 / 停用客戶)正是因此全部繞過 框架Dialog、各自手拼 Headless UI closeDisabled一次擋掉三條關閉途徑(關閉鈕 + 點擊背景 + ESC)。此前手拼的四個 對話框只在關閉鈕與自家 handler 加守衛,處理中按 ESC 或點背景仍會關掉——屬既有缺陷, 一併修正- 內容過高時由內容區捲動,header / footer 保持可見(否則長表單會把 panel 撐出視窗、
footer 的送出鍵搆不到);footer 帶
border-t與 header 對稱,捲動時邊界清楚 size(sm/md/lg/xl,預設md)1:1 對應 Tailwindmax-w-*footer的排列慣例為次要在左、主要在右,與同版Dialog的調整一致- 關閉鈕將 raw
Closekey 交給Button單次翻譯,修正 Modal 與 Button 各翻譯一次造成的aria-label雙重翻譯 - 錯誤訊息屬呼叫端內容、放在
children(建議用Alert),非 Modal 的 prop—— Modal 是外殼,不認識業務狀態
- 選型判斷句:需要顯示一則訊息、或要一個是/否的答案 →
Data Display (lib/components/data-display/)
-
rowPosition—— 資料列人工位置(拖曳重排),DataTable/VirtualTable/VirtualList三支共用同一套核心與語意。- 與排序是兩件事:
sorting是檢視狀態、position 是資料本身的屬性,故啟用時欄位排序 自動停用——不是額外規則,是必然結果。 - 元件不計算位置值:只回報使用者做了什麼(把哪一列放到哪兩列之間),不需知道應用用哪個 欄位記錄位置。理由是元件算得出正確位置值的時機只有「資料全載且未篩選」——而那正是應用 自己算也毫不費力的情況;分頁邊界、篩選、浮點精度耗盡這些真正難算的情形元件都算不出來。 排序鍵方案(稀疏浮點、整數重排、lexorank、prevId 鏈結)因此完全由應用決定。
placement有三種:{ type: 'between', beforeId, afterId }(拖曳,鄰居是data之內 的相鄰列,邊界以null表示該側無鄰居)、{ type: 'start' }/{ type: 'end' }(命令式,語意是整個資料集的開頭 / 結尾,伺服器不需知道載入狀態即可執行)。onReposition可回傳boolean/Promise<boolean>/void決定元件是否保留樂觀順序;data的 id 序列一變(伺服器順序真的改了)即以data為準。onCommandsReady交出moveBy/moveToStart/moveToEnd,供應用接進自己的列動作—— 框架不內建上下移按鈕,UI 只有拖曳把手。- 表格由框架注入把手欄(釘最左、不進欄位面板);
VirtualList則把把手屬性交給renderItem的state.positionHandleProps,由呼叫端自行安置(同該元件不塞勾選欄的決定)。 - 適用數十至數百列。
SortableContext收全量 id,數千列以上拖曳會變鈍;大資料集請用命令式 API。 - 新增 i18n key:
Drag to reposition row(把手的aria-label),下游需補字典。 dragPreview('displace' | 'lift',預設'displace')—— 拖曳期間的視覺語言。'displace'是既有行為(其餘列即時位移讓出落點,verticalListSortingStrategy假設等高列);'lift'讓其餘列不動、改由框架在落點邊界畫一條 2px 的 accent 色插入指示線 (取 accent 而非 primary:主題的 primary 彩度低,細線讀起來像灰線),等高假設因此消失, 不等高列(VirtualList明示支援)的落點才準確——這是它存在的技術理由,不是換個樣子。 兩種模式的把手、鍵盤操作、樂觀更新、pending 鎖定與失敗回退完全相同。刻意不走DragOverlay: 那會同時撞上虛擬化(列捲出視窗即卸載)與<table>(portal 裡的<tr>不合法)兩個硬點, 而被拖列在無 overlay 時本就跟著指標(dnd-kit 取 drag source 自己的 transform)。- 拖曳中的唯讀狀態外露:新增
useRowPositionDragState()(在三支元件的子樹內任意深度可用), 回傳{ activeRowId, sourceIndex, targetIndex };VirtualListItemState另新增isDragging與dropEdge('before' | 'after' | null)。狀態只讀不寫——拖曳期間 不呼叫onReposition,順序仍只在放開時決定一次。新增 export:RowPositionDragPreview、RowPositionDragState、RowDropEdge。 - 觸控需長按 200ms 才拿得起來(滑鼠仍是移動 5px 即啟動)。原本只註冊
PointerSensor, 它連觸控一起吃,於是手指落在把手上捲動即可能誤觸拖曳;改為MouseSensor+TouchSensor兩組門檻。觸控靠時間而非touch-action與捲動區分意圖,故把手上的touch-action不設限——快速滑動照常捲動清單,停住 200ms 才是拖曳。門檻值由框架統一 (lib/components/shared/dnd-activation.ts),不開放逐處設定——「拿起一個東西要按多久」 是互動一致性、不是業務參數,且開放它等於把 dnd-kit 的activationConstraint形狀釘進 公開 API surface。
- 與排序是兩件事:
-
useVirtualScroll新增getItemKey(內部型別,非公開 prop)—— 虛擬化的量測快取改綁穩定 key 而非索引。目前僅VirtualList在啟用rowPosition時使用:只有它以measureElement動態量測(明示支援不等高列),項目一重排舊高度會錯位到別的項目上,直到下次量測才修正。VirtualTable的列高恆為estimateSize,沒有快取可錯位,故不需要。 -
DataTable.height(string | number,選填)— 控制整個元件(表格捲動區 + 內建分頁器)的高度;height="100%"會切到 flex/fill 版面,讓資料區使用flex-1 min-h-0捲動、分頁器固定留在底部, 下游不再需要ResizeObserver量測剩餘高度或猜測分頁器高度。未提供時維持既有自然高度;既有maxHeight保留為「只限制表格捲動區」的 API。height與maxHeight同時提供會明確報錯,避免 衝突設定被靜默忽略。 -
rowSeparator(DataTable/VirtualTable)— 列區分方式的單一屬性,取代原本的striped布林(見 Removed)。三態'zebra' | 'divider' | 'none',預設'zebra'(等同原striped預設)'divider'是新能力:明顯的列隔線(base-content/20)。DaisyUI 的.table原生雖有tbody tr:not(:last-child) :is(td,th)的 border-bottom,但只有base-content/5,肉眼近乎無感, 且從無屬性可控- 實作只覆寫 border 顏色、不動 width / style——最後一列與
VirtualTable的虛擬化佔位列本就 沒有 border-bottom(width 0),改色不會讓它們憑空冒出一條線 'none'以border-b-0歸零 tbody 隔線(thead 底線不受影響)- 與
bordered(外框)正交,可並用;sticky欄的 hover 補色改由rowSeparator分流 ('zebra'→ base-300,其餘 → base-200) - 一併匯出
TableRowSeparator型別(data-table/virtual-table兩個 entry 皆有)
-
PdfViewercontinuous page render error — continuous mode 不再靜默留下空白頁;單頁 canvas render 失敗時顯示與 single mode 一致的可見錯誤狀態,其餘頁面仍可繼續操作。README 同步記錄pdfjs-diststandard build 的瀏覽器 baseline 跟隨策略與目前實測組合。 -
toolbarActions+PdfToolbarAction(PdfViewer)— 宣告式的工具列動作,由 PdfViewer 以 內建工具按鈕的同一組樣式渲染(尺寸、色彩、hover、圖示大小),排在toolbarSlot之後、 內建按鈕之前。欄位為{ id?, icon, label, onClick, disabled?, pressed? };label傳英文 i18n key,框架自動翻譯為aria-label與 tooltip。- 動機:
toolbarSlot只收ReactNode,消費端要加一顆工具列按鈕就得目測抄內建樣式, 但pdfViewerControlButtonVariants並未對外公開——結果必然是尺寸、色彩與 hover 都對不齊。 - 需要 icon 按鈕時一律用
toolbarActions;toolbarSlot僅保留給無法表達成動作的客製內容。
- 動機:
-
ReportViewer(@appfuse/appfuse-web/components)— 報表使用情境的高階 PDF viewer。以 application-owned discriminated state 呈現產生中、可預覽、失敗與過期狀態;ready 時組合既有PdfViewer,加入全部/目前頁/連續範圍/指定頁碼的 physical-page selection。公開ReportArtifact、ReportPageSelection、ReportPrintRequest與ReportViewerAdapter,讓 Prototype 使用 mock adapter,server 整合版則取得已確認的pageCount、在同一接縫建立 subset PDF 並呼叫瀏覽器列印;列印準備支援透過AbortSignal取消。元件不依賴 serverReportDocument、renderer 或 job Entity。- 列印按鈕經
PdfViewer的toolbarActions渲染,與內建工具按鈕樣式一致;應用可用ReportViewer自身的toolbarActions加入更多按鈕(排在列印之前)。 controlsVisibility未指定時採'always'(非PdfViewer的'auto'):列印是報表情境的 主要動作,藏在 hover 後面不利於發現;以viewerProps.controlsVisibility可覆蓋。
- 列印按鈕經
-
fitMode(PdfViewer,'width' | 'page' | 'none',預設'width')— 基準縮放:頁面先貼合 容器,使用者縮放以此為基準相乘(實際 scale = 基準 × zoom),容器尺寸變動時自動重算。- 動機:PDF 頁面的原始寬度(pt)與容器寬度無關,先前恆以
scale=1渲染,放進抽屜 / side panel(如 512px)必然產生水平捲動,等於抵銷了窄容器的比例優勢 'width':貼合可用寬度(垂直捲動閱讀);'page':整頁貼合;'none':維持原始尺寸 (zoom 即絕對縮放,等同先前行為)single以當前頁尺寸為基準,continuous取所有頁的尺寸包絡(混合版面不溢出);旋轉 90° 時以對調後的長寬計算- 基準未算出前顯示 spinner,不會先以
scale=1畫一次再跳動
- 動機:PDF 頁面的原始寬度(pt)與容器寬度無關,先前恆以
-
controlsVisibility(PdfViewer,'auto' | 'hover' | 'always',預設'auto')— 工具列與 頁碼導航的顯示策略。'auto'在有 hover 能力的裝置維持 hover 才顯示、在觸控裝置恆顯示 (觸控沒有 hover 事件,只靠 hover 等於永遠碰不到控制項,使用者只能猜)。判準為(hover: hover)media query,與DataTable/VirtualTable浮動控制項的既有慣例一致。 -
focusIndicator(MediaViewer,預設true)— 是否繪製自己的鍵盤焦點指示環。內嵌於自身已有 焦點樣式的宿主(如MediaInput的欄位外框 + floating label)時設為false,避免兩個外框疊在 一起(見 Fixed)。 -
MediaThumbnailOptionProps/MediaThumbnailContainerProps(MediaViewer)— thumbnail 模式改採 roving selection 的無障礙契約。renderThumbnail的 helpers 多了optionProps(id/role="option"/aria-selected,需展開在縮圖根節點),renderThumbnailContainer多了第二個參數containerProps(role="presentation",需展開在容器根節點)。兩者皆為附加,既有 slot 實作不傳 也能編譯,但不展開就拿不到正確的無障礙語意。- 模式與
RichTextEditor工具列一致:容器是唯一 tab stop,方向鍵移動的是選取而非焦點, 避免每個子項各佔一個 tab stop
- 模式與
-
persistKey(DataTable/VirtualTable)— 欄位版面(欄序 + 顯示/隱藏)的localStorage持久化。給了才啟用;使用者調整的版面跨頁面往返 / 重新登入後自動回復,不再每次回到畫面就重設。- key 為
appfuse.table-layout.{persistKey},需在應用內唯一(同 key 的多個表格會互相覆蓋) - 優先序:已持久化的版面 >
defaultColumnOrder/defaultColumnVisibility種子 - 欄位定義變動時自動調和:仍存在的欄保留使用者順序、新欄插回自然位置、已刪除的欄移除, 不因加減一欄就丟掉整份版面
- 容錯:SSR / 隱私模式 / 配額已滿 / 存檔損毀皆靜默退回預設版面,不讓表格連帶壞掉
- 每瀏覽器 / 每裝置;要跨裝置同步仍走
onColumnOrderChange/onColumnVisibilityChange自行存後端 +default*回種 - 齒輪的「重設版面」會一併把已持久化的版面重設回自然順序 / 全部可見
- key 為
-
VirtualList(@appfuse/appfuse-web/components)— 虛擬化 + 無限捲動的清單。與VirtualTable共用同一套虛擬化捲動核心(useVirtualScroll:虛擬化器、height 解析、無限捲動偵測、回到頂部、 載入指示條),差別只在內容形狀:表格是欄位化的格(表頭 / 排序 / 欄序 / 欄位顯示隱藏才有意義), 清單的一列是不可拆的複合內容、長相全交給renderItem。- 選型判斷句:需要表頭 / 排序 / 欄位操作 →
VirtualTable;列是自訂卡片(master-detail 的清單欄、 通知 feed、活動日誌、搜尋結果)→VirtualList。把複合列硬塞進VirtualTable會得到一個 只有單欄、表頭無意義、排序 / 欄序 / 齒輪全部落空的表格 - Row Selection 與
DataTable/VirtualTable對齊(同一份RowSelectionState、同名的getRowId/selectionMode/getRowCanSelect/onSelectAll/onDeselectAll),但不支援 checkbox(清單的一列不可拆,塞勾選框會破壞renderItem的版面;唯一內建操作途徑是enableRowClick)、也沒有全選鈕(沒有表頭可放,程式化全選走onSelectAll)。內部不引入useReactTable,自行管理同形狀的狀態 - 選中視覺可交還呼叫端:
selectionStyle預設'accent'(元件套用設計語言的背景 tint + 左緣 3px 強調條,與兩張表的選取列一致);設'none'則完全由renderItem依第三參數state.isSelected自行呈現 - 列內互動元素不會誤觸選取:
enableRowClick下,來自列內button/a[href]/input/select/textarea/label/summary/[contenteditable]/ 常見 role,以及泛用兜底[tabindex]:not([tabindex="-1"])的點擊與按鍵不冒泡成選取切換——元件承諾「你的列可以有 自己的按鈕」,故不代包<button>,改在此側攔截 - 無障礙:可選取的列可 focus、支援 Enter / Space 切換,並有隨
color上色的 focus ring (與選中標示同色系)。語意維持role="list"/role="listitem",選中以aria-current表達 ——刻意不宣稱 listbox/option(ARIA 規定 option 的內容為 presentational,與「列內有自己的 按鈕」相斥),故也不實作 listbox 的方向鍵導覽。selectionMode="multi"下多列同時帶aria-current是 ARIA 灰色地帶,屬「排除 listbox + 不放 checkbox」前提下的取捨 - 動態高度:項目不需等高,掛載後由
virtualizer.measureElement量測修正;estimatedItemHeight僅為初始估計(預設 56) height傳百分比時切到 flex/fill 佈局,父容器須有確定高度(flex column 成員或有固定高度的 grid cell / block 皆可)- 匯出:
VirtualList、VirtualListProps、VirtualListItemState、DEFAULT_ESTIMATED_ITEM_HEIGHT(RowSelectionState已由DataTable匯出,三者共用同一型別)
- 選型判斷句:需要表頭 / 排序 / 欄位操作 →
-
VirtualTable.manualSorting(boolean,預設true)— 與DataTable同名同義,補齊兩支的排序 介面一致性。false時改為元件內排序(註冊 TanStack 的getSortedRowModel),可用其內建的 型別感知比較器(alphanumeric自然排序 /datetime/text)、多欄接續邏輯,以及欄位層級的sortingFn/sortUndefined/sortDescFirst——這些在後端排序時是啞的。 只在資料全部載入時生效:data只有一部分時,元件內排序排的是那一部分,得到的順序不反映 整個資料集,故會退回後端排序並於開發模式說明(onSortingChange照常觸發)。判定「全部載入」 ——VirtualTable是未提供infiniteScroll或hasNextPage === false,DataTable是未提供pagination或totalPages <= 1。閘門為即時運算,模式會在 session 中途翻轉(最後一頁載完、 或篩選後總頁數降到 1),翻轉當下會立刻以元件內比較器重排一次。 -
DataTable.getRowId((row, index) => string,選填)— 行的唯一識別碼,selection state 與 React key 共用。未提供時退回「頁面偏移序號」((currentPage - 1) * itemsPerPage + index)而非頁內索引—— DataTable 只支援後端分頁,data只有當前頁,用頁內索引會讓第 2 頁第 1 列與第 1 頁第 1 列共用 id'0',選取直接跨頁串到錯的資料。序號在換頁之間穩定,但排序/篩選後指向不同資料,故有選取 需求仍應提供業務主鍵。啟用 selection 但未提供時,開發模式會console.warn一次 (VirtualTable/VirtualList同樣加上此提醒)。
Utils (lib/utils/)
getApiErrorMessage(error, options?)(@appfuse/appfuse-web/utils)— 從 API 錯誤萃取可顯示訊息。 優先序:violations>detail>title>Error.message>fallback;同時容受ErrorResponse實例、未經攔截器轉換的原始 AxiosError(response.data)與序列化後的純物件。 Bean Validation 違規組裝成「欄位標籤:訊息」並就地 i18n——訊息以原文(或violation.format模板 +params)為 i18n key;欄位標籤以field.{propertyPath}為 key(巢狀路徑 join 成field.address.city),查無對應時退回原始屬性名 (避免多個必填欄位顯示成數行一模一樣的「不可空白」、使用者無從辨識是哪一欄)。options可為 fallback 字串(向後相容的簡寫)或ApiErrorMessageOptions(fallback/fieldLabelPrefix/fieldSeparator/violationSeparator)。 此前各應用專案各自手寫同一份 helper(src/services/api-error.ts),已上收為框架 utilApiErrorMessageOptions— 上述選項型別
Components (lib/components/)
- Data Input(11 個組件)
Input— 文本輸入框(支援 Floating Label)Select— 下拉選單(單選/多選、搜尋、創建);新增六個 opt-in async 搜尋接縫(對現有靜態用法 dormant),支撐「後端 typeahead → 富列 → 選取/新建」與「search-to-add」——由 React Query 驅動資料:onInputChange(text)— 外露搜尋字供 async 查詢當 queryKey(提供時搜尋框恆顯示;空搜尋時下拉顯示「Type to search...」引導而非「No options found」,避免誤讀為無資料)skipLocalFilter— 關閉內建本地過濾(後端已過濾,避免藏掉非顯示欄位命中)loading— 顯示搜尋中、不顯示空狀態renderOption(option, { active, selected })— 整列接管自訂內容(頭像/電話/群組 badge、SKU/缺貨 badge);框架管行為、消費端管長相onSelect(option) => boolean— 提交前守衛;回false否決提交並清搜尋,下拉收合尊重closeOnSelect(單選預設收起;搭配closeOnSelect={false}保持開啟續搜)→ search-to-add(回 void 為正常 fill)footerAction— 下拉底部持久動作 slot(如「快速新建客戶」,有無結果皆顯示)- 下拉內搜尋框改用框架
Input(ghost variant)——無邊框融入下拉、由搜尋列 border-b 分隔,i18n / 主題與其他欄位一致
SelectMenu/OptionRow— 從Select抽出的下拉「清單主體 / 單列」presentational 子組件,新增為公開 export(@appfuse/appfuse-web/components)。Select 內部下拉與下游 design-system 的 Design Language story 共用同一份,確保選單列外觀(分組標頭、icon、多選 checkbox、已選勾、鍵盤高亮、disabled、renderOption整列接管)零漂移(對齊DatePicker的CalendarGrid)。純結構抽取、零行為變化CheckboxTagInput— 標籤輸入(maxLength 限制)FileInput— 檔案上傳(PDF、Word、Excel);chip 上點開動作選單(Preview / Download),預覽依 MIME 派發到 MediaViewer(image/video)或 PdfViewer(PDF),不支援類型 Preview 自動 disabled;X 仍為快捷刪除MediaInput— 媒體上傳(圖片/視頻、預覽);可 opt-in 接受 PDF(accept加入application/pdf),PDF 以「逐頁圖檔」方式預覽,value/刪除/maxFiles 仍以整份 PDF 為單位RichTextEditor— 富文本編輯器(基於 Tiptap v3 / ProseMirror)DatePicker— 日期選擇欄位(ADR-012):以Input為欄位外殼(floating label / variant / density / color / error 全數沿用),popover 月曆的日期可選性由days: DayStatus[]資料驅動——接後端具名行事曆resolve()的解算結果({date, selectable, reasonCode, description},鏡射 appfuse-servercalendar契約),不可選日期灰字(週休類加刪除線)、圖例彙整當月 reasonCode(經 i18n)、description透傳為 tooltip;元件零行事曆邏輯、不打 API。日期值容忍string | Date(days[].date、value/defaultValue)——框架 http client 對無時區的日期(LocalDate/LocalDateTime)保留 ISO 字串、僅將帶時區的 instant 轉為Date,元件內部對string | Date自行正規化,應用層零轉換;base 版onChange輸出 ISOYYYY-MM-DD字串(UI primitive 中性形狀)。onRangeChange於顯示月份跨出資料涵蓋範圍時通知應用層補載;disabledDatepredicate 僅供純前端表單內約束逃生口;renderDay自訂 day-cell 內容。月曆導航頭點標題可開年/月快速跳選(月選單一次選該年 12 個月、年選單每頁 12 年翻頁),選遠年不必逐月點擊;選單開啟時隱藏「上/下個月」箭頭(避免兩排箭頭);日層附**「今天」**按鈕一鍵跳回今天所在月份。同時匯出底層CalendarGrid(月曆網格渲染器,day-cell render prop 為未來事件行事曆檢視的擴充縫)、CalendarNav(導航頭 + 年/月快速選單,包住CalendarGrid供DatePicker/DateTimePicker共用)與DayStatus/RenderDayContext/CalendarNavProps型別TimePicker— 時間選擇欄位:以Input為欄位外殼,自建時 / 分(可選秒)欄位 popover(不使用原生<input type=time>,跨瀏覽器一致)。值為無時區 LocalTime 字串(HH:mm/HH:mm:ss,對齊後端LocalTime);minuteStep/secondStep控 popover 選項密度、withSeconds顯示秒欄。匯出parseTime/formatTime/nowParts工具與TimeParts型別DateTimePicker— 日期時間選擇欄位:單一欄位、單一 popover 組合月曆(重用CalendarGrid與行事曆DayStatus資料驅動,ADR-012)+ 時間欄。值為無時區 LocalDateTime 字串(YYYY-MM-DDTHH:mm[:ss],對齊後端LocalDateTime),不轉Date;沿用 DatePicker 的days/onRangeChange/disabledDate/renderDay與 TimePicker 的withSeconds/minuteStep/secondStep;讀取端寬容Date值(以本地牆鐘拆解顯示)。timeColumnHeaderslot 可在時間欄頂部(h-8 與月曆導航列對齊、下方留間距)附掛輔助控制(如 InstantPicker 的時區選單);未用此 slot 時 Hour/Minute 標籤即與月曆導航列(月年/今日/箭頭)對齊、清單緊湊列高向下延伸。popover 主體(月曆+時間欄)抽為共用呈現元件DateTimePanel(一併匯出),供元件與其 Storybook Design Language 快照共用同一份版型、杜絕漂移。匯出splitDateTime/combineDateTime工具與DateTimeParts/DateTimePanelProps型別InstantPicker— 絕對時刻(Instant)選擇欄位:UI 承DateTimePicker(月曆 + 時間欄),但值為帶時區的絕對Date——使用者選的牆鐘以選定時區解讀成絕對瞬間(對齊後端Instant;Date送出時由框架 http client 序列化成帶 offset 的 ISO)。時區可由 App 指定並開放使用者選:timeZoneprop 指定解讀牆鐘的時區(預設瀏覽器時區)、popover 時間欄頂部(與月曆導航列對齊)一律顯示時區——只顯示時區名稱(無標籤、無 GMT;GMT 於選項與 tooltip):傳入timeZones清單時為多選可改的 ghost 選單(使用者覆寫)、未傳時為同款 ghost 但僅單一選項(唯讀)——兩者版型/置中一致;改時區採「牆鐘不變、瞬間改變」語意。onChange(value: Date | null, timeZone: string)同時回報瞬間與選定時區(後端Instant只需瞬間,timeZone供需另存檢視時區的場景;既有只讀第一參數的呼叫端相容)。用於「使用者輸入一個絕對時刻」(如排程「在此刻執行 / 發布」)、跨時區需指向同一瞬間的場景;需無時區鐘面日期時間請改用DateTimePicker。匯出browserTimeZone/timeZoneOffsetMinutes/instantToZonedWallClock/zonedWallClockToInstant/formatTimeZoneLabel任意 IANA 時區換算工具(含 DST);wallClockToInstant標記@deprecated(改用zonedWallClockToInstant(value, browserTimeZone()))
- Data Display
Badge— 狀態/標籤 chip(@appfuse/appfuse-web/components)。基於 DaisyUIbadge,whitespace-nowrap內建——狀態文字一律單行不折行(窄欄如DataTable狀態欄也不拆行)。支援 8 種語意顏色(color:default / primary / secondary / accent / info / success / warning / error)、4 種填色樣式(variant:solid / soft / outline / ghost)與 5 種尺寸(size:xs–xl)。取代此前各處手寫的<span className="badge …">。一併匯出BadgeProps型別與badgeVariants(cva)Icon(基於 Lucide React)MediaViewer(輪播、全屏);採「外殼 + 每格式檢視元件(Image/Video/PDF)」策略架構,展開操作選單隨格式變化;支援以 PDF 承載的圖檔(一個 PDF = 一個項目);導航分層——外殼底部 ‹ ›+圓點換檔案(←/→),PDF 翻頁由 PDF 自帶頁碼列處理(PageUp/PageDown)PdfViewer(PDF 文件檢視;single / continuous 模式、縮放、旋轉、縮圖、下載、列印、文字搜尋;依賴pdfjs-distpeerDependency)DataTable/VirtualTable浮動動作選單(speed-dial)——右上角把多個表格檢視動作收合成單一齒輪鈕(點擊放射狀展開、往左下象限,視覺語言對齊MediaViewer弧形控制;只有一個動作有意義時則直接顯示、不套齒輪)。齒輪固定於右上角、不支援拖曳(方向固定、無需象限偵測)。預設 hover 表格才淡入(鍵盤 focus / 展開中 / 無 hover 能力的觸控裝置恆顯示),把畫面收乾淨。兩表共用同一實作(內部TableActionSpeedDial)+ hover 邏輯共用useTableHoverReveal。動作鈕給不透明表面底色 + 邊框(bg-base-100),壓在資料列上仍清楚可辨(不因透明底混入底下文字);所有動作鈕外觀一致——切換類動作(序號欄 / 全螢幕)的開啟狀態以aria-pressed表達、不加視覺外框(狀態直接反映在表格本身:序號欄出現/消失、全螢幕展開)。- 齒輪動作(依序):首個動作依表格而異(互斥)——
DataTable為每頁筆數(下拉選 10/20/50…,勾選標示目前值;onPageSizeChange回調、pageSizeOptions選項),VirtualTable為重設(欄位可見性 + 拖動改變的欄序歸位,不動排序);其後共同為 → 全螢幕(CSS 展開至整個視窗,Esc 退出)→ 序號欄顯示切換 → 欄位可見性(下拉勾選)。showTableActions(預設true)為單一總開關;元件自動抑制沒意義的動作(無可隱藏欄位 → 不顯示欄位動作;DataTable無分頁 → 不顯示每頁筆數;序號欄以showRowNumber為初始值於選單即時切換)。設false整組關閉(卡片內唯讀小結表、列印表格等)。 - 載入狀態不在齒輪:
VirtualTable的無限捲動進度改以表格右側中間的垂直「載入指示條」呈現(showFloatingStatus控制,role="progressbar"帶 aria 值)——指示條依載入進度由下往上填充(視為橫向進度條順時針旋轉 90° 後的方向)、與右上角齒輪同一垂直線對齊、hover 表格才顯示(與齒輪 / 回到頂部一致;觸控裝置恆顯示),載入中時填充 pulse。採DataTablePaginationRail的視覺語言(軌道 + 旋轉 90° 小標籤)並於畫面直接標示筆數:已載入筆數跟著填充頂端上移、總筆數固定於頂端(=100% 全載入的目標位、接近全載入時淡出讓位)。筆數既於畫面直接標示,指示條不再掛 hovertitle(role="progressbar"的aria-label仍保留給螢幕報讀)。無總數時退回純文字顯示已載入數。與DataTable位置指示條外觀相同、語意不同——此處是往完成累積的進度填充(已載入 / 總數),DataTable是不累積的位置(第幾頁)。(DataTable與VirtualTable的齒輪首個動作刻意不同——見上「齒輪動作」。) VirtualTable的回到頂部維持右下角獨立按鈕(一鍵、hover 才現,尺寸與齒輪一致)。DataTable分頁器重構為右側垂直控制(PaginationRail,取代原本表格下方那排)——與VirtualTable的垂直指示條同一垂直線(right-2)、置中:中央為捲軸式「位置指示條」(滑塊高度 = 一頁佔總頁數的比例、最小 12% 保持可見;位置隨頁碼由上往下滑,第 1 頁貼頂、末頁貼底),標示「目前在第幾頁」的位置感而非進度;hover 滑塊以title顯示「目前頁 / 總頁數」。指示條左側顯示總筆數(totalItems,垂直置中)、右側顯示本頁筆數範圍(1-10,垂直位置跟著滑塊移動、隨頁碼上下滑),兩者皆順時針旋轉 90°。指示條可操作(捲軸式):點擊軌道滑塊上方 → 上一頁、下方 → 下一頁;拖曳滑塊換頁——拖曳中即時預覽(滑塊、範圍、頁碼跟著指標動),放開才提交(避免後端分頁拖曳中每經一頁就打一次 API 的請求風暴)。滑塊兩端各一顆翻頁按鈕(上=上一頁、下=下一頁,首/末頁對應鈕 disabled;鍵盤/無障礙走按鈕,指示條的點擊拖曳為滑鼠/觸控增強)。分頁器恆顯示(不需 hover),且僅在總頁數 > 1 時出現,上/下留白讓使用者一眼知道還有其他頁。與VirtualTable的「載入指示條」同屬右側垂直指示條、外觀相似但語意區分:VT 是由下往上填充的載入進度(已載入 / 總數,會累積往完成),此處是位置(你在第幾頁,不累積)。移除表格下方那排(含「1-10 of 187」資料範圍文字);每頁筆數改由右上角齒輪的「每頁筆數」動作提供(下拉選pageSizeOptions、onPageSizeChange回調,取代原下方下拉)。- per-action 的欄位可見性 / 密度 / 重置版面 / 全螢幕開關統一為上述
showTableActions(被取代的舊 prop 見下方 Deprecated 與 Removed 段落)。
- 齒輪動作(依序):首個動作依表格而異(互斥)——
- Actions
Button(4 變體、9 顏色、loading、confirmation)
- Charts(基於 nivo + d3-scale-chromatic)
LineChart/BarChart/PieChart/AreaChart/RadarChart/ScatterPlot/FunnelChart/ParallelCoordinates- 主題透過
useChartTheme()跟隨 DaisyUI 主題(座標軸 / 格線 / 刻度文字 / tooltip 自動套用--color-*,切換data-theme即時重算)
- Feedback
MswHealthGuard— 守衛 MSW 攔截健康狀態的 app-shell 元件。Prototype 的 Mock Service Worker 會被瀏覽器閒置終止或在背景分頁被換掉,導致回到分頁後請求穿透報錯、需手動重整;本元件在分頁重新可見時以navigator.serviceWorker.controller偵測攔截是否失效,失效時依recovery引導復原('prompt'預設顯示非阻斷提示條 + 一鍵重載 /'reload'自動重載,內建防迴圈)。無 MSW 依賴、以enabled對齊 MSW 旗標,生產關閉 MSW 時完全惰性;新增 exportMswHealthGuardProps、MswHealthGuardRecovery型別UnderConstruction— 「施工中 / 即將推出」整區佔位元件,供生產前端漸進式上線時,尚未啟用功能的路由渲染一致的佔位畫面(title/description自動t()翻譯,可自訂icon/action)Toast新增variantprop('solid' | 'soft' | 'outline',預設'solid'),對應 daisyUI alert style modifier;MessageToast同步新增variantprop 透傳至全域 Toast 訊息;新增 exportToastVariant型別
Form Integration (lib/form/)
- 日期時間 picker 新增公開辨識式 props 型別:
InstantDatePickerProps、LocalDatePickerProps、InstantDateTimePickerProps、LocalDateTimePickerProps,可從@appfuse/appfuse-web/form匯入。 - 11 個表單集成組件(react-hook-form + 框架 schema)
- 支援類型轉換(number)、i18n 標籤翻譯、統一錯誤處理;
date/datetime-local保留 ISO 局部字串(LocalDate / LocalDateTime 形狀),不轉Date Input(type=date/datetime-local)— 表單值為 ISO 局部字串(YYYY-MM-DD/YYYY-MM-DDTHH:mm[:ss]),對應後端LocalDate/LocalDateTime,不轉Date(無時區的日曆日/鐘面時間塞進帶時區的Date是跨時區 off-by-one 根源);顯示端寬容(既有Date值亦可格式化顯示)DatePicker(form 版)— useController 綁定的日期選擇:表單值為 ISOYYYY-MM-DD字串 | null(LocalDate 形狀,即後端LocalDate的 JSON 形狀,request 免轉換),不轉Date;讀取端寬容(既有Date值亦可顯示);新增 exportuseDatePickerhook 與UseDatePickerHints/UseDatePickerModifiers型別TimePicker(form 版)— 表單值為 LocalTime 字串 | null(HH:mm/HH:mm:ss,即後端LocalTime的 JSON 形狀);新增 exportuseTimePicker與UseTimePickerHints/UseTimePickerModifiers型別DateTimePicker(form 版)— 表單值為 LocalDateTime 字串 | null(YYYY-MM-DDTHH:mm[:ss],即後端LocalDateTime的 JSON 形狀),不轉Date;讀取端寬容Date值;新增 exportuseDateTimePicker與UseDateTimePickerHints/UseDateTimePickerModifiers型別InstantPicker(form 版)— 表單值為絕對Date| null(Instant;牆鐘 × 瀏覽器時區),送出由 http client 序列化成帶 offset ISO、對齊後端Instant;讀取端寬容 ISO 字串(轉Date顯示);新增 exportuseInstantPicker與UseInstantPickerHints/UseInstantPickerModifiers型別
Utils (lib/utils/)
ajax、i18n、time、logger、cn、cookie、environ、browser、numeral、templatecreateHttpClient新增auth配置,把401 → refresh → retry變成框架一級能力。提供後框架在「錯誤轉成ErrorResponse之前」攔截 401,自動完成 token refresh、single-flight(多個並發 401 只觸發一次refresh)、以新 token 重放原請求、_retry防遞迴,並在 request 端依getAccessToken()附上Authorization: Bearer。配置只收 callback(getAccessToken/refresh/shouldAttach/onAuthFailed),不綁定任何儲存實作(localStorage / Redux 皆可),維持框架中性;refresh 最終失敗時呼叫onAuthFailed()後仍以 401ErrorResponsereject(既有錯誤契約不變)。新增 exportAuthConfig、AuthTokens型別。解決下游後加的 response 攔截器只能收到ErrorResponse(.status)、拿不到原始AxiosError(.response.status),導致無法乾淨實作 401 refresh 的問題createHttpClient的auth新增proactiveRefresh選項(boolean | { skewSeconds },預設開啟;false關閉):在送出請求前解碼 access token 的 JWTexp,若已過期或將在skewSeconds(預設 30)內過期,先refresh再附新 token 送出——常態下省去「注定 401 的請求」往返與後端的合法 401 雜訊。與被動 401 refresh 共用同一個 single-flight(被動仍作為安全網);access token 非可解碼 JWT 時靜默跳過、退回純被動,維持框架中性
Hooks (lib/hooks/)
useTimeout、useIntervaluseDebounce<T>(value, delay)— 回傳防抖後的值;用於「搜尋框 → async 查詢」(把輸入經 debounce 再當 React Query 的queryKey,避免每個 keystroke 都打後端)
Messaging (lib/messaging/)
- 全局消息系統:
prompt-bar、prompt-dialog、promptAPI
Development Tools
- Storybook 10、Vitest 3、Playwright 1
- TypeScript 嚴格模式
- ESLint 9 + Prettier 3
Design System
- DaisyUI 5.3 主題系統(30+ 主題)
- Floating Label 統一模式
- 語義顏色系統(
text-base-content、bg-base-100等) - Addon 插槽模式(
leftAddon、rightAddon)
Tooling / CLI
- 新增
appfuse-ds-snapshotbin(cli/snapshot-design-system.mjs)—— design-system 快照工具,把模組 Storybook 的設計系統 stories 渲染成單一自包含扁平 HTML,供/design-language push鏡像進 Claude Design。從消費端 mockup 模組透過 npm bin 執行(root = process.cwd(),模組身分與主題由所在目錄與.storybook/preview.tsx推導,project-agnostic)。下游 mockup 模組改用此 bin 取代各自scripts/內的快照腳本複本(npm script:"snapshot:design-system": "npm run build-storybook && appfuse-ds-snapshot")。
RichTextEditor
- 新增雙層 toolbar 客製化 API,支援下游領域特化按鈕注入:
- Level 1:
toolbarExtras?: (editor: Editor) => ReactNode— 在內建 toolbar 末端(最後一個內建按鈕群與 spacer 之間)注入自訂內容,保留 a11y / 鍵盤面板 / 密度變體 - Level 2:
toolbar?: boolean (default: true)— 設false完全停用內建 toolbar,下游透過getEditor()自行渲染外部 toolbar - 對應公開 export:
ToolbarButton/ToolbarDivider元件(含ToolbarButtonProps型別)與RichTextEditorHandle型別 ToolbarButton採onClickAPI,內部以onMouseDown + preventDefault實作避免 editor 失焦- 範例與選擇指引見
lib/components/data-input/rich-text-editor/README.md的「自訂 Toolbar」段
- Level 1:
- 新增
extensions?: AnyExtension[]prop,允許下游專案注入自訂 Tiptap extensions(自訂 node、mark、commands、input rules、suggestion 等)- 用途:應用領域特化的編輯器擴充(如數學公式、模板變數、mention、自訂 block)
- 框架本身不內建數學公式 / 模板變數 / mention,避免不必要依賴與 API 偏向;下游各自實作或抽成獨立套件
- 讀取時機:只在 editor 初次建立時讀取,後續 prop 變動會被忽略(ProseMirror schema 在 editor 建立時鎖定);如需動態切換請給
<RichTextEditor>一個會變化的key強制 remount - 觸發 UI:appfuse-web 的內建 toolbar 不開放擴充,下游應透過 input rules / suggestion plugin(鍵盤觸發)或在外圍包一層自家 toolbar 並透過
getEditor()呼叫 commands - 範例與最佳實踐見
lib/components/data-input/rich-text-editor/README.md的「自訂 Extensions」段
Checkbox
Checkbox與CheckboxGroup.options[]新增icon?: ReactNode,可在 checkbox 方框與標籤文字之間渲染圖示(搭配lucide-react等 SVG icon),對齊Radio的相同 API;典型場景為設定面板(☑ 📧 訂閱電子報 / ☐ 🌙 夜間模式 / ☑ 🔒 加密儲存)Checkbox.hideLabel行為微調:仍提供icon時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現),對齊Radio.hideLabel- 新增
Checkbox.WithIconstory 示範 4 個帶 icon 的設定切換場景 - MUI Checkbox 沒有對應 prop(其
icon/checkedIcon是替換 checkbox 方框本身,不是 label 前的裝飾 icon),AppFuse 的iconprop 屬於框架便利擴充
Radio
Radio與RadioGroup.options[]新增icon?: ReactNode,可在 radio 圓圈與標籤文字之間渲染圖示(搭配lucide-react等 SVG icon)Radio.hideLabel行為微調:仍提供icon時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現)- 單體
Radio新增inline?: boolean:wrapper 改為inline-flex flex-col align-[-0.25em] mx-[0.25em],可嵌入<p>中文 prose 不打斷段落;同 name 的多個 inlineRadio形成 prose 內嵌的單選題(仿 commit971efd2的Checkboxinline設計) RadioGroup/CheckboxGroup新增inline?: boolean佈局模式- 預設
false:既有行為(label 在上、選項在下、direction控制選項橫直) inline=true:label 與選項排在同一列,自動套用pt-3 + min-h-[60px](comfortable,對齊Inputbordered 浮動 label 留白 + 高度)或pt-3 + min-h-[52px](compact);direction視為'horizontal'- 與
hideLabel正交:inline + hideLabel可表達「只有橫排選項、label 僅供 screen reader」(用於不需要 label 顯示但要對齊 Input 的情境,如付款方式放在 Customer Select 旁邊)
- 預設
- 新增三個 Stories:
MuiBenchmark(density / color / rowGap controls,AppFuse 與 MUI 並列校準)、InlineEdit(prose 內嵌 inlineRadio同 name 互斥 + 結構化RadioGroup)、TableEdit(表級全表單選的「主要」欄 + cell 內局部RadioGroup)
Switch
Switch新增icon?: ReactNode,可在 toggle 與標籤文字之間渲染圖示(搭配lucide-react等 SVG icon),對齊Checkbox/Radio的相同 API;典型場景為設定面板(🔔 推播通知 / 🌙 深色模式 / 💾 自動儲存 / 🛡️ 加密儲存)Switch.hideLabel行為微調:仍提供icon時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現),對齊Checkbox/Radio.hideLabelSwitch新增inline?: boolean:wrapper 改為inline-flex items-center align-baseline mx-[0.25em]的<span>,可嵌入<p>中文 prose 不打斷段落;與Radio不同的是 Switch 每個都是獨立布林(無同 name 互斥),適用使用者協議、權限同意書等「逐項勾選同意」場景。對齊細節採align-baseline(不沿用Checkbox/Radio的align-[-0.25em])—— daisyUI.toggle的 box model(track padding + handle 定位)與.checkbox/.radio不同,沿用會讓 toggle 視覺中心整體偏低 4px- 新增三個 Stories:
MuiBenchmark(density / color / rowGap controls,AppFuse 與 MUI 並列校準,含「Input + Switch 並排」對齊驗證)、InlineEdit(prose 內嵌 inlineSwitch獨立布林 + 結構化 grid 雙向綁定)、TableEdit(表格內聯Switch啟用 / 通知 / 自動同步並陳,含依賴關係連動 disabled);新增WithIconstory 示範 4 個帶 icon 的設定切換場景 - MUI Switch 沒有對應
iconprop(其icon/checkedIcon是替換 toggle thumb 本身,不是 label 前的裝飾 icon),AppFuse 的iconprop 屬於框架便利擴充
MediaInput
MediaInput.stories.tsx新增MuiBenchmarkstory,與其他 Data Input 元件對齊(先前唯一缺 MuiBenchmark 的元件):density / variant / rowGap controls + AppFuse 與 MUI 風格媒體選擇器並列對照,含Input + MediaInput並排校準、單檔(hero 主圖)與多檔(gallery thumbnail)模式、required + error / readOnly / disabled 完整狀態示範- 內含自訂
MuiMediaPicker替身元件(MUI 無原生 MediaInput):以<fieldset>+<legend>模擬 MUI TextField outlined 的 notch 邊框缺口效果,配IconButton上傳鈕、img縮圖網格,提供與 AppFuse MediaInput 視覺對等的對照組
Layout
Fieldset— HTML 語意正確的表單分組容器(lib/components/layout/fieldset/)- 內建 daisyUI
fieldset+fieldset-legendclass,並覆蓋兩個易踩雷的預設值:.fieldset預設font-size: 0.75rem→ 強制text-base,避免內容文字被壓小.fieldset-legend預設font-semibold→ 改font-normal,多 fieldset 頁面視覺更協調
legendprop 為 string 時自動t()翻譯(同 Input/Textarea label 慣例);ReactNode 原樣渲染- 變體:
bordered(預設,card-like chrome)/divider(legend 後接水平線,章節標題感)/section(下緣 groove 樣式 trailing rule,末段自動省略;groove 凹陷溝槽效果與 input/select 的實線邊框視覺區隔) - 密度:
comfortable(預設,p-4+text-base,對齊 input-lg)/compact(p-3+text-sm,對齊 input-md);命名與字級皆與Input/Select/Textarea對齊,方便整頁「fieldset 與內含表單欄位的 density 一致」設計 icon?: ReactNode— legend 前綴圖示,尺寸隨 density 自動對齊字級(comfortable=16px / compact=14px),呼叫端不需手動設定 SVG 寬高divider/section變體 + stringlegend+ 未提供icon時,自動套用Diamond凸顯章節標題(ReactNode legend 或bordered變體不套預設,避免覆蓋既有 inline icon 設計)divider變體的 legend 移除px-2,讓 legend 內容(icon、文字)與 fieldset body 內容左對齊(過去會有 8px 內縮錯位);bordered變體保留px-2維持與斷開邊框的間距divider變體 trailing line 修正:移除::after上的額外 margin,僅靠 daisyUI.fieldset-legend預設的gap: 0.5rem(8px)拉開間距。先前 margin 與 gap 疊加導致 trailing line 看起來離 legend 過遠(實際 16px,預期 8px)- 取代 applet 端的
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4 text-base">樣板
- 內建 daisyUI
Card— 通用「有標題的區塊卡」(lib/components/layout/card/):標頭(icon+title+actions)→(可選)分隔線 → 內文的三段式語意容器(<section>,非<fieldset>)- step 標頭只是傳入的
icon,故表單區塊卡與摘要/資訊卡為同一元件 divided(預設true)標頭下分隔線,滿版頂到左右內邊框(結構為「無 padding 容器 + header 區塊 + body 區塊」,header 與 body 各自 padding、可不同)surface('base-100' | 'base-200' | 'base-300',預設base-100)以表面對比造就立體感(白卡浮於灰底、非陰影);頁面內容區用bg-base-200即凸顯白卡collapsible吸收原CollapsibleCard的收合能力(defaultExpanded/summary/ toggle chevron);不開即靜態卡role="group"時自動以標題 id 綁aria-labelledby,達成等同<fieldset>/<legend>的分組無障礙、但不佔用 fieldset 語意(嚴格 fieldset 語意仍用Fieldset)- density(
comfortable=p-4+text-base/compact=p-3+text-sm)與 title 字重font-normal皆與Fieldset對齊;匯出CardProps/CardDensity/CardSurface型別 - 取代並移除
CollapsibleCard(見 Removed)
- step 標頭只是傳入的
Changed
-
MediaInput小尺寸 gallery 操作改為自適應收合:縮圖寬度低於 96px 時,renderItemActions與內建刪除會合併至單一「更多操作」popover;可排序縮圖左上新增 grip affordance, action 完成後 popover 會關閉並把焦點返回 trigger,但仍保留整張縮圖拖曳與 Alt + 方向鍵排序。 thumbnail viewport 高度低於 120px 時,MediaViewer原本分散於右上、右側中央與右下的刪除/切換檢視/全螢幕/新增/下載控制會收進單一右上 popover, 修正 77px 縮圖與單列矮 gallery 的操作溢出及互相重疊。renderItemActions內的 input、select、 textarea、contenteditable 與同類 ARIA 控制項會完整保留方向鍵、Delete/Backspace 及文字輸入, 不再誤觸 gallery 排序、刪除、上傳或開啟快捷鍵;thumbnail viewport 本身仍以 Enter 進入 single。MediaViewer.onKeyDown仍會先收到輸入控制項事件,再由 viewer 略過內建鍵盤模型;既有 props 與 action node 不需遷移。 -
Data Input 錯誤訊息改為可動態公告的欄位契約:
Input、Textarea、Select、TagInput、OtpInput、Checkbox、Radio、Switch、兩種 Group、RichTextEditor、FileInput與MediaInput皆以穩定aria-errormessage關聯一個從初次 render 起持續存在的aria-live="polite"/aria-atomic="true"區域;掛載後才出現或更新的驗證錯誤會被公告,清除時 節點仍保留供後續驗證重用。一般欄位由同一節點兼任視覺錯誤與 live region,避免重複文字;群組與 檔案內部驗證也只更新單一公告區。FileInput的 button trigger 與MediaInput的 img/listbox viewport 不使用其 role 不支援的aria-invalid/aria-errormessage,改以合法且支援度較廣的aria-describedby指向同一份可見錯誤;欄位狀態仍落在原生 file input。role="alert"不作為一般 欄位預設,保留給必須打斷目前朗讀的緊急錯誤。另統一 error/helper message wrapper 為<div>, 並恢復Input/Textareaghost error 的翻譯一致性。 -
Data Input ARIA public props 補齊:
OtpInput新增id、aria-describedby、aria-errormessage、aria-invalid;Select、TagInput、RichTextEditor、FileInput、MediaInput新增aria-errormessage。aria-errormessage依 ARIA 1.2 維持單一 ID:元件有 error 時使用自動產生 的穩定 ID,沒有 error 時保留呼叫端值;可合併多個 ID 的描述仍使用aria-describedby。 -
Tabs 的
xs/sm/md/lg字級改為 13/15/16/18px,對齊下游介面的共用字級階層; 對應高度仍維持 24/32/40/48px。預設md的字級由 DaisyUI 預設 14px 調整為 16px。 -
DataTable/VirtualTable的排序狀態改為受控 / 未控雙模式。原本sorting無條件帶入 table state 使其恆為受控——未同時提供sorting+onSortingChange時排序狀態遭凍結, 點表頭毫無反應(與先前修正過的columnVisibility同一種缺陷)。現在未提供sorting即由元件 內部持有,表頭直接可用;onSortingChange在兩種模式下都會呼叫,且一律收到解開後的具體值 而非 TanStack 的 updater 函式(未控時呼叫端手上沒有前一份狀態,無從解開)。只傳onSortingChange不傳sorting正是後端排序的典型用法:元件持有狀態、呼叫端據以重新取資料。 兩支共用新的內部 hookuseTableSorting,行為由建構保證一致。 -
DataTable/VirtualTable的表頭全選鈕改為三態,checked只代表「整個資料集都被選取」。VirtualTable在infiniteScroll.hasNextPage為 true 時、DataTable在totalPages > 1時, 全選鈕封頂在 indeterminate——該情境下全選的範圍只到已載入的列/當前頁,宣稱 checked 是不成立的 斷言。資料載完(或只有一頁)後才會達到 checked。連帶aria-label據實改為Select all loaded rows/Select all rows on this page。 -
onDeselectAll交出的函數改為清空整份rowSelection(DataTable/VirtualTable)。原本用toggleAllRowsSelected(false),它只delete當前 row model 內的 id,其他頁/已離開資料集的殘留 key 會存活下來,使「取消全選」無法真正清空。改用resetRowSelection(true)。 -
onSelectAll/onDeselectAll交出的函數改為穩定識別(DataTable/VirtualTable)。原本每次 effect 都交出新函數,配合 README 文件化的onSelectAll={(fn) => setSelectAll(() => fn)}(inline 箭頭)會形成「交出新函數 → 呼叫端 setState → 重新 render → 再交出新函數」的無窮迴圈。見 Fixed。 -
DataTable/VirtualTable/VirtualList非受控 selection 在data變動時自行收拾殘留。 受控模式不介入(消失的 id 是「被篩掉」還是「已刪除」只有應用層知道);非受控模式下組件是唯一 持有者:有getRowId則修剪(保留仍在資料集中的 key),無getRowId則因 id 即索引而清空 (VirtualTable/VirtualList的純追加、DataTable的純換頁除外——那些情境 id 仍穩定)。 元件內排序不算資料變動——clientSorting為真時排序不換data、row id 也完全穩定, 故排除在偵測之外(否則DataTable會在點表頭時清掉未控選取,與VirtualTable行為分歧)。 -
Select只在選單開啟時消費 Escape——搜尋框與已開啟的 combobox 會以preventDefault()+stopPropagation()關閉自身選單,不連帶關閉外層 Popover/Modal;選單已關閉時則不再無條件preventDefault(),因此焦點位於 Select 時按 Escape 會正常交給外層 Modal 關閉。 -
日期時間 picker 統一以必填
type宣告語意型別——DatePicker支援instant/local-date,DateTimePicker支援instant/local-date-time;Instant 模式的值為Date,可指定 IANA 時區, 預設瀏覽器時區。時區控制移至月曆標題的上一月按鈕與月份標題之間,顯示 location 短名,tooltip 顯示完整 zone ID;切換時區保留牆鐘、重算 Instant。DST 重疊時間預設拒絕,可用disambiguation="earlier" | "later"明確決定;不存在的牆鐘時間一律拒絕。DatePicker type="instant"的instantTime預設為start-of-day;改用end-of-day或自訂鐘面時間, 必須由人類指定,或由 AI 建議並經人類確認。合法字面值在編譯期精確檢查,動態非法字串則在執行期 以欄位錯誤呈現且不退回預設值。選日會依有效instantTime重建 Instant;DateTimePicker第一次 編輯後以withSeconds宣告的分鐘/秒精度重建 Instant,不保留毫秒。 -
HTTP client 的 request body 與 query params 統一遞迴編碼日期——
Date使用toISOString()產生 canonical UTCZ格式並保留毫秒;LocalDate/LocalTime/LocalDateTime 字串 原樣傳送,不附加或推測時區。無效Date會在送出前拋出RangeError。 -
Button variant="soft"維持下游可客製的靜態透明契約——19.0.0-alpha.71曾改用 DaisyUIbtn-soft加入常態淡底,會改變既有下游畫面的視覺階層。現恢復以btn-ghost為基底:平時無背景、只保留語義文字色,hover / focus 時才填色;下游可透過className依所在版面定義淡底、邊界或其他靜態識別。ghost則維持連互動填色也不內建的客製入口。 -
PdfViewer預設改為適頁寬度(fitMode='width')——先前預設scale=1(PDF 原始尺寸)。 升級後同一份 PDF 在既有畫面會以貼合容器寬度呈現、不再水平捲動;縮放(+/-/ 工具列)語意 由「絕對縮放」變成「相對 fit 基準的倍率」(範圍 0.5x–3x 不變)。要保留舊行為傳fitMode="none"。 -
MediaViewerthumbnail 模式的「選取」與「開啟」分離——先前單擊縮圖會同時選取並跳進 single 模式,導致滑鼠使用者無法在縮圖模式下移動選取(一點就離開),「當前選取」對滑鼠形同唯讀。 現在:單擊 / 方向鍵只改選取,雙擊 / Enter 才進 single 模式。renderThumbnail的 helpers 中,onSelect語意隨之改為「只改選取」,新增onOpen(選取 + 進 single 模式)。沿用舊行為的 slot 實作需把onSelect換成onOpen。- 連帶:
MediaInput工具列的刪除鈕不再限定 single 模式,thumbnail 模式同樣渲染並作用於 當前選取的縮圖(與Delete鍵同義)——先前該模式下只有 hover 才出現的每張縮圖 X, 工具列沒有任何 Tab 到得了的刪除入口。
-
MediaViewerthumbnail 模式的 viewport 語意由role="img"改為role="listbox"(single / carousel 模式不變),並帶aria-activedescendant指向當前縮圖;縮圖為role="option"、中介容器為role="presentation"。以getByRole('img')取得 thumbnail 模式 viewport 的測試需改為listbox。 -
MediaViewer的控制項在觸控裝置改為恆顯示——先前僅在 hover 時顯示,觸控裝置沒有 hover 事件、等於永遠不顯示。判準為(hover: hover)media query;有 hover 能力的裝置行為不變。 同步套用於其 PDF 單檔檢視的頁碼列。 -
createHttpClient()新增mswHealth配置:MSW 啟用時,所有 API 請求在送出前先由協調器確認攔截 健康;GET 等讀取共用 1 秒健康快取,POST / PUT / PATCH / DELETE 不沿用健康快取。自癒仍失敗時在 request 階段以503 ErrorResponse擋下,不讓讀取或寫入穿透真實後端;成功但回text/html的 SPA fallback 亦轉為相同錯誤並發布給守衛(明確要求 blob/text 等非 JSON 回應時不套用此判準)。 健康結果快取 1 秒,失效結果短暫快取 500ms,避免 retry 造成重複探測與自癒。synthetic 錯誤的status固定為 503,原始探測 HTTP status 保留於params診斷欄位,不再把穿透回應的 200 誤當成錯誤本身的 HTTP 語意。 -
FileInput的下載(「下載全部」與單檔下載)現在於欄位下方顯示進度/結果狀態,並在部分失敗時中止——單檔仍下載原檔名、多檔仍打包成 zip(downloadAllFileName),行為維度不變,變的是回饋與錯誤語意:- 內嵌狀態列(非對話框):呈現於欄位下方 helperText 位置。取得/打包中顯示細進度條(「取得 N/M → 打包中」);成功顯示「下載完成」、失敗顯示後端訊息,訊息後自動消失(錯誤停留較久)。無關閉/重試鈕——要重試直接再按 FileInput 的下載鈕。進度條延遲顯示:本地
File/FileProxy幾乎瞬間完成、不閃進度條,直接顯示完成訊息。 - 不再靜默略過失敗:先前預設路徑取檔失敗只
console.error+ 略過,使用者拿到殘缺甚至空的 zip 卻毫不知情。現在任一檔取得失敗即中止,狀態列顯示失敗檔名 + 後端 RFC 7807 訊息(經getApiErrorMessage),不產生誤導的殘缺 zip。單檔網路下載失敗同樣顯示(先前亦為靜默console.error)。 - 相容性:傳入自訂
onDownloadAll者不受影響(仍由呼叫端自理 UX);showDownloadAll/downloadAllFileName等 API 不變。下游會觀察到的是「下載時欄位下方多了一行狀態訊息」這個執行期行為。
- 內嵌狀態列(非對話框):呈現於欄位下方 helperText 位置。取得/打包中顯示細進度條(「取得 N/M → 打包中」);成功顯示「下載完成」、失敗顯示後端訊息,訊息後自動消失(錯誤停留較久)。無關閉/重試鈕——要重試直接再按 FileInput 的下載鈕。進度條延遲顯示:本地
-
DataTable/VirtualTable選中列新增左緣 3px 強調條(與VirtualListmaster-detail 的選中標示一致):原本選中列只有柔和 primary 底色(bg-primary/10),現在加上左緣強調條,選中更明顯。強調條隨colorprop 對應 8 色。實作為 inset box-shadow 套在該列第一個 cell(<tr>上的 box-shadow / border-left 在 DaisyUI table 不渲染),不推擠版面、與 sticky 欄相容。純視覺增強,無 API 變更。 -
VirtualTable/VirtualList的showScrollToTop預設由false改為true——虛擬化元件本就為長清單/無限捲動而生,回到頂部是該場景最標準的 affordance,預設關閉不符直覺。按鈕自我隱藏(僅捲離頂部超過scrollToTopThreshold(預設 400px)且 hover 時才現、觸控裝置恆現),故短清單 / 停在頂部時開著也不打擾。需關閉(如與應用層右下角 FAB 撞位)傳showScrollToTop={false}。 -
DataTable/VirtualTable修正 sticky 欄(選取 / 序號欄)的列底色接縫——選取 tint 與 hover 底色皆與其他欄一致。DaisyUItable-pin-cols給 sticky<th>不透明bg-base-100(供水平捲動不透光),會蓋住套在<tr>上的半透明 tint(bg-primary/10)與 DaisyUIrow-hover的底色,使 sticky 欄比其他欄淺、出現接縫。修正:sticky 欄的 cell 依狀態改套不透明等價色——選中列用color-mix算出的bg-primary/10overbase-100實色;未選中列 hover 時補group-hover:bg-base-300(對齊row-hover)。四種狀態(未選中預設 / hover、選中預設 / hover)各欄底色皆一致。純視覺修正,無 API 變更。 -
DataTable的分頁 UI 由右側垂直「位置指示條」改為表格下方橫向「分頁器」(整組靠右:每頁筆數下拉 + 範圍/總數 + 首/上/下/末頁),版面與字級(1rem)對齊既有 MUITablePagination慣例,便於與舊系統畫面並存。連帶:每頁筆數選擇由齒輪移入分頁器;齒輪首個動作因此改為重設(版面歸位),與VirtualTable一致。API 相容(pagination/onPageChange/onPageSizeChange/pageSizeOptions不變),僅 UI 位置與外觀改變。- 導覽為
« ‹ › »四顆圓形 ghost 鈕,無數字頁碼:中間頁需逐頁翻,但末頁一鍵可達(首/末頁鍵補足無頁碼的跳頁缺口,且導覽寬度不隨總頁數增長)。 - 分頁器恆顯示:總頁數為 1(或無資料)時整組仍在、四顆導覽鍵皆 disabled——避免分頁器隨資料量出現/消失造成表格底部跳動。
- 新增
showPagination?: boolean(預設true):設false時不畫內建分頁器,但pagination資料仍驅動序號偏移與骨架列數——讓應用層改用自己的分頁器(因pagination不只畫 UI,故「不傳 pagination」不是等價替代)。 - 分頁器的按鈕改用框架
Button元件(原為原生 daisyUI class),與框架其餘元件一致:導覽為variant="ghost" shape="circle" density="tight"、每頁筆數 trigger 為variant="ghost" density="tight"。兩者皆以className覆寫Buttonghost 變體預設的扁平 hover(icon-only 圓鈕無底色就看不出可點、圓形亦不可見)與density="tight"的text-sm(分頁器整體字級為 1rem)。 - i18n:新增
First page/Last page/Per page三個 key。既有下游若沿用自己的nls副本,需補上這三個 key(未補會退回顯示英文 key);不再使用Page ${page}(數字頁碼的 aria-label)。
- 導覽為
-
VirtualTable的預設dateFormat由yyyy-MM-dd改為yyyy-MM-dd HH:mm(與DataTable一致)。兩個表格原本各持一份default-cell-formatter,DataTable早已在一次更新中把預設改為含時分並附上推理(只有帶時區的Instant會被apiClient轉成Date而走到預設格式化,多為建立 / 修改時間戳,通常要看到幾點幾分;LocalDate/LocalDateTime保持字串、走 string 分支不受影響),而VirtualTable那份 fork 停在舊值、漏接此更新。現在兩者共用單一實作(shared/default-cell-formatter),此差異一併收斂。影響:VirtualTable中未指定cell、值為Date的欄位,預設會多顯示HH:mm。需只顯示日期的欄位覆寫defaultCellFormatter={{ dateFormat: 'yyyy-MM-dd' }}。(實務上接後端時,LocalDate欄位是字串、原樣顯示日期,不受影響。) -
DatePicker/DateTimePicker/TimePicker/InstantPicker的欄位改為可鍵盤輸入的分段遮罩(不再readOnly)- 之前:欄位是
readOnly的唯讀顯示,值只能經 popover 點選。這比它們取代的原生<input type="date">/type="datetime-local"少了一整種輸入方式——大量鍵入日期時 每一筆都得開月曆點三下,極不友善 - 現在:欄位本身是 MUI 風格的分段遮罩編輯器——
↑↓增減當前段、←→切換段、 打數字自動跳段、Backspace清當前段、Home/End跳首末段,並可貼上 (2026-07-16/2026/7/16/20260716皆可寬容解析)。分/秒的↑↓步進沿用 既有的minuteStep/secondStep - 顯示 vs 編輯兩態:未聚焦顯示
formatValue的格式化結果(預設含(週X)後綴), 聚焦時切為固定的遮罩形狀(YYYY/MM/DD、HH:mm[:ss]、YYYY/MM/DD HH:mm[:ss])。formatValue因此只影響顯示態、不需可逆 - 可選性只約束月曆、不約束鍵盤:
days/disabledDate標記為不可選的日期在月曆上 仍點不到,但可以鍵入且onChange照常送出(status一併帶出可選性)。打字途中 攔截會在使用者尚未打完時就搶著改值;可選性由表單驗證與後端validate()裁決 - Impact(行為變更,非編譯錯誤——props 與值形狀完全不變):
- 點擊欄位不再開啟 popover,改為定位編輯分段;popover 由日曆/時鐘鈕或
Alt+↓ 開啟(ARIA combobox 慣例)。
↑↓與Enter/Space不再開啟 popover——↑↓已用於增減分段 - 依賴「點欄位即開月曆」或「Enter 開月曆」的 E2E 測試需改為點日曆鈕或送
Alt+↓ - 欄位
readOnly屬性由true變false;斷言input.readOnly === true的測試需更新 (傳入readOnlyprop 仍照常停用編輯)
- 點擊欄位不再開啟 popover,改為定位編輯分段;popover 由日曆/時鐘鈕或
Alt+↓ 開啟(ARIA combobox 慣例)。
- 已知限制:遮罩以
keydown攔截數字鍵,故不支援 IME 組字輸入;Android 部分虛擬鍵盤 不送出可靠的key值——該情境仍可用 popover 選取或貼上
- 之前:欄位是
-
AuthImage認證圖片的 URL 組合改為直接透傳src給 httpClient(對齊MediaViewer做法)- 之前:載入前先經內部
normalizeUrlForHttpClient(src),以硬編的baseURL = '/api/v1/'假設剝掉/api/v1前綴,再交給 axios 用 baseURL 組回 - 問題:實際
apiClient.defaults.baseURL是各部署的 context-path(經environ.baseURL注入),並非/api/v1/;在 baseURL 非/api/v1/的部署,剝前綴會遺失/api/v1段而組出錯誤路徑。MediaViewer以同一 httpClient、同樣的/api/v1/...路徑直接透傳且運作正常,證明前綴應由 baseURL 組合、不該預剝 - 現在:
AuthImage與MediaViewer一致,直接把原始src交給httpClient.get(src, { responseType: 'blob' }),URL 前綴一律由 httpClient 的 baseURL 負責 - Impact:
src應傳與MediaViewer(FileDescriptor.url)相同形式的路徑(如/api/v1/files/...)。若既有消費端在 baseURL 確為/api/v1/的環境下依賴舊的剝前綴行為,升級後該圖片請求會少一層前綴——請改讓 baseURL 承載 context-path、src保留完整/api/v1/...路徑
- 之前:載入前先經內部
-
isExternalUrl(url, apiBase?)/needsHttpClientFetch(url, apiBase?)(@appfuse/appfuse-web/utils)新增可選apiBase參數,並改以 API 伺服器來源判定「外部」(安全邊界修正)- 背景:這兩個判斷決定圖片/檔案「附 token 走 httpClient」還是「直接
<img>/ 原生 fetch」。因為一般請求攔截器不對絕對 URL 做守衛(不像上傳 presigned 路徑),這道判斷是防止 Bearer token 洩漏給第三方的唯一防線 - 之前:
isExternalUrl以window.location.origin(頁面來源) 判定同源。僅在「前端與 API 同源」時才等價於 API 來源;跨源 API 部署(baseURL為https://api.example.com/...)下,指向 API 的絕對檔案 URL 會被誤判為「外部」→ 不附 token → 401、圖載不出來 - 現在:以
apiBase(httpClient 的 baseURL)的 origin 判定。相對路徑一律非外部(必接 baseURL 打 API 伺服器);絕對 URL 才比對 origin:等於 API origin → 附 token,不等於 → 外部、不附 token。apiBase省略時預設window.location.origin(向後相容,行為與舊版一致) - 框架內
AuthImage/MediaViewer/PdfViewer/FileInput已改傳httpClient.defaults.baseURL,跨源 API 部署下正確載入認證圖片/檔案;直接呼叫此二 util 的下游若在跨源 API 部署,應一併傳入httpClient.defaults.baseURL
- 背景:這兩個判斷決定圖片/檔案「附 token 走 httpClient」還是「直接
-
Dialog動作按鈕密度由compact改為comfortable(40px/14px → 48px/16px):對齊框架其餘元件(Button / 表單組件預設即comfortable)與對話框內文字級(text-base16px),使阻塞式對話框的主互動——動作按鈕——成為明確的決策焦點(先前按鈕字級 14px 反小於內文 16px)。不另開尺寸 prop:阻塞式對話框宜全 app 一致,極少數需異尺寸的消費端可用公開的promptservice 自建對話框訂閱者- 動作按鈕加入主次階層:
actions的順序即主次——第一項為主要(肯定選項)、其餘為次要,次要一律比主要「降一階」(solid → outline、outline → ghost),三種 dialogvariant下皆有對比。零 API 變更(actions仍為string[])。- 此慣例非新增、只是把既有語意畫出來:
MessageDialog的關閉 / ESC 早已 resolve 最後一項作為否定選項,且prompt.confirm/prompt.warn的用例一律是['刪除', '取消']這種順序——先前所有按鈕同色同樣式,使「哪個是安全選項」完全不可見(破壞性確認尤其危險)。 - 影響:既有 dialog 的外觀會變(第二顆以後的按鈕改為較弱樣式),行為與 API 不變。若某處刻意把取消放在第一項,主次會顛倒——但那本就與 ESC 慣例相衝,應調整順序。單一 action(如
['OK'])外觀不變。 - 次要為
ghost時補回 hover / focus 底色(Button的 ghost 變體預設扁平),避免對話框的次要鍵弱到不像可點。 - 新增
DIALOG_SECONDARY_BUTTON_VARIANTconfig map(未列入公開 export,與既有DIALOG_BUTTON_VARIANT同層)。
- 此慣例非新增、只是把既有語意畫出來:
- 動作按鈕間距同步由
gap-2(8px)調為gap-3(12px):按鈕升到 48px 高後,8px 未跟著調——同 severity 的多顆實心按鈕會黏成一塊,中間的縫看似按鈕內部分隔線而非兩顆獨立按鈕(「刪除 / 取消」這類破壞性確認尤其危險)。12px 低於容器 padding(16px)一階,分得開又不散。純視覺調整,無 API 變更
- 動作按鈕加入主次階層:
-
Select下拉的內建文案No options found/Create "…"/ 新增的Searching...改為經t()翻譯(先前No options found寫死未 i18n) -
DataTable預設 cell 格式化的dateFormat由'yyyy-MM-dd'改為'yyyy-MM-dd HH:mm'(defaultCellFormatter)- 動機:
Date值只有在欄位為後端Instant時才走此格式化路徑(apiClient 唯一會轉Date的日期型別;LocalDate/LocalDateTime保持字串、走 string 分支原樣顯示),而 Instant 多為建立 / 修改時間戳,預設丟掉時分不合直覺 - 下游若依賴舊的「只顯示日期」,於該欄
defaultCellFormatter={{ dateFormat: 'yyyy-MM-dd' }}覆寫
- 動機:
-
time.toLocaleString/toLocaleDateString/toLocaleTimeString新增可選第二參數options?: Intl.DateTimeFormatOptions(透傳原生toLocale*),可指定顯示時區(如{ timeZone: 'Asia/Taipei' })等——供多租戶等需固定顯示時區的 Instant 呈現。預設行為不變(無 options 時仍用執行環境時區),純增量、向後相容 -
Data Input 浮動 label 採 MUI outlined 機制,不再預留版面高度(
Input/Textarea/Select/TagInput/FileInput/MediaInput/RichTextEditor,及CheckboxGroup/RadioGroup的 inline 模式)- 之前:bordered 浮動 label 元件在 wrapper 預留
pt-3(約 12px label band),佔據版面高度 - 現在:浮起的 label 改以
absolute溢出至 input 上方、不佔版面高度(對齊 MUI outlined),label 空間靠版面 row gap 提供;CheckboxGroup/RadioGroupinline 同步移除預留的pt-3,min-h對齊輸入框高度(compact 40 / comfortable 48) - 效益:有 label 與無 label 的 input 同高,並排時可對齊
- 視覺影響(下游升級需注意):label 溢出改由表單 row gap 吸收,原本靠緊湊
space-y-2排列 bordered 欄位的表單會變擠(label 撞上一列)。下游需逐表單放寬space-y(建議 ≥space-y-5/space-y-6)、移除任何手動pt-3band 補償、並把 inline checkbox/radio/switch 的對齊外殼改用 containeritems-center - 例外:
RichTextEditor的underlined(MUI Standard)維持原行為
- 之前:bordered 浮動 label 元件在 wrapper 預留
-
RichTextEditor底層編輯引擎由 SlateJS 改為 Tiptap v3 (ProseMirror)- 功能 1:1 對等遷移:marks、heading、list、blockquote、code-block、color、highlight、sup/sub、align、link、image (含 resize 面板)、table (含 merge/split) 全數保留
- 鍵盤可達性與 i18n 行為不變:Alt+F10 進入工具欄、roving tabindex、Home/End/Esc 導航、所有 toolbar 標籤與 placeholder 透過
t()翻譯 - Variants / density / color / floating label / notch 行為不變
- 理由:Slate 0.x 維護節奏慢、API 不穩定;Tiptap 生態系(extensions、collaboration、AI)更活躍,社群與文件遠勝 Slate
-
Data Input 元件三變體架構統一對齊 MUI(
Input/Textarea/Select/TagInput)bordered對齊 MUI outlined:notch(fieldset + legend)邊框缺口、padding-x 14px、label start 對齊;fieldset notch 透過px-3/ focuspx-[11px]對齊 MUIunderlined對齊 MUI standard:containermt-4推下、label 浮在 container 頂部、focus 底線中心展開動畫ghost為 inline 編輯設計:字級行高繼承父容器([font-size:inherit]! leading-tight)、px-[0.5em]隨字級縮放、wrappermx-[0.25em] pb-1保留呼吸與底線距離compact字級改為text-[0.9625rem] pointer-coarse:text-base(桌面 15.4px / 觸控 16px,避開 iOS auto-zoom)- 配套元件:新增
Tooltip(lib/components/overlays/tooltip/)—cloneElement注入 handlers 不增加 DOM wrapper,ghost variant 的helperText/error透過 Tooltip 顯示不打斷文字流 - 配套 daisyUI:fusion theme
--radius-field由0.5rem→0.25rem(對齊 MUI Material Design)
-
Chip / Badge 樣式對齊 MUI Chip(
TagInput/Selectmultiple 使用)- 改用 daisyUI
badge-soft變體(淺色背景 + 深色文字),視覺輕量、不在輸入框內顯得擁擠 - 形狀
rounded-full膠囊形、comfortable高度 32px(對齊 MUI medium) - close button 22×22 +
bg-current/15半透明圓背景(對齊 MUI Cancel icon) - 字級 13px、
gap-x-1.5(6px)、gap-y-1(4px),與 MUI 視覺一致 wrap+bordered場景:container padding-left 縮為 2px、第一行第一個 chip 文字對齊浮動 label 視覺 column;pt-2 pb-1.5不對稱 padding 讓 wrapper 高度與 Input 對齊
- 改用 daisyUI
-
Data Input compact 字級與 chip 結構二次校準(supersedes 上方「Data Input 元件三變體架構」與「Chip / Badge 樣式」中相關細節)
- 七個元件(
Input/Textarea/Select/TagInput/FileInput/MediaInput/RichTextEditor)compactfloating label 字級統一為text-base(16px),與comfortable一致;對應 notch legend 字級統一為text-[0.78rem] - 理由:原
text-[0.9625rem](15.4px) 造成 compact 浮起 label 偏小、與 comfortable 風格不一致;pointer-coarse:text-base旁路在桌面也已 16px 後屬冗餘 - 配套:compact bordered 浮起 label transform 對齊 comfortable(
top-1 -translate-y-4),消除字級變大後的 2px 偏下視覺 - Chip(
TagInput/Select/FileInput)對齊 MUI Chip 最終規格:- 移除 daisyUI
.badge預設 1px border - 對稱
px-3padding(comfortable)/px-2(compact),唯讀 chip 文字置中(修正pl-3 pr-[5px]造成的文字偏右) gap-1.5對齊 MUI text-to-X 視覺間距- X 按鈕重構為雙層結構:外層
size-[22px](compactsize-4)透明 hit area,內層size-[18px](compactsize-[14px])可見實心圓bg-current/15;對齊 MUI deleteIcon SVG 框與可見圓直徑,同時擴大 click target 改善 accessibility -mr-[7px](compact-mr-[3px])負 margin 把 X 按鈕拉回距 chip 右邊 5px,對齊 MUI deleteIcon 位置
- 移除 daisyUI
TagInput/Selectunderlined 容器加pt-1形成對稱 padding,文字中心對齊Input元素中點(修正 2.5px 偏上;瀏覽器對單行 input 文字置中時會忽略 asymmetric padding)Select/TagInput/FileInputbordered notch 與FileInputunderlined 補shadow-none focus:shadow-none focus-within:shadow-none,蓋掉 daisyUI.input預設 inset box-shadow(bordered 模式會穿過 floating label 形成淡橫線、underlined 模式會出現在 label 下方)- 故事新增:
TagInput.MuiBenchmark加入「暱稱 / 興趣分類」對照列,驗證未浮起 label 與Input文字內容垂直對齊
- 七個元件(
-
Select ghost variant 清除互動重新設計
- 移除容器外 X clear 按鈕(視覺更輕量)
single模式 dropdown 頂部加「—」破折號選項作為清除入口(符合下拉選單「空白選項」UX 慣例)multiple模式仍可透過個別 chip X 移除單個 tag
-
Stories 升級(全 Data Input)
Variants改為 3×2 矩陣(取代原獨立Variants+Densities),一覽變體 × 密度排列組合- 新增
MuiBenchmarkstory:與 MUI 對應元件(TextField / Autocomplete)並列校準 - 新增
Select.TableEdit/TagInput.TableEditstory:展示 ghost 在表格 cell 內聯編輯 InlineEdit加入 multiple Select / Input + TagInput 並排示範
-
InputhandleClear:onClear提供時優先呼叫,不再重複觸發onChange(對齊 JSDoc 與既有測試「onClear 優先於 onChange」) -
Radio 對齊 MUI 視覺語意(仿 commit
971efd2的 Checkbox 對齊)- 色階分層仿 MUI FormLabel vs FormControlLabel 語意:
RadioGrouplabel 用text-base-content/60(欄位名稱色,同Input浮動 label);個別Radiolabel 與未選邊框用text-base-content(欄位內容色,同Input文字);error才轉紅 - 個別
Radiolabel 不再隨colorprop 變色(之前color="primary"會讓 label 跟著變text-primary),對齊 MUIFormControlLabel.label不隨 Radio color 變色行為 gap-3→gap-1.5(radio↔label 間距,對齊 MUI)RadioGroup群組錯誤訊息與輔助文字統一用mt-2(不再依 density 調整 helperMargin),與CheckboxGroup一致
- 色階分層仿 MUI FormLabel vs FormControlLabel 語意:
-
Switch 對齊 MUI 視覺語意(仿 commit
971efd2/402595b的 Checkbox / Radio 對齊)-
個別
Switchlabel 不再隨colorprop 變色(之前color="primary"會讓 label 跟著變text-primary),永遠使用text-base-content,僅error轉text-error;對齊 MUIFormControlLabel.label不隨 Switch color 變色行為 -
gap-3→gap-1.5(toggle↔label 間距,對齊 Checkbox/Radio 與 MUI) -
helperMargin對應gap-1.5重算:compactml-[calc(26px+6px)]、comfortableml-[calc(33px+6px)] -
之前:
compact與comfortable都使用text-base(16px) -
現在:
compact使用text-sm(14px),comfortable使用text-base(16px) -
理由:與所有 Data Input 組件保持一致的密度語義
-
視覺影響:字體稍微縮小,可透過調整根字體大小全局放大
-
詳見:
lib/components/data-display/data-table/README.md的 Density 章節
-
-
Chip 預設改為輪流換色(
FileInput/Select/TagInput)- 當
color未指定(default)時,chip 依索引輪流套用一組中性語意色(primary/secondary/accent/info/success),提升多 chip 的可辨識度 - 指定
color(如primary)或欄位處於error狀態時,所有 chip 仍統一單色,維持語意一致 - 視覺變更:升級後預設的多選 chip 不再全為灰色 neutral,而呈現輪流色彩;如需維持單色,明確指定
color - 共用邏輯抽到
lib/components/data-input/chip-color.ts(resolveChipColor、CHIP_ROTATION_COLORS)
- 當
-
FileInputchip Popover 內容擴充 + 氣泡外觀- Popover 上半新增檔案資訊區(檔名、類型、大小);外框與氣泡尾巴呼應該 chip 顏色
- 新增 i18n key:
Type、Size(下游專案需在nls/term補上翻譯)
-
Dropdown.Content新增arrowprop(預設false,不影響既有 dropdown)- 開啟後外框呈現對話氣泡外觀,氣泡尾巴指向 trigger(
position決定朝上/朝下、align決定水平位置) - 尾巴顏色透過
border-inherit跟隨外框邊框色
- 開啟後外框呈現對話氣泡外觀,氣泡尾巴指向 trigger(
-
FileInput新增上傳標誌鈕(bordered/underlinedvariant)- 右側顯示 Upload 圖示鈕作為「這是檔案欄位」的視覺標誌(類似
Select的 chevron),解決無值時看不出是 FileInput 的問題 - 永遠顯示,
readOnly/disabled時隱藏;ghost(inline edit)不顯示以維持融入文字流 - 與「下載全部」鈕並排於右側按鈕群組,chip 容器依鈕數動態預留右側空間
- 視覺變更:升級後 bordered / underlined 的 FileInput 右側會多一個上傳圖示鈕
- 新增 i18n key:
Upload files(下游專案需在nls/term補上翻譯)
- 右側顯示 Upload 圖示鈕作為「這是檔案欄位」的視覺標誌(類似
-
Dialog動作按鈕的主次階層改以「填充 + 色相」雙軸降階,並改為次要在左、主要在右- 次要鍵改中性色:原本主次只降填充(
solid→outline)、次要沿用 severity 色, 於是error/warning對話框會出現「紅底刪除 + 紅框紅字取消」兩個同色鈕互相競爭。 取消是安全的逃生口,語意上不該漆成危險色。現在次要一律color="neutral"+variant="outline", severity 色只保留給主要動作 - 渲染順序反轉:由「主要在左」改為
[次要…] [主要](主要鍵置於最右)。 宣告面不變——actions仍是「主要寫在前」(actions[0]為主要、MessageDialog的 關閉 / ESC 仍 resolve 最後一項為否定選項),只有畫面左右順序改變,故無需改 call site。 此舉使Dialog與應用層既有的自訂對話框(一律次要在左)一致 density由comfortable(48px)改為compact(40px)severity="info"改用 info 色(標題列bg-base-200→bg-info、按鈕neutral→info)。 此前五個 severity 中唯獨 Info 不使用自己的顏色而走中性灰,且與兩個孿生元件相衝——Toast與Alert皆將 Info 對應到alert-info。該特例無註解說明、亦無設計記錄, 判定為遺留而非設計。現五個 severity 一律「標題列色 = 按鈕色 = severity 色」- 視覺變更:升級後所有經
Dialog/MessageDialog/prompt.confirm/prompt.error顯示的對話框,其動作鍵的顏色、左右順序與高度皆會改變。無 API 變更
- 次要鍵改中性色:原本主次只降填充(
-
Dialog/Toast/Alert/Modal的字級與字重統一為text-base+font-normal- 此前四者各行其是:
Dialog標題text-lg font-normal、Modal標題text-lg font-semibold、Toast/Alert標題font-medium;且Alert在有標題時把內文降為text-sm opacity-90,Toast卻不降——同一組訊息面元件出現四種排版處理 - 層級改由顏色與版面承擔(severity 標題列、邊框、位置、圖示),不由字級字重承擔。 設計語言因此更單純,下游 design-system 也更容易對齊
Alert/Toast的容器另加顯式text-base——daisyUI 的.alert自帶font-size: .875rem, 未顯式覆寫時標題與內文會繼承成 14px 而非 16px- 動作鍵的字級隨
Button的density="compact"一併改為 16px(見下條) - 視覺變更:升級後對話框與訊息面的標題不再放大或加粗;
Alert帶標題時內文不再縮小。無 API 變更
- 此前四者各行其是:
-
DataTable/VirtualTable的density="compact"字級由text-sm(14px)改為 15px (text-[0.9375rem]/[1.375rem],行高 22px);表頭改為元件自帶樣式,不再依賴應用層 CSS 覆寫- 表格的 density 刻意包含字級,與
Input/Button(density 只管高度)不同——這是有意識的 差異:表格 compact 的目的是一屏塞更多列,字級是達成手段;按鈕 compact 只是佔位小,縮字 無功能意義。原本table.config.ts寫「與所有 Data Input 組件統一」,但Input各密度一律text-base、從未使用text-sm,該敘述與事實不符,已更正 - 取 15px 而非沿用 14px:介於
text-sm(14/20) 與text-base(16/24) 之間,既保留密集感, 又不與其餘元件(一律 16px)落差過大 thead新增text-base-content:daisyUI 對:where(thead,tfoot)直接套 60% 淡化色, 元件過去靠應用層 CSS 覆寫補回,現由元件自己負責- 8 處重複的密度字級三元式收斂為單一常數
TABLE_DENSITY_FONT_CLASSES(table.config.ts) - 表頭底色由
bg-base-100改為bg-base-300:字級 / 字重 / 文字色與表身一致後,表頭的 識別度全靠表面色,而原本的bg-base-100與表身同色、等於沒有區隔。不可用bg-base-200——table-zebra的斑馬列正是該色(實測色值完全相同),表頭會被誤讀成另一條斑馬紋 - ⚠️ 下游若在
tailwind.css以@layer daisyui覆寫.table的字級/字重/顏色,請移除—— 該覆寫會蓋掉元件的 density 字級,使compact與comfortable渲染完全相同(此坑在框架自身 與參考實作中都曾實際發生,本版一併清除)。移除後表頭顏色由元件補上,不會變淡
- 表格的 density 刻意包含字級,與
-
Button的density不再改變字級——tight/compact/comfortable一律text-base(16px), density 只管高度(32 / 40 / 48px),對齊InputInput各密度一律text-base(config 內 10 處無一例外),僅高度不同;Button原本tight/compact為 14px,同高度並排時字級卻與Input不一致button.config.ts原本的註解// 14px - matches Input compact font size與事實不符 (Input從未使用text-sm),一併更正- 框架內部原本就有兩處在對抗此設定:
DataTable分頁器與參考實作的product-detail皆以className="text-base"覆寫tight的text-sm;本次一併移除該覆寫,行為不變 - 影響:所有
density="tight"/"compact"的Button(含Dialog/MessageDialog的動作鍵、 各元件的關閉鈕、表格內操作鈕)字級變大 2px;按鈕高度不變(btn-sm/btn-md固定), 實測文字未溢出(tight內容高 30px / 可用 30px)
-
Dialog/Modal的 Storybook Docs 頁改為 iframe 渲染 story(docs.story.inline: false)- 兩者為 portal modal(
position: fixed),Docs 頁原本把所有 story inline 渲染在同一個文件流裡, 導致每個open的對話框蓋滿整頁、背景幕層層相疊,Docs 完全無法閱讀 - 改為 iframe 後
fixed定位被限縮在各自的 iframe 內;DesignLanguage另行加高 - 僅影響 Storybook 文件呈現,不影響元件行為
- 兩者為 portal modal(
Deprecated
usePromptSubscription()— 改用usePromptQueue()。前者只回傳訊息陣列、沒有移除能力, 不足以實作任何真正的容器(框架自己的MessageDialog/MessageToast因此曾各自 inline 一份訂閱邏輯,同一段邏輯在框架裡有三份)。現為usePromptQueue的薄包裝,行為不變。
Removed
-
Modal/ModalProps/ModalSize(@appfuse/appfuse-web/components)— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.87- Replacement:
Dialog/DialogProps/DialogSize - Migration: 自
@appfuse/appfuse-web/components匯入的Modal→Dialog;<Modal … >→<Dialog … >;型別同名替換 - Note: 同檔若另有
import { Dialog } from '@headlessui/react'(手刻外殼的遺留)會撞名—— 該類檔案本就應改用框架元件,或將 Headless UI 的匯入別名為HeadlessDialog。 命名沿革見 ADR-010
- Replacement:
-
Dialog/DialogProps/DialogVariant(舊語意,prompt 佇列的呈現面)— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.87- Replacement:
PromptDialog/PromptDialogProps/PromptDialogVariant - Migration:
<Dialog open title content severity actions … >→<PromptDialog … >(props 不變) - Note:
Dialog這個名字未消失,但語意已換——現在指向原本的Modal(通用可組合外殼)。 兩者 props 幾乎不重疊(舊者必要content/severity,新者必要children), 故未遷移的呼叫端會編譯失敗而非靜默渲染成別的元件。應用面通常不直接使用本元件 (全域訊息走prompt.*()+MessageDialog),fleet 盤點顯示零直接匯入
- Replacement:
-
manualPagination(DataTableProps)— deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.83- Replacement: 無。
DataTable明確只支援後端分頁,元件內部固定manualPagination: true - Migration: 傳
manualPagination={true}(或未傳,即預設)→ 直接刪掉該 prop,行為不變; 傳manualPagination={false}→ 該值原本就無作用(元件未註冊getPaginationRowModel), 刪除後行為同樣不變。大量資料請由後端分頁後只傳當前頁 - Note: 新增的
getRowIdfallback 是「頁面偏移序號」,前端分頁下data為全量、索引已是全域 序號,再加一次 offset 會讓 row id 錯亂——此 prop 與新的 row id 邏輯無法並存
- Replacement: 無。
-
striped(DataTableProps/VirtualTableProps)— deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.77- Replacement:
rowSeparator(見 Added) - Migration:
striped(或不給,預設 true)→rowSeparator="zebra"(亦為新預設,可直接刪掉該 prop);striped={false}→rowSeparator="none" - Note:
striped={false}對到'none'而非'divider'——原本striped={false}下留著的是 DaisyUIbase-content/5的原生淡線,肉眼近乎無感,語意上更接近「無列區分」
- Replacement:
-
InstantPicker(components/form)— deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.73- Replacement:
DateTimePicker type="instant";只有日期的 Instant 改用DatePicker type="instant" - Migration: 將
InstantPicker改為DateTimePicker type="instant",其Date值與時區 props 可直接沿用; 原 picker 內的時區換算工具改由@appfuse/appfuse-web/utils匯入。
- Replacement:
-
AuthConfig.shouldAttach— deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.66- Replacement: request-level
HttpRequestConfig.authMode - Migration: 移除 client-level URL matcher,改在 login、refresh、Email OTP challenge/resend/verify 等
公開認證呼叫傳入
{ authMode: 'none' };其餘請求沿用預設的session。
- Replacement: request-level
-
MswHealthGuard.enabled/probe/selfHeal— deprecated since N/A(pre-release,未經 deprecation 期), removed in 19.0.0-alpha.64- Replacement: 必填
health: MswHealthCoordinator - Migration: 以
createMswHealthCoordinator({ enabled })建立模組層單例,同時傳給createHttpClient({ mswHealth: health })與<MswHealthGuard health={health} />。 - Impact: 使用舊 props 的應用會出現 TypeScript 編譯錯誤;這是刻意的 pre-release 契約收斂。
- Replacement: 必填
-
VirtualTable/DataTable的 per-action 檢視控制開關 — 由showTableActions統一取代- 涵蓋:
showColumnVisibilityMenu、showDensityToggle、showResetLayout、showFullscreen、onDensityChange、onResetLayout(密度切換與重置版面動作一併移除) - deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.45
- Replacement:
showTableActions(預設true,收合成單一齒輪;元件會自動抑制沒意義的動作——如無可隱藏欄位時不顯示欄位動作) - Migration: 原
showColumnVisibilityMenu={true}直接刪除即等價(新預設已涵蓋);需整組關閉檢視控制時改傳showTableActions={false}。showDensityToggle/showResetLayout/onDensityChange/onResetLayout對應的密度切換與重置版面動作已移除,無替代
- 涵蓋:
-
CollapsibleCard— 由Card取代(collapsible涵蓋其全部能力,且 Card 另補三段式divided標頭與surface表面對比)- deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.40
- Replacement:
Card(@appfuse/appfuse-web/components) - Migration:
<CollapsibleCard …>→<Card collapsible …>(title/summary/defaultExpanded同名透傳);importCollapsibleCard→Card。不需收合的靜態卡可省略collapsible;divided預設true(如需舊的無分隔外觀傳divided={false})
-
RichTextEditor移除 Slate-only 工具函式- 影響範圍:直接引用
lib/components/data-input/rich-text-editor/rich-text-editor.utils.ts的parse/stringify/clear/reset的下游程式碼 - deprecated since N/A, removed in 19.0.0-alpha.18
- Replacement: 不再需要 —
value已是 HTML 字串,無需轉換 - Migration: 移除對應 import;reset/clear 改為
editorRef.current?.getEditor()?.commands.setContent('<p></p>')
- 影響範圍:直接引用
-
Badge的variant='dash'- 影響範圍:傳
variant="dash"給<Badge>的下游程式碼 - 移除理由:與
Toast的dash一併清除,使 daisyUI 的dash修飾子不再出現在框架的 任何 variant 詞彙中。注意此處理由與 Toast 不同——Badge 非訊息面,「虛線=佔位/未完成」 對狀態標籤未必衝突(草稿標籤即為合理用途);移除的實際依據是參考實作零使用、 且統一 variant 詞彙。若日後確有「暫定狀態」的視覺需求,應以語意化的方式重新引入, 而非復原dash - deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.52
- Replacement:
outline(同為框線外觀,實線) - Migration:
variant="dash"→variant="outline"
- 影響範圍:傳
-
ToastVariant的'dash'(Toast/MessageToast的variant='dash')- 影響範圍:傳
variant="dash"給<Toast>/<MessageToast>的下游程式碼 - 移除理由:虛線框語意偏「佔位 / 拖放區 / 尚未完成」,與訊息面「這是一則真實訊息」相衝。
此推理框架自身早已寫下——
Dialog當初新增 variant 時即明文排除dash——只是未一併 套用到Toast。現與AlertVariant統一為'solid' | 'soft' | 'outline'三值 - deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.52
- Replacement:
outline(同為框線外觀,實線) - Migration:
variant="dash"→variant="outline";型別ToastVariant不再接受'dash'
- 影響範圍:傳
-
DialogVariant的'outline'(Dialog/MessageDialog的variant='outline')- 影響範圍:傳
variant="outline"給<Dialog>/<MessageDialog>的下游程式碼 - 移除理由:原 variant 軸源自與
Toast的 API 對稱、非Dialog自身需求,三值中soft與outline皆零消費且兩者視覺幾乎難分。soft後因「扁平動作鍵過於強烈」這個具體需求 重新引入(見 Added),outline則無對應需求,不再提供 - deprecated since N/A(pre-release,未經 deprecation 期),removed in 19.0.0-alpha.52
- Replacement:
soft(柔色標題列)或solid(預設) - Migration:
variant="outline"→variant="soft"(最接近的柔和外觀)或刪除該 prop 回到solid。DialogVariant型別仍存在,僅不再接受'outline'
- 影響範圍:傳
Fixed
-
MediaViewer控制項原本位於role="listbox"/role="img"viewport 內:工具列、操作環、 導航及下載控制現在改為 viewport 的兄弟節點,避免互動按鈕被 listbox/img 擁有。這是 DOM 結構 變更:使用[role="listbox"] > button、[role="listbox"] button等選取器,或假設 viewport 是控制項 定位父層的自訂 CSS,需改以[data-media-viewer-frame]共同定位 wrapper 為準。鍵盤事件同步掛在 該 wrapper,焦點位於控制項時仍保留元件快捷鍵。 -
DataTable/VirtualTable的 checkbox 選取欄可能被壓窄:選取欄現在保留至少 44px 寬度, 對齊完整觸控命中區;多欄位在窄容器中改由水平捲動承擔空間,不再讓 auto table layout 把 選取欄壓回 checkbox 的視覺寬度。 -
Input/Textarea的 helper 與 error 未連結至 control:兩個元件現在會由 control ID 產生穩定的 helper/error ID,分別透過aria-describedby/aria-errormessage自動建立關聯, 並合併呼叫端既有的 ARIA ID references。error 仍同步設定既有aria-invalid;ghost variant 另外保留螢幕閱讀器可參照的隱藏訊息,不要求 consumer 建立 wrapper 或自行接線。 -
Button loading把 DaisyUI spinner mask 套到整顆按鈕:根元素不再輸出.loading,該 class 只保留於內部.loading-spinner。loading 時仍使用原生disabled,並新增aria-busy="true"; spinner 標為裝飾性內容,文字繼續保留於畫面與 accessible name。compact loading Button 因此維持 40px 高度與正常文字寬度,不再縮成約 34×40px 的 icon-only 外觀。loading 現在也會保留原 variant 與語意色的背景、邊框及文字,不再套用一般 disabled 的灰化視覺;原生 disabled 行為仍維持不變。 -
VirtualList的rowPosition拖曳實際上無法重排:項目把虛擬化位移(translateY(start)) 與 dnd-kit 的拖曳位移放在同一個transform上,而 dnd-kit 量測 droppable 走getTransformAgnosticClientRect——它依 computed style 把節點自己的整份 transform 反推掉, 虛擬化位移一併被消掉,於是每個項目都被量成「容器頂端」那一個矩形。碰撞偵測分不出彼此,over永遠停在自己身上:項目跟著手指動,放開後卻不重排、onReposition一次都不會發生 (DataTable/VirtualTable不受影響——它們的列沒有虛擬化 transform)。啟用拖曳的項目 改以top定位(不是 transform,反推不到),並停止套用useRowPositionRow為表格列準備的position: relative(會把絕對定位的項目拉回文件流、瞬移到清單頂端)。未啟用人工位置的 項目維持 transform 定位,合成器效能不變。 -
拖曳途中把
rowPosition.enabled翻成false會殘留對外的拖曳狀態:DndContext連同 sensor 一起卸載時onDragCancel不會發生,useRowPositionDragState()因此繼續回報一場 已結束的拖曳,重新啟用時還會短暫復活。現在停用即對外歸零,內部 state 一併清掉。 -
拖曳把手在觸控上是捲動死區:把手的
touch-none禁止瀏覽器從把手起始捲動,而長按門檻 又擋下未達 200ms 的拖曳——手指落在把手上快速滑動於是既不拖也不捲,還順帶關掉該元素上的 雙指縮放。改為不設限touch-action:快速滑動照常捲動,停住 200ms 才進入拖曳(瀏覽器開始 捲動的位移門檻大於長按的 5px 容許值,故長按成立時瀏覽器尚未接手,dnd-kit 的preventDefault仍有效)。 -
lift的插入指示線漏掉所有 sticky 欄:指示線只寫了[&>td],而 daisyUItable-pin-cols的設計是 sticky 欄用<th>、其餘用<td>——於是把手欄、選取欄、序號欄 與stickyFirstColumn全部沒有線,看起來像從第二欄才開始、沒有對齊。td與th現在都畫。 -
框架注入的三個內部欄都比宣告寬度寬一倍以上:daisyUI 給每個儲存格
padding-inline: 1rem(左右共 32px),把這些窄欄的 min-content 墊高,宣告值從來沒有靠近過 ——把手欄40px實測 92px、選取欄3ch實測 66px、序號欄4ch實測 73px,合計白白擠掉 資料欄逾 100px。三者的內容(把手、checkbox、序號)都置中且不靠內距承擔可讀性,鄰欄的 16px 內距已提供欄間間隔,故表頭與儲存格一律不留左右內距:92→59px / 66→43px / 73→57px。 仍非宣告值:table-layout: auto下儲存格的width/max-width只是提示,剩餘寬度仍會 分配到各欄——fixedWidth與STICKY_COLUMN_WIDTHS是下限指引而非保證。stickyFirstColumn不在此列:那是使用者的資料欄,內距在那裡確實承擔可讀性。 -
MediaInput的縮圖排序在觸控上沒有長按門檻:同時註冊了PointerSensor(距離 8px)與TouchSensor(長按 200ms),但PointerSensor監聽pointerdown、觸控也走這條,於是距離門檻 永遠先成立、TouchSensor形同不存在——註解寫的「觸控長按 200ms」從未生效。改為MouseSensorTouchSensor,滑鼠門檻不變。
-
Dialog的訊息本文對輔助技術是「無主文字」:content先前以裸<p>渲染,未掛aria-describedby——螢幕閱讀器唸完標題後不會接著唸訊息本文,而「訊息本身」正是這支元件 存在的理由。現改以 Headless UI 的Description渲染(預設仍為<p>,樣式與 DOM 結構不變), 由 Headless UI 自動關聯。 -
MessageToast把譯文而非原始動作字串 resolve 回呼叫端:onAction直接把畫面上的 譯文交給prompt.handleAction(),未如MessageDialog反查原始鍵。若將warning/confirmation導向 Toast,await prompt.warn()會 resolve 出呼叫端認不得的字串。 兩個容器現共用resolvePromptAction()。 -
MessageToast對已翻譯內容二次查詢:content先前無條件i18n.t(),後端回傳的 完整句子沒有對應翻譯鍵。現與MessageDialog一致,經translatePromptMessage()只在 鍵存在時才翻譯。 -
Dialog與FileInput預覽對話框的關閉鈕aria-label被翻譯兩次:Button已擁有aria-label的翻譯責任 (見button.tsx),Dialog卻再傳t('Close')進去,形成t(t('Close'))。多數語系下 第二次查詢因查無鍵而回傳原字串、僥倖看不出問題,但只要譯文本身撞上另一個翻譯鍵就會譯錯。 兩處現與Modal一致,只傳 raw key。 -
Dropdown.Content的點擊會外洩到<Dropdown>的祖先,最明顯的症狀是FileInput的 chip 選單:選單雖經 Portal 渲染到body,但 React 合成事件仍沿 React tree 冒泡——chip 選單位於 上傳容器(role="button"+ 開啟選檔對話框的onClick)之內,於是點選單任一處都會連帶跳出瀏覽器 選檔對話框,連預覽、下載鈕也不例外(動作照常執行,同時多開一個選檔視窗)。Dropdown.Content現在攔下自身的點擊冒泡(僅click;mousedown/pointerdown等仍照常冒泡,由各自情境 處理)。選單項自己的onClick先於此執行(bubble 階段)故不受影響;關閉選單的 document 監聽 聽的是mousedown,與click不同型別故不受影響。下游需留意:合成事件的stopPropagation()會一併呼叫nativeEvent.stopPropagation(),而 React 把 portal 的監聽掛在body,因此 document 層級的原生click監聽收不到選單內的點擊(分析埋點、第三方 outside-click 套件可能受影響;框架內部無此類監聽)。同類情境(可點擊的卡片/表格列內放Dropdown)一併修正。 -
DatePicker/DateTimePicker/TimePicker的受控值被清成null時畫面仍留著舊日期: 分段遮罩引擎(useSegmentedMask)為了保護半途輸入,一律不讓空的外部值覆蓋內部 draft—— 「打了年、月日未填 → 值為 null → 回聲把 draft 洗掉」是必須擋的,但這道保護連外部清空 也一併擋下,於是表單reset()、連動欄位互清或非同步回填null之後,欄位仍顯示先前的日期 (值是null、畫面卻看似有值);聚焦中被清空時更會永久殘留。修正改以「這個空值是誰造成的」 分辨:外部確有變動且非本次apply()的回聲才追平(畫面同步清空,聚焦中亦然),自家onChange送出的空值一律忽略。半途輸入的保護不變——鍵入未完成的分段、清除單一分段、以及 blur 後殘留的片段皆不受影響。注意「外部清空」以值確實從有值變成空為準:對本來就是null的值再清一次沒有變動可偵測,畫面上的半途輸入會留著(值與畫面皆代表沒有值,不矛盾),要一併 清掉請走清除鈕的reset()。 -
表單欄位的
error未同步aria-invalid,視覺狀態與可及性狀態不一致:欄位依error套用input-error視覺樣式並顯示錯誤訊息,卻沒有標記aria-invalid,螢幕閱讀器讀不到無效 狀態,每個下游應用都得在呼叫端重複補救。現在所有具errorprop 的欄位一致遵循:呼叫端明確 指定aria-invalid時以呼叫端為準;未指定時只要error存在即標記aria-invalid="true"; 無錯誤時維持未設定(不輸出aria-invalid="false")。涵蓋Input、Textarea、Checkbox、Radio、Switch、Select、TagInput、FileInput、MediaInput、RichTextEditor,以及 委派Input的DatePicker/DateTimePicker/TimePicker與lib/form/各表單版元件 (FileInput/MediaInput的檔案驗證錯誤亦納入判斷)。CheckboxGroup/RadioGroup的 群組層級error會落到每個選項控制項上(群組訊息仍只渲染一則,選項不轉紅)。OtpInput原已正確,行為不變。 -
Checkbox的indeterminate在點擊後失效、錯誤呈現為未勾選:依 HTML 規範,使用者點擊 checkbox 會把indeterminate原生重設為false;React 會還原受控的checked,但indeterminate不是它管理的屬性。同步 effect 原本只在 prop 值變動時執行([indeterminate]),於是「點擊前後 prop 皆為 true」的情境會卡在原生重設後的false。修正為每次 render 都同步,並在onChange當下 立即還原(外部狀態未因這次點擊改變時不會 re-render,光靠 effect 補不到)。副作用:元件因此恆有onChange,React 的「傳了 checked 卻沒有 onChange」開發期警告不再出現——本元件是受控包裝, 唯讀請用readOnly。 -
DataTable/VirtualTable的序號欄在元件內排序後顯示原始資料位置:序號取自row.index,那是 core row model 的索引——getSortedRowModel只把它當穩定排序的 tie-breaker、不重寫。後端排序時data進來就是排好的,兩者恰好相等,這個巧合掩蓋了問題; 改為元件內排序後使用者會看到 2、3、1。改取顯示位置(以row.id在當前 row model 中查找—— 排序後的 row 是淺拷貝,物件識別比對必然失敗)。 -
onSelectAll/onDeselectAll依 README 的寫法使用會無窮 re-render(DataTable/VirtualTable):元件每次 effect 都交出新函數,而 README 文件化的onSelectAll={(fn) => setSelectAll(() => fn)}是 inline 箭頭、每次 render 都是新識別,兩者形成 「交出新函數 → 呼叫端 setState → 重新 render → effect 依賴變動 → 再交出新函數」的迴圈(實測會 讓測試 worker 卡死)。修正為以 ref 讀取最新 table 實例、useCallback零依賴,交出的函數識別穩定。 -
表頭全選鈕的 indeterminate 判斷被殘留 selection key 汙染(
DataTable/VirtualTable): TanStack 的getIsSomeRowsSelected()以Object.keys(rowSelection).length為分子、當前列數為分母, 會把「對應資料已離開data」的殘留 key 一併計入。篩選後選 5 筆、剩 3 列可見其中 1 列選取時,5 < 3為 false 使 indeterminate 關閉、getIsAllRowsSelected()也為 false,checkbox 於是呈現全空, 儘管畫面上明明有一列打勾。改為只依當前 row model 統計。 -
表頭全選鈕在封頂 indeterminate 時按不掉(
DataTable/VirtualTable):getToggleAllRowsSelectedHandler()依e.target.checked決定方向,而checked在多頁/尚有未載入 資料時恆為false,每次點擊都變成「全選」。改為依「當前列是否已全選」決定方向。 -
Select.options拒絕具名interface選項——公開Option的物件分支原為Record<string, unknown>,要求字串 index signature,導致一般的領域 interface 無法指派並出現 TS2322。改為接受任意object,元件內部僅在解析動態 accessor 時局部轉為 record;components 與 form 入口同步生效,既有原始值與物件字面值用法不變。 -
Dropdown.Trigger覆寫 child 的onClick、onKeyDown與 ref——改為先執行框架的開關/鍵盤 邏輯,再呼叫 child handler,並同步寫入框架與 child ref。觸發鈕現在可直接以event.stopPropagation()避免冒泡到 DataTable row click,不需額外 DOM wrapper;React 19 callback ref 回傳的 cleanup 也會保留。FileInputchip 已改把 stopPropagation 直接放在 trigger,移除示範舊 workaround 的外層 wrapper。 -
DataTable的分頁器與表格內容之間缺少穩定分隔——PaginationBar補上border-t border-base-content/10- 原本三種
rowSeparator都受影響:divider與none完全沒有分隔;zebra看似有分隔 其實是偶然——最後一列為偶數列才有底色,奇數筆資料就沒有 - 線加在分頁器而非表格最後一列:它分隔的是「表格區 / 分頁器區」兩個 UI 區塊,屬於
分頁器的上緣。這樣三種
rowSeparator都受益,且沒有分頁器時表格最後一列維持無 border (下方沒有相鄰區塊要分隔,那會是懸空的收邊);兩者若並存還會出現相距py-2的平行線 - 取
/10:比 DaisyUI 原生列線(base-content/5)明顯以撐起區塊界線,又比divider的 列線(/20)輕,避免在zebra/none模式下突兀 VirtualTable無內建分頁器,不受影響
- 原本三種
-
Button的outline與soft在鍵盤焦點時填色比滑鼠 hover 淺——補上與 hover 等價的focus-visible變深規則,兩者現在完全一致- 根因:DaisyUI 把
.btn的變深規則只掛在@media (hover:hover){&:hover{…}},:focus-visible沒有對應規則;而.btn-outline與.btn-ghost(soft的底)都以:not(…, :focus-visible, …)排除選擇器實作「常態透明」,focus 時該條停止套用、--btn-bg回落到--btn-color原色而非 hover 的變深色 outline:所有color皆受影響(實測 primary:focusL=0.5/ hoverL=0.465)soft:只有color="default"受影響(實測:focusL=0.97/ hoverL=0.90)——有色時softTextColorClasses自帶的focus-visible:bg-{color}直接設background-color、勝過--btn-bg變數,早已把 focus 拉齊 hover,但default那格是空字串、無人補救- 本次把該式子抽成共用常數,由
solid(原已手寫同式)、outline、soft共用,避免三者 各自漂移;ghost刻意無填色互動故不補,link走 underline - 影響:受影響組合的焦點填色略為變深,與各自的 hover 一致;有色的
soft行為不變(新常數 對它是 no-op)。color="default"因--btn-color未定義而 fallback 到--color-base-200, 焦點填色雖已對齊 hover 但仍偏淺,該色的焦點可見度另案處理
- 根因:DaisyUI 把
-
DatePicker 的無效
instantTime直到點選日期才從事件 handler throw——移除 handler throw;未提供值時採文件化的start-of-day預設,但已提供的無效值會在欄位初次 render 顯示 inline error,不會退回預設、後續點選也不送出部分值。字面型別不再使用會接受1.5/負數/ 科學記號的`${number}`片段。 -
DatePicker/DateTimePicker 收到非法 IANA zone 時於 render 期直接 throw——先驗證 zone,錯誤改走 Input inline error;切換到非法 zone 或 DST gap 時保持時區與 Instant 原子性,不做部分更新。
-
DayStatus.date查找與選取的正規化策略不對稱——現在只接受真實存在且精確符合YYYY-MM-DD的值;完整 timestamp 或非法日期顯示欄位合約錯誤,不再靜默查不到或截斷。 -
form
DatePicker/DateTimePicker把型別不符的欄位值靜默吞成null——name現依 picker 模式 限制到相符值型別;執行期錯誤型別或Invalid Date會在欄位顯示 inline contract error,不會跨型別 轉換、改寫原始表單值或從 render 丟出例外。只有 nullish 代表缺值。 -
Refresh error 判準不再由下游複製
status === 401:framework 的共用 classifier 僅把 stablerefresh-session-invalid視為正常拒絕,並為無 code 的 legacy 401 提供有 warning 的相容出口; proactive refresh 自製的ErrorResponse也補齊 URN type 與errorCode。 -
DatePicker/DateTimePicker/InstantPicker窄寬度下的格式化值會與清除、日曆按鈕重疊—— picker 的自訂rightAddon實際包含兩顆 24px 按鈕與間距,先前共用Input只將整個 slot 當成單一 addon,僅預留 40px。現在有值且顯示清除鈕時預留 64px;並依欄位實際可用寬度 量測,完整預設格式放不下時只省略星期,極窄時再以省略號收尾,不再穿入按鈕區。InstantPicker透過DateTimePicker共用同一修正;自訂formatValue不會被擅自拆解。 -
PdfViewer:在搜尋框 / 頁碼框打字會誤觸元件快捷鍵——viewport 的 keydown handler 沒有檢查e.target,而兩個輸入框都在 viewport 內、事件會冒泡上來:打r會同時把頁面轉 90°、按 ←→ 是 翻頁而不是移游標、打-會縮小。改為焦點在input/textarea/contenteditable時,元件 快捷鍵一律讓位(Ctrl/Cmd+F、F3等不與打字衝突者除外)。 -
MediaInput:鍵盤 focus 時出現兩個外框,其中一個壓在 floating label 上——內嵌的MediaViewerviewport 是 tab stop 卻沒有處理瀏覽器預設 focus ring,鍵盤(:focus-visible) 進入時 Chrome 會在欄位內側再畫一圈藍框,位置正好穿過 notch / label(滑鼠點擊不觸發:focus-visible,所以只有鍵盤看得到)。MediaViewer改為一律關閉 UA outline、需要時改畫 主題化 inset ring,並新增focusIndicator(預設true)讓宿主關閉;MediaInput傳false(欄位外框與 label 已表達焦點)。 -
PdfViewer:Tab 進入後要多按一次才到主要顯示區——tab stop 原本是包住「側邊欄 + 頁面 + 工具列」的整層外殼,不是頁面本身。改為頁面區才是 tab stop(外殼降為單純容器,keydown 仍留在 外殼上,故焦點在工具列按鈕時方向鍵等快捷鍵照常作用)。側邊欄開啟時 tab 序為「側邊欄(左)→ 頁面區(右)」,與視覺一致;側邊欄關閉時第一站直接就是頁面區。頁面區同步改用主題化 inset ring 取代 UA outline。 -
MediaViewer控制項的 tab 序與視覺位置不一致——控制項皆為絕對定位,tab 序由 DOM 序決定, 但 DOM 序是「導航(下)→ 操作環(中右)→ 工具列(上)→ 下載(右下)」,Tab 會由下往上跳。 改為依視覺位置由上而下、由左而右排列 DOM;操作環內部同理(扇形是由下往上排的,改為依實際 offset 排序),展開鈕則排在被它控制的操作之前(disclosure 慣例)。版面與動畫不受影響 (幾何與延遲仍依原 index 計算),工具列補z-10維持原有疊放層次。 -
PdfViewer/MediaViewer:Tab 會把焦點送到看不見的工具列按鈕上——工具列 hover 才顯示, 但按鈕是 tab stop,鍵盤使用者的焦點會落在不可見處(WCAG 2.4.7)。改為焦點進入元件內即顯示 控制項(focus-within),焦點離開才收回。 -
MediaInput/MediaViewer:鍵盤模型對齊PdfViewer(方向鍵選項目、Tab 停留在操作按鈕)MediaInput工具列的「刪除」「新增檔案」先前是tabIndex={-1}(鍵盤到不了,只能靠Delete/U快捷鍵),改為正常 tab stop。MediaViewer檔案導航的圓點(Go to item N)改為tabIndex={-1}——它是「選第 N 個項目」、 屬方向鍵領域,且數量隨項目成長(10 張圖 = 10 個 tab stop)。固定數量的 ‹ › 兩顆維持 tab stop。MediaViewer操作環(縮放 / 旋轉 / 重置)收合時是scale-0但仍在 tab 序,焦點會落在看不見的 按鈕上;改為焦點進入即展開。- 結果:元件內的 tab stop 數量不再隨項目數成長(5 張圖的 thumbnail 模式為 6 個、single 模式 14 個,皆為固定操作按鈕)。
-
PdfViewer:縮圖側邊欄每頁各佔一個 tab stop——300 頁的 PDF 就是 300 個 tab stop。改為側邊欄 本身是單一 tab stop(role="listbox"+aria-activedescendant),進入後 ↑↓ 選頁、Home/End 跳 首末頁,縮圖為role="option"、tabIndex={-1};←→ 仍冒泡給主區的翻頁 handler。側邊欄與主區 驅動同一個currentPage,行為不分岔。 -
PdfViewer:放大超過容器寬度時,頁面左半邊捲不回去——捲動容器用justify-content: center置中頁面,flex 的 center 對齊會把溢出內容推到 scroll origin 之外,該區域永遠無法捲到。改用safe center(溢出時退回 start 對齊),兩端皆可捲到。 -
PdfViewer:使用者傳入ref時,內部量測失效——ref={ref ?? containerRef}使外部 ref 取代內部 ref,導致響應式側邊欄隱藏(容器 < 600px)與全螢幕狀態偵測失去參照。改為合併 ref, 兩者並存。 -
MediaInput:每顆縮圖各佔一個「按了沒反應」的 tab stop——縮圖展開了 dnd-kituseSortable的attributes,帶進role="button"/tabIndex={0}/aria-roledescription="sortable";但元件 只註冊 Pointer / Touch sensor(無KeyboardSensor),那些 tab stop 按鍵盤什麼也做不了,還對螢幕 閱讀器謊報可鍵盤排序。改為不展開attributes,鍵盤操作一律走 MediaViewer 的 roving selection。 滑鼠 / 觸控拖曳排序不受影響(行為由listeners提供,非attributes)。- 連帶修正焦點與作用對象不一致:先前焦點在第 3 顆縮圖按 Delete,刪掉的是選取中的第 1 顆;
現在 Delete 一律作用於
aria-activedescendant指向的項目。 - 連帶修正刪除後焦點掉回
<body>(被刪的節點持有焦點):焦點恆在 viewport,刪除後仍在原位。
- 連帶修正焦點與作用對象不一致:先前焦點在第 3 顆縮圖按 Delete,刪掉的是選取中的第 1 顆;
現在 Delete 一律作用於
-
MediaInput:觸控裝置無法刪除已上傳的媒體——縮圖的刪除鈕只以group-hover:opacity-100顯示,觸控裝置沒有 hover 事件、該鈕永不出現;它又是tabIndex={-1},鍵盤也到不了,等於功能 不可達。改為(hover: none)時恆顯示(與其他元件的(hover: hover)判準互補)。 -
浮動控制項隱藏時仍會吃掉點擊——
PdfViewer(工具列 / 頁碼列)、MediaViewer(工具列 / 檔案導航 / 操作環 / 下載鈕)、MediaInput(縮圖刪除鈕)、DataTable/VirtualTable的TableActionSpeedDial、VirtualTable/VirtualList的回到頂部鈕,先前皆只以opacity-0隱藏,看不見的按鈕仍攔截其下方內容的點擊。隱藏時一併關閉pointer-events(鍵盤 Tab 不受影響, focus 進入仍會使控制項顯示)。 -
Server availability 錯誤分類與恢復流程:取消中的 Axios request 不再被誤判為網路中斷;收到 明確
5xx回應的 mutation 不再標成「結果未知」,只有未收到任何 response 的傳輸失敗才需對帳。 恢復確認若重取資料失敗會保留遮罩、顯示錯誤並允許重試;recovered期間再發生連線異常也會重新 探測並回到unavailable,健康複探不會重複發布server/recovered。結果未知清單可獨立清除,不再 依賴完整的中斷恢復狀態流程;若 Server 全程健康而只有單筆傳輸結果不明,使用獨立的對帳文案, 不再誤稱「連線已恢復」。System-info probe 只要求具鑑別力的version,不耦合 optional metadata。 -
MswHealthGuard:對 MSW 最常見的失效模式完全漏報:原判準navigator.serviceWorker.controller != null抓不到「Service Worker 被閒置終止後重啟、activeClientIds落空」這個情況——分頁進背景後瀏覽器節流頁面計時器,MSW 每 5 秒的 keepalive 被拉長到超過瀏覽器對閒置 worker 的終止門檻 → worker 被終止;下次請求雖會喚醒它,但重啟後activeClientIds是空集合,mockServiceWorker.js遂對所有請求 passthrough。此期間controller恆為非 null(它反映的是「哪個 registration 控制本頁」,不是 worker 是否仍在攔截), 故守衛恆判為健康、從不亮起。改以probe實際探測後可正確偵測;未傳probe時維持原判準, 行為完全向後相容。實測補充:穿透後拿到什麼取決於 dev server 設定——無
/apiproxy 時為 SPA fallbackindex.html(200+text/html)、有 proxy 但後端未啟動時為500、有 proxy 且後端已啟動時為200+application/json+ 真實後端資料。最後一種只有 mock-only marker 分辨得出來,且因請求「成功」, 重試機制連觸發的機會都沒有——這也是「靠 React Queryretry救回攔截失效」這個說法不成立的原因 (重試會喚醒 worker,但喚醒 ≠ 重新註冊 client)。 -
RichTextEditor:整頁載入(deep link / 重新整理)時整棵子樹被 error boundary 接走: 受控同步 effect 以if (!editor) return當守衛,但useEditor在 StrictMode 重掛載 (及卸載競態)期間會先回傳已 destroy 的舊實例——該實例非 null,其view/schema卻已 釋放,editor.getHTML()於是在 ProseMirror 的DOMSerializer.fromSchema(null)拋TypeError: Cannot read properties of null (reading 'cached')。症狀是「從列表點進編輯頁正常、 直接輸入網址或按 F5 就整頁壞掉」,使用者編輯中重新整理會看到錯誤頁。修正:受控同步、setEditable、imageUploadstorage 三個 effect 一律改用if (!editor || editor.isDestroyed) return守衛(TipTap 對 editor 副作用的建議寫法)。不影響既有行為——受控 value 仍照常灌入編輯器。 -
Select/TagInput:中文/日文/韓文輸入法組字時,按鍵被元件搶走:兩者的onKeyDown未辨識 IME 組字狀態——Select搜尋框組字期間↑↓會移動選項 highlight(候選字選不動)、Enter會直接選取 highlighted 選項並收起下拉(候選字還沒確認,搜尋字也被清掉),中文搜尋(如Select/AsyncCustomerSearch)幾乎無法用鍵盤完成;TagInput則會把尚未確認的組字中間態 (注音符號等)當成 tag 加進去,Backspace亦會誤刪最後一個 tag。修正:組字期間一律不攔截按鍵, 全數留給輸入法。判定同時採nativeEvent.isComposing(Chrome / Firefox)、keyCode === 229(舊版瀏覽器)與自記的 composition 旗標——後者為 Safari 而設:其compositionend早於 「確認候選字」的keydown,該刻isComposing已為 false,只有把旗標延後一個 macrotask 才放下 才擋得住。組字結束後另按的按鍵行為完全不變。- 共用 IME 引擎:組字旗標、三重按鍵判定與 composition 處理器抽為
data-input/ime-composition內部共用 hook(比照chip-color/file-download),由Select與TagInput共用,不重複實作
- 共用 IME 引擎:組字旗標、三重按鍵判定與 composition 處理器抽為
-
Select(async):IME 組字中間態會打出無意義的搜尋查詢:搜尋框的onChange對每次輸入 都送onInputChange,用注音/拼音打字時每個中間態字元(ㄏ、ㄏㄨㄚ…)各觸發一次後端查詢。 修正:組字期間不送查詢,落定後以最終文字送出一次;顯示文字仍即時同步(受控 input 於組字期間 被回退會打斷 IME)。非 IME 輸入(英數)維持即時送出。查詢時機的判定點在compositionend之後 一個 macrotask,確保讀到的是瀏覽器補送input事件後的確定文字。 -
Select(單選):再點已選中的選項會把它取消掉:選取邏輯對單/多選共用同一條 toggle (selectedValues.includes(value) ? [] : [value]),使單選也具備「再點一次取消」語意——但那是 多選才友善的操作;單選使用者再點同一項的預期是「確認這個選項」(對齊原生<select>), 卻換來欄位被清空。修正:單選一律newValues = [optionValue],不再取消選取;多選的 toggle 行為不變。清除入口不受影響、仍可清空(非 ghost 的 X 鈕、ghost 的下拉「—」選項、下拉收起時的Delete/Backspace)。 -
DatePicker/DateTimePicker/TimePicker/InstantPicker:從月曆/選單選取、或按清除鈕後,輸入框不更新: 選取/清除後以setInternalValue更新值(非同步),但收合/清除時於同一批次同步focus()回輸入框 →onFocus讀到尚未更新的舊values並setDraft(舊值),把分段draft停在舊值(清除時更會洗掉reset()剛清空的 draft),編輯態顯示走draft→ 輸入框停在舊值(uncontrolled 尤其明顯,如 StorybookDatePicker/Default)。修正兩處:①useSegmentedMask於編輯期間、外部值確有值且與draft內容不同時把draft追平到外部值(空值不覆蓋半途輸入、相同值不觸發迴圈);② 移除onFocus內讀舊values覆蓋 draft 的 邏輯(同步統一由前者處理),讓清除後的空 draft 得以保留。四個 picker 共用此遮罩,一併修復。 -
Select/TagInput停用(disabled)降淡樣式從未生效,外觀與Input不一致:兩者的容器是<div>(非表單元素),Tailwinddisabled:變體所需的:disabled偽類永不匹配,整組停用樣式為 死碼——停用的 Select 文字全彩、TagInput chips 全彩,與唯讀難以區分(Input 為/70半透明)。 修正:改用aria-disabled:屬性變體(TagInput 容器連帶補上aria-disabled屬性),文字濃度由/50對齊 Input 的/70,分支結構鏡像 Input(notch 模式容器邊框維持透明、由 fieldset 提供邊框; ghost 維持透明;其餘border-base-content/20);multiple chips 以opacity-70等效降淡(chip 自帶色彩、不吃容器文字色繼承)。連帶修正Select停用/唯讀游標:無條件cursor-pointer與 條件式cursor-not-allowed/cursor-default為同屬性疊加、勝負取決於 CSS 產出順序(實測 pointer 恆勝),改為三態互斥條件式,停用現正確顯示not-allowed -
DataTable/VirtualTable使用者調整的欄序在 re-render 時被打回原形:欄位定義變動的偵測以naturalColumnOrder的參照為準(useEffect的相依),而該值useMemo自columnsprop—— 呼叫端若把columns定義在 render 內而未useMemo(常見寫法,且元件從未要求要 memo),每次 render 都是新參照,於是任何一次 re-render 都會把欄序重設回自然順序。使用者拖好欄位,下一次 狀態更新就白拖。修正:改以欄位 id 的內容比對,且欄位真的變動時採調和而非整份丟棄—— 仍存在的欄保留使用者順序、新欄插回自然位置、已刪除的欄移除(可見性同步調和,丟棄已不存在的欄)。 連帶把 reconcile 由useEffect改到 render 期間進行,少一輪串聯重繪。 兩個表格的版面狀態邏輯一併收斂到共用的useColumnLayout -
表單版
<FileInput>(lib/form/)未提供onDownloadAll時,「下載全部」按鈕完全無作用:useFileInput無條件把onDownloadAll包成一個恆為 truthy 的 wrapper 函式傳給BaseFileInput, 而BaseFileInput以if (onDownloadAll)判斷要走「使用者自訂下載」還是「預設 JSZip 打包」。 wrapper 恆存在 → gate 永遠命中自訂分支 → 呼叫undefined?.()= no-op → 預設 JSZip 打包路徑 永遠到不了;按鈕按下去不打包、不發請求、不報錯。修正:useFileInput直接透傳原始onDownloadAll(未提供時為undefined),讓 base 的 gate 正確分流。純 UI 版 (lib/components/)不受影響(本就直接收 prop) -
MediaViewer自訂縮圖 renderer 收到未解析的src→ 帶認證的伺服器媒體在 gallery(縮圖)檢視永遠轉圈圈: 內建縮圖路徑以getDisplaySrc解析出 blob url 再顯示,但走自訂renderThumbnail時(如MediaInput的 gallery)卻傳原始item(src 為相對 / API url),自訂 renderer 判定needsHttpClientFetch=true顯示 spinner、又無從取得 blob url → 卡死;單張 / 輪播檢視正常(走getDisplaySrc)。修正:傳給自訂 renderer 的 item 也置換為已解析的getDisplaySrc(item)(保留其餘欄位)。連帶讓MediaInput的 gallery 正確顯示伺服器媒體縮圖。 -
createHttpClient攔截器對responseType: 'blob'請求的 HTTP 錯誤,遺失 RFC 7807 契約訊息: blob 請求失敗時error.response.data也是Blob,而攔截器的isRecord()明確排除Blob→ErrorResponse被建成空殼(只有status,detail/violations全丟)→getApiErrorMessage顯示不出後端具體訊息。修正:攔截器在建ErrorResponse前,若data instanceof Blob先把 JSON 錯誤 body 從 Blob 讀回物件(非 JSON 則退回空物件,與現狀同)。此為通用修正,惠及所有responseType: 'blob'的下載請求,非僅FileInput。401 auto-refresh 不受影響(於 blob 轉換前處理) -
VirtualTableheight傳百分比時,在非 flex 父容器(如 grid cell)內塌成 0 高:百分比會切到 flex/fill 佈局,但根容器只取flex-1 min-h-0——flex-1僅在 flex 容器內有效,放進 grid cell 或一般 block 時是 no-op,捲動層的absolute inset-0於是撐在一個 0 高的容器上,整個表格不可見。三欄式 Finder 這類 grid 版面首當其衝。修正:根容器改取flex-1 h-full min-h-0——在 flex column 內flex-1的flex-basis: 0%仍主導主軸尺寸(h-full無害、行為不變),在 grid cell / block 內則由h-full生效。實測:grid cell 內 0 → 400px,flex column 內維持既有的「填滿剩餘空間」語意。同時把此佈局約束補進height的 JSDoc(原本完全沒有文件) -
VirtualTableshowScrollToTop/onScrollChange在未提供onFetchNextPage時完全失效:捲動監聽器以if (!scrollElement || !onFetchNextPage) return開場,使整個 scroll listener 在沒有無限捲動來源時根本不掛上——回到頂部按鈕永遠不出現、onScrollChange永遠不觸發,兩者於是隱性依賴「必須同時傳onFetchNextPage」。但這兩項與分頁無關:純虛擬化的長清單(資料一次載完、無分頁來源)同樣需要它們。修正:onFetchNextPage的守衛下移到只守「載入更多」那一段,位置回報與回到頂部按鈕改為無條件生效。既有測試只驗過「初始不顯示」、從未驗過「捲動後會顯示」,故長期未被發現;已補上兩則捲動後的斷言 -
DataTable欄位可見性(齒輪的「欄位可見性」動作)在未提供columnVisibility/onColumnVisibilityChange時勾選無效:先前state.columnVisibility無條件帶入(預設{})使其恆為 controlled,未提供對應回調時遭凍結——齒輪下拉可勾選卻不會隱藏欄位(序號欄、欄序拖曳皆有未控 fallback、正常運作,唯獨欄位可見性沒有)。修正:比照columnOrder補上未控 fallback(columnVisibility未提供 → 內部狀態管理、零 props 即可運作;提供則為 controlled,經onColumnVisibilityChange通知)。columnVisibility預設由{}改為undefined以區分受控/未控:ISO8601_REGEXP的時間段原為可選((T…)?),使"2025-12-21"(LocalDate)與"2025-12-21T14:30:00"(無 offset 的 LocalDateTime)被判為 ISO 日期 → responsedecode()將其parseISO成Date(本地午夜的時間戳);一旦以 UTC 取日期(toISOString().slice(0,10))或跨時區使用即偏移一天,且守衛測試should reject incomplete ISO 8601實為紅燈。修正:regex 要求帶 offset/Z——唯有時間軸上的 instant 才轉Date,LocalDate/LocalDateTime一律留字串。連帶 formInput(type=date/datetime-local)與 formDatePicker不再把輸入轉Date(見上方 Added):表單值即後端LocalDate/LocalDateTime的 JSON 字串形狀,寫出免轉換、免時區陷阱;讀回(decode保留字串)與寫出(encode原樣送字串)對稱 -
RichTextEditorautoResize(預設開啟)時編輯區塌成只剩一行高:autoResize 內容區同時掛了 inlinemin-height: <minRows×1.5>em與 class!min-h-0(min-height: 0 !important),而 stylesheet 的!important會蓋掉非 important 的 inline style,使min-height解析為0、編輯區塌到內容自然高度(約一行)。改為比照 container 既有作法(!autoResize && densityStyles.container):autoResize 時不套 density 預設 min-h class、純由 inlineminHeight控制,移除!important衝突。density 的 min-h 從densityConfig.content拆到新欄位contentMinHeight(僅非 autoResize 時套用)。預設用法<RichTextEditor … />(autoResize、minRows=3)現正確呈現 ~3 行(4.5em)下限 -
FileInputghostvariant 在多 chip 排滿時,最後一個 chip 會與「下載全部」鈕重疊(chip 容器未為絕對定位的右側按鈕群組預留空間);改以動態pr依右側鈕數預留空間修正 -
RadioGroup群組label現在會自動透過t()翻譯,與其他表單組件(Input、Select等)的 i18n 自動翻譯規範一致。先前必須在呼叫端手動t() -
RadioGroup.required不再連帶在個別Radiolabel 顯示*,僅在群組 label 顯示一次(對齊CheckboxGroup行為與 MUIFormControl required設計)。先前每個選項 label 後都會被加上* -
Radio未選取邊框:覆蓋 daisyUI 預設/20透明度為text-base-content(100%),把 radio 視為「欄位內容」一員,與Input文字內容色一致 -
Checkbox/Radio/SwitchhideLabel且未提供icon時,label 容器使用sr-only(避免空 label 仍佔 flex item 並 trigger 外層gap-1.5,在flex justify-center的表格 cell 內造成 3px 居中偏移)。有icon時仍保留 visible flex container 以實現 icon-only 視覺 -
Checkbox/Radio/Switchinline模式下,外層 wrapper 為<span>(避免嵌入<p>prose 段落時觸發 HTML 違規<div> cannot be a descendant of <p>與 React hydration warning),內層inline-flex items-center,視覺上 input ↔ label 排列方式不變- 行為變更:
inline模式下不再渲染error與helperText(這兩個訊息在 prose 文字流中顯示本來就會破壞段落結構,應由外圍表單統一呈現) - Ref 變更:
inline模式下ref指向HTMLSpanElement;非inline模式仍為HTMLDivElement。下游若有透過 ref 操作 inline Switch/Radio/Checkbox 的需求,需自行用Ref<HTMLElement>或 union 型別接收
- 行為變更:
-
MediaInput/MediaViewertoolbar 與縮圖刪除鈕統一 hover-to-show 行為:- 先前
MediaViewer內部renderNavigationControls/renderImageControls(含 Settings2 expand 與展開後的 Zoom/Rotate/Reset fan)已採「hover 才顯示」模式(透過hoveringReact 狀態),但renderToolbar(Delete / Upload / Toggle Grid / Fullscreen)與SortableThumbnail刪除鈕仍永遠顯示,造成不一致 - 統一修正:
renderToolbar改為hovering ? 'opacity-100' : 'opacity-0'與其他控制群一致;SortableThumbnail刪除鈕hover:opacity-100→group-hover:opacity-100,並在MediaInput的內層 positioning wrapper 加groupclass 提供 CSS hover 上下文 - 結果:hover MediaInput 任意處 → toolbar 與縮圖刪除鈕全部淡入;mouse leave → 全部淡出。先前
SortableThumbnail用hover:需要 hover 到(透明的)按鈕本身才出現,使用者幾乎無法觸發
- 先前
-
MediaInput/MediaViewer控制鈕尺寸統一為 24px:MediaViewer與MediaInput的平移功能 prop 由scrollable直接更名為pannable(預設仍為true),精確反映其控制的是媒體拖曳平移而非容器捲動;未保留向後相容 aliasMediaInputsingle mode toolbar 的 Delete / Upload 按鈕由btn-sm(32×32px)改為btn-xs(24×24px),圖示w-4 h-4→w-3.5 h-3.5MediaInput的單檔與縮圖刪除按鈕不再固定使用error/destructive色彩,改為與 FileInput 等輸入元件一致的中性移除語意;hover 樣式同步採用 toolbar 的 ghost 控制鈕規格- 下載能力由
MediaInput遷移至MediaViewer:新增預設開啟的downloadable與downloadAllFileName,single/carousel 下載目前項目、thumbnail 打包全部項目;下載按鈕維持 24×24px 中性 ghost 樣式與bottom-4 right-4定位,進度/成功/失敗狀態改由 viewer 左上角疊層呈現。MediaInput僅轉交設定並沿用相同行為 fullscreenable預設值由false改為true;全螢幕控制由右上 toolbar 收入 Settings 展開選單,固定插在 Settings 與 Rotate Counter-clockwise 的中點,不參與既有扇形操作的角度重分配;縮圖/輪播模式也可透過該選單進入全螢幕,F快捷鍵維持不變MediaViewer的mediaViewerControlButtonVariants基底btn-sm→btn-xs,影響所有控制按鈕(Zoom In/Out、Rotate CW/CCW、Reset、Expand/Collapse Controls、Page Navigation、Play/Pause、View Mode Toggle、Fullscreen Toggle),對應圖示w-4 h-4→w-3.5 h-3.5- 統一規格:所有 MediaInput 與 MediaViewer 工具按鈕現為 24×24px,與
SortableThumbnail縮圖刪除鈕(已是 24px)以及 MUIIconButton sizeSmall(24px)一致 - 規避方式:若應用層需要保留原本 32px 觸控目標,可透過 className 覆蓋傳入自訂尺寸
-
MediaViewerviewMode被ResizeObserver覆蓋 bug:- 症狀:multiple 模式下使用者點 toggle 切換到 Show Single 後會「閃一下又切回 thumbnail」
- 根因:響應式 displayMode 的
useEffect在 deps 中包含setViewMode,而setViewMode的useCallbackdeps 含viewMode,導致使用者每次 toggle 後 setViewMode reference 變更 → effect 重跑 → 重設ResizeObserver→ 首次 fire 立刻執行else { setViewMode(initialDisplayMode) }分支,把剛切到的single強制改回thumbnail - 修法:用
constrainedRef追蹤前一次的「受限狀態」(isSmall || items.length <= 1),只在條件轉換時才動作。初次掛載與 effect 重跑都不主動覆寫使用者選擇——使用者透過 toggle 按鈕的選擇被保留
-
MediaViewerdisabled不阻擋 wheel zoom / mouse drag bug:- 症狀:
MediaInputdisabled時,圖片仍可用滑鼠滾輪或 macOS trackpad 兩指 pinch 手勢(觸發wheel + ctrlKey)縮放,亦可拖曳平移 - 根因:
MediaViewer.handleWheel/handleMouseDown未檢查disabledprop(僅handleKeyDown有檢查) - 修法:兩個 handler 開頭加
if (disabled) return,與鍵盤一致;readOnly不影響檢視操作(縮放/平移為檢視輔助而非編輯動作),維持原行為
- 症狀:
-
MediaInput/MediaViewertoolbar 視覺統一:- 修正
Reset與Rotate Counter-clockwise共用同一個RotateCcw圖示無法分辨的問題:Reset改用 lucideRefreshCw(完整圓圈雙箭頭,與單向 RotateCw / RotateCcw 半圓箭頭明顯不同) - 三邊 toolbar 按鈕 hover 風格統一:
- 先前
MediaInputUpload(bg-base-100/90 backdrop-blur-sm shadow-lg、無顯式 hover)、MediaViewerghost variant(bg-base-100/50 hover:bg-base-100/80、無 shadow、無 backdrop blur)、MediaInputDelete(btn-error、無 shadow)三者背景 / shadow / hover 三維度全不一致 - 現統一為
bg-base-100/90 backdrop-blur-sm shadow-md hover:bg-base-100(Upload / MediaViewer ghost)、btn-error shadow-md hover:bg-error(Delete 維持紅色 destructive 語意但補 shadow + hover) mediaViewerControlButtonVariants.solidvariant 也補shadow-md保持一致
- 先前
- 修正 ghost variant hover 時白底白字看不見 bug:daisyUI 在
btn-ghosthover 時會把文字色還原為btn-primary的primary-content(白色),與本次強制保持白色背景衝突;於 ghost variant className 補text-base-content/70 hover:text-base-content強制覆蓋——ghost variant 上colorprop 對視覺無效果,符合「toolbar overlay 應該中性」的設計直覺
- 修正
-
MediaInput/MediaViewersingle 模式 image padding 與 multiple 縮圖 gap:- Single mode:
renderMedia外層包<div className="w-full h-full p-2">(8px 內距),避免圖片貼邊框且與 floating label 視覺接觸 - Multiple mode:
renderThumbnailContainer縮圖網格gap-4 p-4(16px)→gap-2 p-2(8px),縮圖間距更緊湊、與 single mode 視覺一致
- Single mode:
-
MediaInputJSX 結構對齊 Input / Textarea / Select:borderedvariant 外層 wrapper 新增pt-3(對齊 Input/Textarea/Select 的浮動 label buffer 慣例);先前外層無 buffer,浮動 label 渲染於外層上緣 −10 ~ −12px 處,在grid items-start並排其他 bordered Data Input 時,label 會「凸出 grid row」造成視覺不對齊dc.container(h-[200|280px])由原本套在最外層 wrapper 改為套在內層新增的 positioning wrapper<div className="relative">;fieldset notch / floating label 改為此內層的絕對定位子,外層改為 auto heighterror/helperText/ validation errors 改為內層 positioning wrapper 的 sibling(在外層 normal flow 中),不再被 fixed-height 截斷溢出- 視覺影響:bordered variant 總高度增加約 12px(pt-3 buffer)+ helperText 高度;先前 helperText 溢出外層的「鬼影」現象消失,並列其他 bordered Data Input 時 label 對齊到同一 grid row top
- 規避方式:若應用層需保留原本「無 pt-3 + helperText 溢出」的視覺效果,可在外層自行加
mt-[-12px]抵消,但不建議——原本的溢出行為實為 bug
-
createHttpClient檔案上傳binary-separate(fileUpload.strategy: 'binary'+endpoint)的 PUT 階段未帶Authorization,對非 presigned(內部)上傳 URL 一律 401:- 症狀:prepare(
POST {endpoint})正常回傳上傳 URL,但隨後上傳二進位的PUT對需 Bearer 認證的內部上傳 URL 回401 Unauthorized(每次必現、非偶發) - 根因:PUT 走原生
fetch,其Authorization取自config.headers.Authorization;但附 Bearer 的 request 攔截器「註冊在最前、axios 反序執行故最後跑」,而handleFileUpload(執行 PUT 處)先跑——PUT 當下該欄位尚未填值,條件永不成立 - 修法:PUT 的 token 改與 Bearer 攔截器同源,直接取
auth.getAccessToken(),與攔截器時序無關;presigned URL 行為不變(仍credentials: 'omit'、不附Authorization),並保留對外部預設config.headers.Authorization的後備判斷(向後相容)
- 症狀:prepare(
-
createHttpClient檔案上傳binary-separate的 PUT 對非 presigned(自家 API)上傳 URL 未套用baseURL,反向代理子路徑部署一律上傳失敗:- 症狀:app 部署在反向代理子路徑下(如前端
…/tqf/office、API…/tqf/{app}-server)時,帶附件的表單送不出——prepare(POST {endpoint})成功,但上傳二進位的PUT從未到達後端(反向代理回 404)。app 在本機執行(無子路徑前綴)時正常,故僅在部署環境現形 - 根因:prepare 回傳的上傳 URL 是相對 API 根的路徑(如
/api/v1/staging/files/{tempId},不含子路徑前綴)。PUT 走原生fetch(url)、未套用 clientbaseURL,瀏覽器改以「頁面 origin」解析相對 URL → 掉了baseURL承載的子路徑前綴 → 指向不存在的位址。prepare 走 axios(有套baseURL)故正常,兩者不一致 - 修法:依「絕對 vs 相對 URL」二分決定傳輸——相對 URL(自家 API)改走 axios instance(
instance.put),與其他 API 呼叫一視同仁:自動套baseURL(補回子路徑前綴)、由 request 攔截器附 Bearer、由 response 攔截器統一處理 401 refresh 與錯誤轉ErrorResponse;絕對 URL(presigned,S3/Azure/GCS 等第三方)維持原生fetch中性傳輸(不套baseURL、credentials: 'omit'、不附自家 Bearer,避免洩漏憑證給第三方)。handleFileUpload攔截器新增「body 本身為 File/Blob 即原樣放行」守衛,作為自家 PUT 重入的遞迴防護 - 行為變更(自家 API 上傳):PUT 失敗現由 response 攔截器轉為
ErrorResponse(原為通用Error),並納入 401 自動 refresh 重試——與其他 API 呼叫一致
- 症狀:app 部署在反向代理子路徑下(如前端
-
DataTable/VirtualTable:striped={false}時 sticky 欄 hover 底色比整列深一階:- 症狀:關閉斑馬紋的表格,滑鼠移到列上時最左的 sticky 欄(選取欄/序號欄/
stickyFirstColumn) 明顯比同列其他欄深,像多了一塊色塊;開啟斑馬紋時正常 - 根因:sticky 欄的
<th>有不透明底色(table-pin-cols給 base-100、斑馬偶數列給 base-200), 會蓋住套在<tr>上的 hover 色,故 sticky cell 需自行補色;但補色寫死group-hover:bg-base-300, 只對齊了 DaisyUIrow-hover的斑馬規則——非斑馬表的 hover 實為 base-200 (實測 light 主題:oklch(0.94)vsoklch(0.97)) - 修法:補色改由
getStickyHoverBgClass(striped)依斑馬紋分流(斑馬 base-300/非斑馬 base-200), DataTable 與 VirtualTable 共用shared/table.config.ts的同一來源
- 症狀:關閉斑馬紋的表格,滑鼠移到列上時最左的 sticky 欄(選取欄/序號欄/
Security
- Content 類型檢測(FileInput、MediaInput)
- 檔案大小限制
Breaking Changes
-
Modal更名為Dialog,原Dialog更名為PromptDialog(命名決策見 ADR-010)- Impact:
Modal/ModalProps/ModalSize不再匯出,既有<Modal>用法出現 TypeScript 編譯錯誤。原Dialog/DialogProps/DialogVariant亦不再以該名匯出——但 fleet 盤點 顯示無任何下游直接匯入該元件,實務上僅Modal的更名有影響。兩支的 props 幾乎不重疊 (原Dialog必要content/severity,新Dialog必要children),故不存在 「編譯得過但渲染成別的元件」的靜默失敗。MessageDialog/MessageToast/prompt名稱與行為皆不變。 - Migration: 機械替換即可。
自
@appfuse/appfuse-web/components匯入的Modal→Dialog;<Modal … >→<Dialog … >;ModalProps/ModalSize→DialogProps/DialogSize。 若直接使用過原Dialog,改為PromptDialog(DialogProps→PromptDialogProps、DialogVariant→PromptDialogVariant)。注意同檔若另有import { Dialog } from '@headlessui/react'(手刻外殼的遺留)會撞名——該類檔案本就 應改用框架元件,或將 Headless UI 的匯入別名為HeadlessDialog。
- Impact:
-
DatePicker/DateTimePicker現以必填type形成辨識式 union,且不再寬容跨語意值型別- Impact: 既有呼叫未傳
type、Local 模式傳入Date、或DayStatus.date傳入Date時會出現 TypeScript 編譯錯誤;DatePicker type="instant"傳入非法鐘面字面值,或 form wrapper 的name指向不相符值型別時也會編譯失敗。未提供instantTime則採start-of-day。JS/as any造成的 form 值錯配會顯示欄位 inline contract error,且保留原始表單值供修正或診斷。 - Migration: LocalDate 使用
DatePicker type="local-date"與YYYY-MM-DD字串;LocalDateTime 使用DateTimePicker type="local-date-time"與無 offset 字串;Instant 使用對應 picker 的type="instant"與Date。只有日期的 Instant 另以instantTime="start-of-day"、"end-of-day"或明確鐘面時間宣告補時策略。
- Impact: 既有呼叫未傳
-
AuthConfig.refresh()的null契約收緊為「Server 明確拒絕 refresh credential」;網路、timeout、5xx等基礎設施失敗必須 throw,不得 catch 後回null- Impact: 舊實作若以
catch { return null }吞掉 transport/5xx,升級後仍會把 Server 異常誤判為 Session Expired,且不會有編譯期錯誤提示 - Migration: 僅在可辨識的 refresh credential 拒絕(例如
ErrorResponse.status === 401)時回null;其他錯誤原樣throw,交由createHttpClient發布 Server availability 事件
- Impact: 舊實作若以
-
下游自有的 refresh classifier/跨分頁 Web Lock rollout shim 與新版 framework coordinator 衝突
- Impact: 若 app callback 在 framework 已取得
appfuse:refresh-session後再次要求同名 exclusive lock,Web Locks 不可重入,refresh 將永久等待且不發布重新認證或 availability 事件 - Migration: 升級時移除 app-owned classifier、refresh lock 與 callback timeout shim。無法原子移除
時,shim 必須先以
'requiresReauthenticationAfterRefresh' in appfuseUtilsfeature detection 自動 停用三者,讓新版 framework 成為唯一 refresh lifecycle policy owner
- Impact: 若 app callback 在 framework 已取得
-
DataTable/VirtualTable的欄位排序改到齒輪「欄位」面板操作,表頭不再有拖曳把手;欄位順序一併改為純非受控- UI 變更:表頭移除拖曳把手(
⋮⋮),只剩排序 + 欄名。欄位的顯示 / 隱藏與排序統一收在齒輪的「欄位」面板——每列一個拖曳把手 + 勾選,拖動列重排、點列切換顯示(支援鍵盤:把手 focus → 空白鍵拾起 → 上下移動 → 空白鍵放下) - API 變更:
- 移除
enableColumnReorderingprop——排序是「欄位」面板的常駐功能,沒有東西要 gate - 移除受控
columnOrderprop,改為defaultColumnOrder(種子;只在首次渲染取值)
- 移除
- 影響範圍:以
columnOrder受控驅動、或以enableColumnReordering開關欄位拖拽的下游 - Impact: 編譯期型別錯誤(
columnOrder/enableColumnReorderingprop 不存在);倚賴「表頭拖曳」操作的使用者改用「欄位」面板 - Migration:
enableColumnReordering→ 移除(排序恆在「欄位」面板可用)- 受控
columnOrder={x}→defaultColumnOrder={x}(種子),onColumnOrderChange保留(非受控下仍會觸發,可持久化;Redux 場景照舊 dispatch) - 程式化驅動欄序(切換檢視預設集)→ 用
onTableChange取得的 table 實例呼叫table.setColumnOrder(...)
- UI 變更:表頭移除拖曳把手(
-
DataTable/VirtualTable的欄位可見性改為純非受控:移除受控columnVisibilityprop,改為defaultColumnVisibility(種子;只在首次渲染取值)- 影響範圍:以
columnVisibility受控驅動欄位顯示 / 隱藏的下游 - Impact: 編譯期型別錯誤(
columnVisibilityprop 不存在);VirtualTable先前更是恆受控卻無未控 fallback,未同時傳columnVisibility+onColumnVisibilityChange時齒輪隱藏欄完全無效(本次一併修正) - Migration:
- 只想讓使用者自由切欄位 → 移除
columnVisibilityprop(零 props 即可運作) - 需持久化 → 保留
onColumnVisibilityChange(非受控下仍會觸發),初始值改用defaultColumnVisibility(例:Redux 場景columnVisibility={x}→defaultColumnVisibility={x},callback 不變) - 需初始隱藏某欄 →
defaultColumnVisibility={{ colId: false }} - 需程式化驅動(切換檢視預設集)→ 用
onTableChange取得的 table 實例呼叫table.setColumnVisibility(...) - 逐欄禁止隱藏 → column 的
enableHiding: false(不變)
- 只想讓使用者自由切欄位 → 移除
- 影響範圍:以
-
RichTextEditor公開 API 由 SlateDescendant[]改為 HTML 字串- 影響範圍:使用
<RichTextEditor>(base 元件或 form 包裝)並透過value/defaultValue/onChange操作內容的下游 applet - Impact: 編譯期型別錯誤(
Descendant[]→string),執行期既有資料若仍為 Slate JSON 字串會以原樣顯示為文字 - Migration:
value: Descendant[] | null→value: string | null(HTML,如'<p>Hello <strong>world</strong></p>')onChange: (value: Descendant[]) => void→onChange: (value: string) => void- 移除
import type { Descendant } from 'slate' - 移除
EMPTY: Descendant[]引用,改為EMPTY: '<p></p>'(從@appfuse/appfuse-web/componentsre-export) - Form 層:react-hook-form 的 defaultValues 由
''/ Slate JSON 字串改為 HTML 字串,欄位值序列化也直接是 HTML - 進階用途(取 Editor 實例):
ref.current?.getEditor()回傳的型別由 SlateEditor改為 TiptapEditor,commands API 完全不同
- 設計取捨:HTML 為跨工具事實標準(後端 sanitize、Email/PDF 模板、SSR、全文搜尋皆受惠),且未來若改換底層引擎不必再破 API。需要 ProseMirror JSON 的進階場景仍可透過
ref.current?.getEditor()?.getJSON()取得
- 影響範圍:使用