跳至主要内容

appfuse-web Changelog

Framework Library 變更日誌(lib/ 框架層)。


[Unreleased]

Added

Data Input / MediaInput (lib/components/data-input/media-input/)

  • MediaInput 新增 container-based columnsthumbnailObjectFit、非同步 beforeRemoverenderItemActionsitemActionsVisibilitygetItemKey 與具接受/回復語意的 onOrderChange; 排序支援 Alt + 方向鍵並以 live region 公告成功/回復結果,非同步持久化失敗會回復原順序。 桌面 hover/focus actions 以單一縮圖為觸發範圍,不會因整個 MediaInput 取得 hover/focus 而全部顯示。
  • FileDescriptor.fileId 現在優先作為 React/DnD 穩定 identity;同步公開 MediaInputSourceMediaInputColumns、action/ordering context 與相關型別,以及 DEFAULT_MEDIA_INPUT_COLUMNS; 增量加入相同來源時會避開既有 item key,維持 React、刪除及 DnD identity 唯一。
  • MediaInput 新增 collapseToSingleBelowshowAddTilerenderAddTilecapacitySlotsremovablethumbnailContainerHeight,支援不收合的窄容器網格、固定容量位置、網格內新增卡片, 以及「可替換但不可刪除」的主圖組合;只設定 capacitySlots 且尚無媒體時仍保留上傳 dropzone。 renderAddTile 只渲染視覺內容,可點擊的 role="option" 根節點由框架建立。MediaViewer 同步公開 可停用的自動收合門檻與 bottomActionsSlot;關閉收合不影響獨立的 400px 小螢幕控制項門檻。 已有媒體且沒有網格新增卡片時,MediaInput 的上傳按鈕改列於右下角下載按鈕左側。

Overlays / Popover (lib/components/overlays/popover/)

  • Popover 新增 closeOnSelect:點擊內容中的有效 action 後關閉並把焦點返回 trigger;容器空白及 disabled/aria-disabled action 不會誤關閉。適合以任意 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() 與解析結果(kindtolabel)。解析順序為 獨立視窗關閉 → 應用內上一頁(navigate(-1))→ 最近的 <ReturnBoundary> 落點 → fallback (預設 /);邊界與 fallback 一律 replace,返回不在歷史堆疊留下深入頁面。
  • <ReturnBoundary path label>useReturnBoundary() — 由 collection/applet root 宣告返回落點。 以 Context 而非 route handle 解析,因此同時涵蓋 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 Lock appfuse: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,集中判斷 stable refresh-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_ENABLEDfalse = 無法自癒)。純瀏覽器 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 在請求路徑發布的失效狀態;自癒失敗才顯示 提示條。runtime enabled 判定由協調器統一持有,避免 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)

  • 新增 ProgressProgressProps:以原生 <progress>/progressbar 語意表達工作完成進度;提供 value 時為 determinate(max 預設 100),省略時為不輸出 value/max 的 indeterminate。
  • 新增 MeterMeterProps:以原生 <meter> 語意表達有界量測值,支援 minmax/必填 value、會翻譯的 accessible label/value text,以及與 Progress 一致的 DaisyUI color。元件不內建 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/)

  • ButtonLinkButtonLinkPropsInternalButtonLinkPropsExternalButtonLinkProps@appfuse/appfuse-web/components)— 以 mode + tohref 提供 internal/external discriminated API。internal mode 省略 mode 即維持完整 React Router Link 行為;external mode 輸出原生 <a href>,預設新分頁並強制補齊 noopener noreferrer,且要求可見、會納入 accessible name 的 externalLabel。兩種模式都保留 modified click、中鍵、右鍵選單與輔助科技 link 語意, 同時與 Button 共用 variantcolordensityshape、hover、focus-visible、active 的 class composition。新增 disabled representation:仍要求 tohref,但不向 DOM 輸出目的地、 internal router props 或 external targetrel,並以 role="link"aria-disabled="true"tabIndex={-1} 與 Button disabled classes 呈現;click/中鍵不會導航或觸發 consumer handler。 title / aria-label / externalLabel 延續框架自動翻譯;仍不提供 loadingconfirmationwarning。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-DispositionfallbackFilename、URL 尾段決定檔名。
    • 公開 FileDownloaderuseFileDownloader()FileDownloadTargetFileDownloadRequestFileDownloadError 與進度型別等下載引擎 API,讓需先驗證/儲存再下載的業務操作與 FileDownloadLinkFileInputMediaInput 共用同一實作。

Data Input (lib/components/data-input/)

  • Input 新增 revealable prop(@appfuse/appfuse-web/components/form 兩版)——密碼欄位的 明碼顯示切換鈕(眼睛),補齊 Input 既有的 type-conditional 右側控制項家族(clearable 的清除鈕、 date 的日曆鈕、number 的增減鈕),此前各應用需自行以 rightAddon 重造,含 aria-labeltabIndex={-1} 與 addon 內距計算等細節,已在 fleet 內出現無障礙標註不一致的分歧。

    • type="password" 生效,其餘 type 忽略;readOnly / disabled 時不顯示。
    • 預設 false(opt-in):明碼顯示在高安全情境(如二次驗證 modal)可能是刻意不提供的能力, 故不隨 type="password" 自動開啟,升級不改變既有畫面外觀。
    • 切換狀態由組件內部持有,只改 DOM 的 type attributepasswordtext),不動 props.type,故表單層的資料轉換與驗證完全不受影響——此前應用層在 form 層 Input 上切換 type,該值會流進 useInput 的轉換管線。
    • clearable 及自訂 rightAddon 並存(不像日曆/數字按鈕會被自訂 addon 取代), 因為 revealable 是顯式 opt-in。
    • 新增 nls terms Show password / Hide password
  • 日期時間 picker 新增公開辨識式 props 型別:DatePickerInstantTimeInstantDatePickerPropsLocalDatePickerPropsInstantDateTimePickerPropsLocalDateTimePickerProps,可從 @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-731482 731 亦可還原。 萃取邏輯另匯出為 extractOtp / sanitizeOtp 供應用層重用。
    • onComplete 接縫:輸滿位數時觸發(含貼上一次填滿)。自動送出屬應用層決策,框架不內建 送出;需要「輸滿即驗證、畫面不放送出按鈕」時在此送出即可。
    • 字元集:typenumeric(預設,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
  • MediaInput 新增下載能力(單檔 / 下載全部):hover 媒體區右下角出現下載鈕——gallery 檢視為「下載全部」(打包 zip)、單檔檢視為「下載當前檔」(原檔名)。受保護媒體(經 httpClient 帶 token 抓 blob 顯示)無法用瀏覽器右鍵另存,此鈕填補該缺口;readOnly 仍可下載(純讀取)。進度 / 成功 / 失敗狀態顯示於欄位下方 helperText 位置、訊息後自動消失(取代 helperText、消失後復現),失敗顯示後端 RFC 7807 訊息,任一檔失敗即中止(不產生殘缺 zip)。

    • 共用下載引擎:單檔 / 多檔 zip 下載、失敗即 throw(帶檔名 + 後端錯誤)、進度回報與內嵌狀態列抽為 data-input/file-download 共用模組,由 FileInputMediaInput 共用(單一 JSZip 副本),不重複實作。FileInput 的下載改由此模組驅動(行為不變)。
  • 內層控制項 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-centertracking-*、字級)會經繼承散到浮動 label 與 notch legend 上—— label 被字距撐開、邊框缺口與 label 對不齊,而字級因控制項自帶 text-base 與 DaisyUI .inputfont-size 而根本不生效(典型受害場景:OTP 驗證碼欄位想要置中大字距)。
    • 命名慣例:className = 外層 wrapper、{內層元素}ClassName = 內層控制項,沿用既有 badgeClassName 的元素具名慣例;四個 prop 的 JSDoc 互相交叉引用,避免再誤用。
    • 合併語意:以 cn()(tailwind-merge)接在元件內建 class 之後,故可直接覆寫內建的字級等 衝突 class,不需 ! important。
    • picker 家族自動涵蓋:DatePicker / TimePicker / DateTimePicker / InstantPickerInput 為欄位外殼並 {...rest} 展開,故 inputClassName 直接生效,不需各自轉送。
    • 未變更者:Checkbox / Radio / SwitchclassName 本就直接套在控制項 <input> 上、 RichTextEditorclassName 本就套在編輯器容器(label 為其兄弟節點,不受繼承影響), 皆無此問題;FileInput / MediaInput 無承載輸入文字的內層控制項,不適用。
    • 向後相容:純新增選填 prop;未傳時內層控制項的 class 組法與先前完全一致(不經 tailwind-merge), className 的既有語意與行為不變。同時補上四個元件 className 的 JSDoc,明示其作用於 wrapper。

Feedback (lib/components/feedback/)

  • variantDialog / 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
  • actionHierarchyDialog / 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 覆寫即可,兩者可疊加
  • AlertVariant / Alertvariant prop('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 個檔案各自手貼 daisyUI alert 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。 涵蓋 ModalSheetDialogFileInput 的預覽對話框。density="tight" 的 32px 在滑鼠下夠用,但手指按不準,而覆蓋層的關閉鈕一律貼在 panel 右上角、誤觸代價是 把填到一半的表單或正在看的預覽關掉。只放大命中區、不放大圖示,視覺重量不變。 Alert / Toast 不在此列——那是行內通知,關閉鈕不在角落且 Toast 會自動消失, 放大只會擠壓訊息本文。

  • Modal.description?: ReactNode — 標題下方的補充說明 slot,渲染於內容區頂端、children 之上, 並自動以 aria-describedby 關聯到對話框。此關聯只有元件做得到——呼叫端自行在 children 裡 放一段說明文字,對輔助技術而言只是內容的一部分,讀不出「這是對話框的描述」。 未提供則整段不渲染,對既有用法零影響。

  • Tooltip.className — 提供 panel 樣式出口,可覆寫預設 max-w-xstext-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 UI Dialog instance 支援 right drawer 與 bottom sheet,並以純 CSS 的 ResponsiveSheetPlacement(如 { base: 'bottom', md: 'right' })在 breakpoint 切換, 不需 matchMedia、不重建 children,也不會同時產生兩個 Dialog。

    • placement 僅提供 right | bottom,預設 right;刻意不擴充 left / top,避免在尚無消費 證據時放大公開設計語彙與 class 組合面。
    • sizesm | md | lg | wide | full(預設 md),語意軸隨 placement 改變:right 映射寬度, bottom 映射最大高度;完整 placement × size × breakpoint class 皆以字面 mapping 發布, 供下游 Tailwind 靜態掃描。
    • backdrop、Escape 與關閉鈕共用 onClosecloseDisabled 同時擋掉三條途徑, 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)— 承載自訂內容的模態外殼,childrenfooter 皆為 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 對稱,捲動時邊界清楚
    • sizesm / md / lg / xl,預設 md)1:1 對應 Tailwind max-w-*
    • footer 的排列慣例為次要在左、主要在右,與同版 Dialog 的調整一致
    • 關閉鈕將 raw Close key 交給 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 則把把手屬性交給 renderItemstate.positionHandleProps,由呼叫端自行安置(同該元件不塞勾選欄的決定)。
    • 適用數十至數百列。SortableContext 收全量 id,數千列以上拖曳會變鈍;大資料集請用命令式 API。
    • 新增 i18n keyDrag 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 另新增 isDraggingdropEdge'before' | 'after' | null)。狀態只讀不寫——拖曳期間 不呼叫 onReposition,順序仍只在放開時決定一次。新增 export:RowPositionDragPreviewRowPositionDragStateRowDropEdge
    • 觸控需長按 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.heightstring | number,選填)— 控制整個元件(表格捲動區 + 內建分頁器)的高度; height="100%" 會切到 flex/fill 版面,讓資料區使用 flex-1 min-h-0 捲動、分頁器固定留在底部, 下游不再需要 ResizeObserver 量測剩餘高度或猜測分頁器高度。未提供時維持既有自然高度;既有 maxHeight 保留為「只限制表格捲動區」的 API。heightmaxHeight 同時提供會明確報錯,避免 衝突設定被靜默忽略。

  • rowSeparatorDataTable / 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 皆有)
  • PdfViewer continuous page render error — continuous mode 不再靜默留下空白頁;單頁 canvas render 失敗時顯示與 single mode 一致的可見錯誤狀態,其餘頁面仍可繼續操作。README 同步記錄 pdfjs-dist standard build 的瀏覽器 baseline 跟隨策略與目前實測組合。

  • toolbarActions + PdfToolbarActionPdfViewer)— 宣告式的工具列動作,由 PdfViewer 以 內建工具按鈕的同一組樣式渲染(尺寸、色彩、hover、圖示大小),排在 toolbarSlot 之後、 內建按鈕之前。欄位為 { id?, icon, label, onClick, disabled?, pressed? }label 傳英文 i18n key,框架自動翻譯為 aria-label 與 tooltip。

    • 動機toolbarSlot 只收 ReactNode,消費端要加一顆工具列按鈕就得目測抄內建樣式, 但 pdfViewerControlButtonVariants 並未對外公開——結果必然是尺寸、色彩與 hover 都對不齊。
    • 需要 icon 按鈕時一律用 toolbarActionstoolbarSlot 僅保留給無法表達成動作的客製內容。
  • ReportViewer@appfuse/appfuse-web/components)— 報表使用情境的高階 PDF viewer。以 application-owned discriminated state 呈現產生中、可預覽、失敗與過期狀態;ready 時組合既有 PdfViewer,加入全部/目前頁/連續範圍/指定頁碼的 physical-page selection。公開 ReportArtifactReportPageSelectionReportPrintRequestReportViewerAdapter,讓 Prototype 使用 mock adapter,server 整合版則取得已確認的 pageCount、在同一接縫建立 subset PDF 並呼叫瀏覽器列印;列印準備支援透過 AbortSignal 取消。元件不依賴 server ReportDocument、renderer 或 job Entity。

    • 列印按鈕經 PdfViewertoolbarActions 渲染,與內建工具按鈕樣式一致;應用可用 ReportViewer 自身的 toolbarActions 加入更多按鈕(排在列印之前)。
    • controlsVisibility 未指定時採 'always'(非 PdfViewer'auto'):列印是報表情境的 主要動作,藏在 hover 後面不利於發現;以 viewerProps.controlsVisibility 可覆蓋。
  • fitModePdfViewer'width' | 'page' | 'none'預設 'width')— 基準縮放:頁面先貼合 容器,使用者縮放以此為基準相乘(實際 scale = 基準 × zoom),容器尺寸變動時自動重算。

    • 動機:PDF 頁面的原始寬度(pt)與容器寬度無關,先前恆以 scale=1 渲染,放進抽屜 / side panel(如 512px)必然產生水平捲動,等於抵銷了窄容器的比例優勢
    • 'width':貼合可用寬度(垂直捲動閱讀);'page':整頁貼合;'none':維持原始尺寸 (zoom 即絕對縮放,等同先前行為)
    • single 以當前頁尺寸為基準,continuous 取所有頁的尺寸包絡(混合版面不溢出);旋轉 90° 時以對調後的長寬計算
    • 基準未算出前顯示 spinner,不會先以 scale=1 畫一次再跳動
  • controlsVisibilityPdfViewer'auto' | 'hover' | 'always'預設 'auto')— 工具列與 頁碼導航的顯示策略。'auto' 在有 hover 能力的裝置維持 hover 才顯示、在觸控裝置恆顯示 (觸控沒有 hover 事件,只靠 hover 等於永遠碰不到控制項,使用者只能猜)。判準為 (hover: hover) media query,與 DataTable / VirtualTable 浮動控制項的既有慣例一致。

  • focusIndicatorMediaViewer,預設 true)— 是否繪製自己的鍵盤焦點指示環。內嵌於自身已有 焦點樣式的宿主(如 MediaInput 的欄位外框 + floating label)時設為 false,避免兩個外框疊在 一起(見 Fixed)。

  • MediaThumbnailOptionProps / MediaThumbnailContainerPropsMediaViewer)— thumbnail 模式改採 roving selection 的無障礙契約。renderThumbnail 的 helpers 多了 optionPropsid / role="option" / aria-selected,需展開在縮圖根節點),renderThumbnailContainer 多了第二個參數 containerPropsrole="presentation",需展開在容器根節點)。兩者皆為附加,既有 slot 實作不傳 也能編譯,但不展開就拿不到正確的無障礙語意。

    • 模式與 RichTextEditor 工具列一致:容器是唯一 tab stop,方向鍵移動的是選取而非焦點, 避免每個子項各佔一個 tab stop
  • persistKeyDataTable / VirtualTable)— 欄位版面(欄序 + 顯示/隱藏)的 localStorage 持久化。給了才啟用;使用者調整的版面跨頁面往返 / 重新登入後自動回復,不再每次回到畫面就重設。

    • keyappfuse.table-layout.{persistKey},需在應用內唯一(同 key 的多個表格會互相覆蓋)
    • 優先序:已持久化的版面 > defaultColumnOrder / defaultColumnVisibility 種子
    • 欄位定義變動時自動調和:仍存在的欄保留使用者順序、新欄插回自然位置、已刪除的欄移除, 不因加減一欄就丟掉整份版面
    • 容錯:SSR / 隱私模式 / 配額已滿 / 存檔損毀皆靜默退回預設版面,不讓表格連帶壞掉
    • 每瀏覽器 / 每裝置;要跨裝置同步仍走 onColumnOrderChange / onColumnVisibilityChange 自行存後端 + default* 回種
    • 齒輪的「重設版面」會一併把已持久化的版面重設回自然順序 / 全部可見
  • 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 皆可)
    • 匯出:VirtualListVirtualListPropsVirtualListItemStateDEFAULT_ESTIMATED_ITEM_HEIGHTRowSelectionState 已由 DataTable 匯出,三者共用同一型別)
  • VirtualTable.manualSortingboolean,預設 true)— 與 DataTable 同名同義,補齊兩支的排序 介面一致性。false 時改為元件內排序(註冊 TanStack 的 getSortedRowModel),可用其內建的 型別感知比較器(alphanumeric 自然排序 / datetime / text)、多欄接續邏輯,以及欄位層級的 sortingFn / sortUndefined / sortDescFirst——這些在後端排序時是啞的。 只在資料全部載入時生效data 只有一部分時,元件內排序排的是那一部分,得到的順序不反映 整個資料集,故會退回後端排序並於開發模式說明(onSortingChange 照常觸發)。判定「全部載入」 ——VirtualTable 是未提供 infiniteScrollhasNextPage === falseDataTable 是未提供 paginationtotalPages <= 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 字串(向後相容的簡寫)或 ApiErrorMessageOptionsfallback / fieldLabelPrefix / fieldSeparator / violationSeparator)。 此前各應用專案各自手寫同一份 helper(src/services/api-error.ts),已上收為框架 util
  • ApiErrorMessageOptions — 上述選項型別

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 整列接管)零漂移(對齊 DatePickerCalendarGrid)。純結構抽取、零行為變化
    • Checkbox
    • TagInput — 標籤輸入(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-server calendar 契約),不可選日期灰字(週休類加刪除線)、圖例彙整當月 reasonCode(經 i18n)、description 透傳為 tooltip;元件零行事曆邏輯、不打 API。日期值容忍 string | Datedays[].datevalue/defaultValue)——框架 http client 對無時區的日期(LocalDate / LocalDateTime)保留 ISO 字串、僅將帶時區的 instant 轉為 Date,元件內部對 string | Date 自行正規化,應用層零轉換;base 版 onChange 輸出 ISO YYYY-MM-DD 字串(UI primitive 中性形狀)。onRangeChange 於顯示月份跨出資料涵蓋範圍時通知應用層補載;disabledDate predicate 僅供純前端表單內約束逃生口;renderDay 自訂 day-cell 內容。月曆導航頭點標題可開年/月快速跳選(月選單一次選該年 12 個月、年選單每頁 12 年翻頁),選遠年不必逐月點擊;選單開啟時隱藏「上/下個月」箭頭(避免兩排箭頭);日層附**「今天」**按鈕一鍵跳回今天所在月份。同時匯出底層 CalendarGrid(月曆網格渲染器,day-cell render prop 為未來事件行事曆檢視的擴充縫)、CalendarNav(導航頭 + 年/月快速選單,包住 CalendarGridDatePicker / 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 值(以本地牆鐘拆解顯示)。timeColumnHeader slot 可在時間欄頂部(h-8 與月曆導航列對齊、下方留間距)附掛輔助控制(如 InstantPicker 的時區選單);未用此 slot 時 Hour/Minute 標籤即與月曆導航列(月年/今日/箭頭)對齊、清單緊湊列高向下延伸。popover 主體(月曆+時間欄)抽為共用呈現元件 DateTimePanel(一併匯出),供元件與其 Storybook Design Language 快照共用同一份版型、杜絕漂移。匯出 splitDateTime / combineDateTime 工具與 DateTimeParts / DateTimePanelProps 型別
    • InstantPicker — 絕對時刻(Instant)選擇欄位:UI 承 DateTimePicker(月曆 + 時間欄),但值為帶時區的絕對 Date——使用者選的牆鐘以選定時區解讀成絕對瞬間(對齊後端 InstantDate 送出時由框架 http client 序列化成帶 offset 的 ISO)。時區可由 App 指定並開放使用者選timeZone prop 指定解讀牆鐘的時區(預設瀏覽器時區)、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)。基於 DaisyUI badgewhitespace-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-dist peerDependency)
    • 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。DataTable PaginationRail 的視覺語言(軌道 + 旋轉 90° 小標籤)並於畫面直接標示筆數已載入筆數跟著填充頂端上移、總筆數固定於頂端(=100% 全載入的目標位、接近全載入時淡出讓位)。筆數既於畫面直接標示,指示條不再掛 hover titlerole="progressbar"aria-label 仍保留給螢幕報讀)。無總數時退回純文字顯示已載入數。與 DataTable 位置指示條外觀相同、語意不同——此處是往完成累積的進度填充(已載入 / 總數),DataTable 是不累積的位置(第幾頁)。(DataTableVirtualTable 的齒輪首個動作刻意不同——見上「齒輪動作」。)
      • 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」資料範圍文字);每頁筆數改由右上角齒輪的「每頁筆數」動作提供(下拉選 pageSizeOptionsonPageSizeChange 回調,取代原下方下拉)。
      • 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 時完全惰性;新增 export MswHealthGuardPropsMswHealthGuardRecovery 型別
    • UnderConstruction — 「施工中 / 即將推出」整區佔位元件,供生產前端漸進式上線時,尚未啟用功能的路由渲染一致的佔位畫面(title / description 自動 t() 翻譯,可自訂 icon / action
    • Toast 新增 variant prop('solid' | 'soft' | 'outline',預設 'solid'),對應 daisyUI alert style modifier;MessageToast 同步新增 variant prop 透傳至全域 Toast 訊息;新增 export ToastVariant 型別

Form Integration (lib/form/)

  • 日期時間 picker 新增公開辨識式 props 型別:InstantDatePickerPropsLocalDatePickerPropsInstantDateTimePickerPropsLocalDateTimePickerProps,可從 @appfuse/appfuse-web/form 匯入。
  • 11 個表單集成組件(react-hook-form + 框架 schema)
  • 支援類型轉換(number)、i18n 標籤翻譯、統一錯誤處理;date / datetime-local 保留 ISO 局部字串(LocalDate / LocalDateTime 形狀),不轉 Date
  • Inputtype=date / datetime-local)— 表單值為 ISO 局部字串YYYY-MM-DD / YYYY-MM-DDTHH:mm[:ss]),對應後端 LocalDate / LocalDateTime不轉 Date(無時區的日曆日/鐘面時間塞進帶時區的 Date 是跨時區 off-by-one 根源);顯示端寬容(既有 Date 值亦可格式化顯示)
  • DatePicker(form 版)— useController 綁定的日期選擇:表單值為 ISO YYYY-MM-DD 字串 | null(LocalDate 形狀,即後端 LocalDate 的 JSON 形狀,request 免轉換),不轉 Date;讀取端寬容(既有 Date 值亦可顯示);新增 export useDatePicker hook 與 UseDatePickerHints / UseDatePickerModifiers 型別
  • TimePicker(form 版)— 表單值為 LocalTime 字串 | nullHH:mm / HH:mm:ss,即後端 LocalTime 的 JSON 形狀);新增 export useTimePickerUseTimePickerHints / UseTimePickerModifiers 型別
  • DateTimePicker(form 版)— 表單值為 LocalDateTime 字串 | nullYYYY-MM-DDTHH:mm[:ss],即後端 LocalDateTime 的 JSON 形狀),不轉 Date;讀取端寬容 Date 值;新增 export useDateTimePickerUseDateTimePickerHints / UseDateTimePickerModifiers 型別
  • InstantPicker(form 版)— 表單值為絕對 Date | null(Instant;牆鐘 × 瀏覽器時區),送出由 http client 序列化成帶 offset ISO、對齊後端 Instant;讀取端寬容 ISO 字串(轉 Date 顯示);新增 export useInstantPickerUseInstantPickerHints / UseInstantPickerModifiers 型別

Utils (lib/utils/)

  • ajaxi18ntimeloggercncookieenvironbrowsernumeraltemplate
  • createHttpClient 新增 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() 後仍以 401 ErrorResponse reject(既有錯誤契約不變)。新增 export AuthConfigAuthTokens 型別。解決下游後加的 response 攔截器只能收到 ErrorResponse.status)、拿不到原始 AxiosError.response.status),導致無法乾淨實作 401 refresh 的問題
  • createHttpClientauth 新增 proactiveRefresh 選項(boolean | { skewSeconds }預設開啟false 關閉):在送出請求前解碼 access token 的 JWT exp,若已過期或將在 skewSeconds(預設 30)內過期,先 refresh 再附新 token 送出——常態下省去「注定 401 的請求」往返與後端的合法 401 雜訊。與被動 401 refresh 共用同一個 single-flight(被動仍作為安全網);access token 非可解碼 JWT 時靜默跳過、退回純被動,維持框架中性

Hooks (lib/hooks/)

  • useTimeoutuseInterval
  • useDebounce<T>(value, delay) — 回傳防抖後的值;用於「搜尋框 → async 查詢」(把輸入經 debounce 再當 React Query 的 queryKey,避免每個 keystroke 都打後端)

Messaging (lib/messaging/)

  • 全局消息系統:prompt-barprompt-dialogprompt API

Development Tools

  • Storybook 10、Vitest 3、Playwright 1
  • TypeScript 嚴格模式
  • ESLint 9 + Prettier 3

Design System

  • DaisyUI 5.3 主題系統(30+ 主題)
  • Floating Label 統一模式
  • 語義顏色系統(text-base-contentbg-base-100 等)
  • Addon 插槽模式(leftAddonrightAddon

Tooling / CLI

  • 新增 appfuse-ds-snapshot bin(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 型別
    • ToolbarButtononClick API,內部以 onMouseDown + preventDefault 實作避免 editor 失焦
    • 範例與選擇指引見 lib/components/data-input/rich-text-editor/README.md 的「自訂 Toolbar」段
  • 新增 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

  • CheckboxCheckboxGroup.options[] 新增 icon?: ReactNode,可在 checkbox 方框與標籤文字之間渲染圖示(搭配 lucide-react 等 SVG icon),對齊 Radio 的相同 API;典型場景為設定面板(☑ 📧 訂閱電子報 / ☐ 🌙 夜間模式 / ☑ 🔒 加密儲存)
  • Checkbox.hideLabel 行為微調:仍提供 icon 時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現),對齊 Radio.hideLabel
  • 新增 Checkbox.WithIcon story 示範 4 個帶 icon 的設定切換場景
  • MUI Checkbox 沒有對應 prop(其 icon / checkedIcon 是替換 checkbox 方框本身,不是 label 前的裝飾 icon),AppFuse 的 icon prop 屬於框架便利擴充

Radio

  • RadioRadioGroup.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 的多個 inline Radio 形成 prose 內嵌的單選題(仿 commit 971efd2Checkbox inline 設計)
  • RadioGroup / CheckboxGroup 新增 inline?: boolean 佈局模式
    • 預設 false:既有行為(label 在上、選項在下、direction 控制選項橫直)
    • inline=true:label 與選項排在同一列,自動套用 pt-3 + min-h-[60px](comfortable,對齊 Input bordered 浮動 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 內嵌 inline Radio 同 name 互斥 + 結構化 RadioGroup)、TableEdit(表級全表單選的「主要」欄 + cell 內局部 RadioGroup

Switch

  • Switch 新增 icon?: ReactNode,可在 toggle 與標籤文字之間渲染圖示(搭配 lucide-react 等 SVG icon),對齊 Checkbox / Radio 的相同 API;典型場景為設定面板(🔔 推播通知 / 🌙 深色模式 / 💾 自動儲存 / 🛡️ 加密儲存)
  • Switch.hideLabel 行為微調:仍提供 icon 時圖示不受影響,僅隱藏文字(支援 icon-only 視覺呈現),對齊 Checkbox / Radio.hideLabel
  • Switch 新增 inline?: boolean:wrapper 改為 inline-flex items-center align-baseline mx-[0.25em]<span>,可嵌入 <p> 中文 prose 不打斷段落;與 Radio 不同的是 Switch 每個都是獨立布林(無同 name 互斥),適用使用者協議、權限同意書等「逐項勾選同意」場景。對齊細節採 align-baseline(不沿用 Checkbox / Radioalign-[-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 內嵌 inline Switch 獨立布林 + 結構化 grid 雙向綁定)、TableEdit(表格內聯 Switch 啟用 / 通知 / 自動同步並陳,含依賴關係連動 disabled);新增 WithIcon story 示範 4 個帶 icon 的設定切換場景
  • MUI Switch 沒有對應 icon prop(其 icon / checkedIcon 是替換 toggle thumb 本身,不是 label 前的裝飾 icon),AppFuse 的 icon prop 屬於框架便利擴充

MediaInput

  • MediaInput.stories.tsx 新增 MuiBenchmark story,與其他 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-legend class,並覆蓋兩個易踩雷的預設值:
      • .fieldset 預設 font-size: 0.75rem → 強制 text-base,避免內容文字被壓小
      • .fieldset-legend 預設 font-semibold → 改 font-normal,多 fieldset 頁面視覺更協調
    • legend prop 為 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)/ compactp-3 + text-sm,對齊 input-md);命名與字級皆與 Input / Select / Textarea 對齊,方便整頁「fieldset 與內含表單欄位的 density 一致」設計
    • icon?: ReactNode — legend 前綴圖示,尺寸隨 density 自動對齊字級(comfortable=16px / compact=14px),呼叫端不需手動設定 SVG 寬高
    • divider / section 變體 + string legend + 未提供 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"> 樣板
  • 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)

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 錯誤訊息改為可動態公告的欄位契約InputTextareaSelectTagInputOtpInputCheckboxRadioSwitch、兩種 Group、RichTextEditorFileInputMediaInput 皆以穩定 aria-errormessage 關聯一個從初次 render 起持續存在的 aria-live="polite"aria-atomic="true" 區域;掛載後才出現或更新的驗證錯誤會被公告,清除時 節點仍保留供後續驗證重用。一般欄位由同一節點兼任視覺錯誤與 live region,避免重複文字;群組與 檔案內部驗證也只更新單一公告區。FileInput 的 button trigger 與 MediaInput 的 img/listbox viewport 不使用其 role 不支援的 aria-invalidaria-errormessage,改以合法且支援度較廣的 aria-describedby 指向同一份可見錯誤;欄位狀態仍落在原生 file input。role="alert" 不作為一般 欄位預設,保留給必須打斷目前朗讀的緊急錯誤。另統一 error/helper message wrapper 為 <div>, 並恢復 InputTextarea ghost error 的翻譯一致性。

  • Data Input ARIA public props 補齊OtpInput 新增 idaria-describedbyaria-errormessagearia-invalidSelectTagInputRichTextEditorFileInputMediaInput 新增 aria-errormessagearia-errormessage 依 ARIA 1.2 維持單一 ID:元件有 error 時使用自動產生 的穩定 ID,沒有 error 時保留呼叫端值;可合併多個 ID 的描述仍使用 aria-describedby

  • Tabs 的 xssmmdlg 字級改為 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 正是後端排序的典型用法:元件持有狀態、呼叫端據以重新取資料。 兩支共用新的內部 hook useTableSorting,行為由建構保證一致。

  • DataTable / VirtualTable 的表頭全選鈕改為三態,checked 只代表「整個資料集都被選取」VirtualTableinfiniteScroll.hasNextPage 為 true 時、DataTabletotalPages > 1 時, 全選鈕封頂在 indeterminate——該情境下全選的範圍只到已載入的列/當前頁,宣稱 checked 是不成立的 斷言。資料載完(或只有一頁)後才會達到 checked。連帶 aria-label 據實改為 Select all loaded rows / Select all rows on this page

  • onDeselectAll 交出的函數改為清空整份 rowSelectionDataTable / 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 支援 instantlocal-dateDateTimePicker 支援 instantlocal-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 UTC Z 格式並保留毫秒;LocalDate/LocalTime/LocalDateTime 字串 原樣傳送,不附加或推測時區。無效 Date 會在送出前拋出 RangeError

  • Button variant="soft" 維持下游可客製的靜態透明契約——19.0.0-alpha.71 曾改用 DaisyUI btn-soft 加入常態淡底,會改變既有下游畫面的視覺階層。現恢復以 btn-ghost 為基底:平時無背景、只保留語義文字色,hover / focus 時才填色;下游可透過 className 依所在版面定義淡底、邊界或其他靜態識別。ghost 則維持連互動填色也不內建的客製入口。

  • PdfViewer 預設改為適頁寬度(fitMode='width'——先前預設 scale=1(PDF 原始尺寸)。 升級後同一份 PDF 在既有畫面會以貼合容器寬度呈現、不再水平捲動;縮放(+ / - / 工具列)語意 由「絕對縮放」變成「相對 fit 基準的倍率」(範圍 0.5x–3x 不變)。要保留舊行為傳 fitMode="none"

  • MediaViewer thumbnail 模式的「選取」與「開啟」分離——先前單擊縮圖會同時選取並跳進 single 模式,導致滑鼠使用者無法在縮圖模式下移動選取(一點就離開),「當前選取」對滑鼠形同唯讀。 現在:單擊 / 方向鍵只改選取,雙擊 / Enter 才進 single 模式

    • renderThumbnail 的 helpers 中,onSelect 語意隨之改為「只改選取」,新增 onOpen (選取 + 進 single 模式)。沿用舊行為的 slot 實作需把 onSelect 換成 onOpen
    • 連帶:MediaInput 工具列的刪除鈕不再限定 single 模式,thumbnail 模式同樣渲染並作用於 當前選取的縮圖(與 Delete 鍵同義)——先前該模式下只有 hover 才出現的每張縮圖 X, 工具列沒有任何 Tab 到得了的刪除入口。
  • MediaViewer thumbnail 模式的 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 不變。下游會觀察到的是「下載時欄位下方多了一行狀態訊息」這個執行期行為。
  • DataTable / VirtualTable 選中列新增左緣 3px 強調條(與 VirtualList master-detail 的選中標示一致):原本選中列只有柔和 primary 底色(bg-primary/10),現在加上左緣強調條,選中更明顯。強調條隨 color prop 對應 8 色。實作為 inset box-shadow 套在該列第一個 cell(<tr> 上的 box-shadow / border-left 在 DaisyUI table 不渲染),不推擠版面、與 sticky 欄相容。純視覺增強,無 API 變更。

  • VirtualTable / VirtualListshowScrollToTop 預設由 false 改為 true——虛擬化元件本就為長清單/無限捲動而生,回到頂部是該場景最標準的 affordance,預設關閉不符直覺。按鈕自我隱藏(僅捲離頂部超過 scrollToTopThreshold(預設 400px)且 hover 時才現、觸控裝置恆現),故短清單 / 停在頂部時開著也不打擾。需關閉(如與應用層右下角 FAB 撞位)傳 showScrollToTop={false}

  • DataTable / VirtualTable 修正 sticky 欄(選取 / 序號欄)的列底色接縫——選取 tint 與 hover 底色皆與其他欄一致。DaisyUI table-pin-cols 給 sticky <th> 不透明 bg-base-100(供水平捲動不透光),會蓋住套在 <tr> 上的半透明 tint(bg-primary/10)與 DaisyUI row-hover 的底色,使 sticky 欄比其他欄淺、出現接縫。修正:sticky 欄的 cell 依狀態改套不透明等價色——選中列用 color-mix 算出的 bg-primary/10 over base-100 實色;未選中列 hover 時補 group-hover:bg-base-300(對齊 row-hover)。四種狀態(未選中預設 / hover、選中預設 / hover)各欄底色皆一致。純視覺修正,無 API 變更。

  • DataTable 的分頁 UI 由右側垂直「位置指示條」改為表格下方橫向「分頁器」(整組靠右:每頁筆數下拉 + 範圍/總數 + 首/上/下/末頁),版面與字級(1rem)對齊既有 MUI TablePagination 慣例,便於與舊系統畫面並存。連帶:每頁筆數選擇由齒輪移入分頁器;齒輪首個動作因此改為重設(版面歸位),與 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 覆寫 Button ghost 變體預設的扁平 hover(icon-only 圓鈕無底色就看不出可點、圓形亦不可見)與 density="tight"text-sm(分頁器整體字級為 1rem)。
    • i18n:新增 First page / Last page / Per page 三個 key。既有下游若沿用自己的 nls 副本,需補上這三個 key(未補會退回顯示英文 key);不再使用 Page ${page}(數字頁碼的 aria-label)。
  • VirtualTable 的預設 dateFormatyyyy-MM-dd 改為 yyyy-MM-dd HH:mm(與 DataTable 一致)。兩個表格原本各持一份 default-cell-formatterDataTable 早已在一次更新中把預設改為含時分並附上推理(只有帶時區的 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/DDHH:mm[:ss]YYYY/MM/DD HH:mm[:ss])。 formatValue 因此只影響顯示態、不需可逆
    • 可選性只約束月曆、不約束鍵盤days / disabledDate 標記為不可選的日期在月曆上 仍點不到,但可以鍵入onChange 照常送出(status 一併帶出可選性)。打字途中 攔截會在使用者尚未打完時就搶著改值;可選性由表單驗證與後端 validate() 裁決
    • Impact(行為變更,非編譯錯誤——props 與值形狀完全不變):
      • 點擊欄位不再開啟 popover,改為定位編輯分段;popover 由日曆/時鐘鈕Alt+↓ 開啟(ARIA combobox 慣例)。↑↓EnterSpace 不再開啟 popover—— ↑↓ 已用於增減分段
      • 依賴「點欄位即開月曆」或「Enter 開月曆」的 E2E 測試需改為點日曆鈕或送 Alt+↓
      • 欄位 readOnly 屬性由 truefalse;斷言 input.readOnly === true 的測試需更新 (傳入 readOnly prop 仍照常停用編輯)
    • 已知限制:遮罩以 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 組合、不該預剝
    • 現在:AuthImageMediaViewer 一致,直接把原始 src 交給 httpClient.get(src, { responseType: 'blob' }),URL 前綴一律由 httpClient 的 baseURL 負責
    • Impact:src 應傳與 MediaViewerFileDescriptor.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 洩漏給第三方的唯一防線
    • 之前:isExternalUrlwindow.location.origin(頁面來源) 判定同源。僅在「前端與 API 同源」時才等價於 API 來源;跨源 API 部署baseURLhttps://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
  • Dialog 動作按鈕密度由 compact 改為 comfortable(40px/14px → 48px/16px):對齊框架其餘元件(Button / 表單組件預設即 comfortable)與對話框內文字級(text-base 16px),使阻塞式對話框的主互動——動作按鈕——成為明確的決策焦點(先前按鈕字級 14px 反小於內文 16px)。不另開尺寸 prop:阻塞式對話框宜全 app 一致,極少數需異尺寸的消費端可用公開的 prompt service 自建對話框訂閱者

    • 動作按鈕加入主次階層actions順序即主次——第一項為主要(肯定選項)、其餘為次要,次要一律比主要「降一階」(solid → outlineoutline → ghost),三種 dialog variant 下皆有對比。零 API 變更actions 仍為 string[])。
      • 此慣例非新增、只是把既有語意畫出來MessageDialog 的關閉 / ESC 早已 resolve 最後一項作為否定選項,且 prompt.confirm / prompt.warn 的用例一律是 ['刪除', '取消'] 這種順序——先前所有按鈕同色同樣式,使「哪個是安全選項」完全不可見(破壞性確認尤其危險)。
      • 影響:既有 dialog 的外觀會變(第二顆以後的按鈕改為較弱樣式),行為與 API 不變。若某處刻意把取消放在第一項,主次會顛倒——但那本就與 ESC 慣例相衝,應調整順序。單一 action(如 ['OK'])外觀不變。
      • 次要為 ghost 時補回 hover / focus 底色(Button 的 ghost 變體預設扁平),避免對話框的次要鍵弱到不像可點。
      • 新增 DIALOG_SECONDARY_BUTTON_VARIANT config 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 / RadioGroup inline 同步移除預留的 pt-3min-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-3 band 補償、並把 inline checkbox/radio/switch 的對齊外殼改用 container items-center
    • 例外:RichTextEditorunderlined(MUI Standard)維持原行為
  • 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 元件三變體架構統一對齊 MUIInput / Textarea / Select / TagInput

    • bordered 對齊 MUI outlined:notch(fieldset + legend)邊框缺口、padding-x 14px、label start 對齊;fieldset notch 透過 px-3 / focus px-[11px] 對齊 MUI
    • underlined 對齊 MUI standard:container mt-4 推下、label 浮在 container 頂部、focus 底線中心展開動畫
    • ghost 為 inline 編輯設計:字級行高繼承父容器([font-size:inherit]! leading-tight)、px-[0.5em] 隨字級縮放、wrapper mx-[0.25em] pb-1 保留呼吸與底線距離
    • compact 字級改為 text-[0.9625rem] pointer-coarse:text-base(桌面 15.4px / 觸控 16px,避開 iOS auto-zoom)
    • 配套元件:新增 Tooltiplib/components/overlays/tooltip/)— cloneElement 注入 handlers 不增加 DOM wrapper,ghost variant 的 helperText / error 透過 Tooltip 顯示不打斷文字流
    • 配套 daisyUI:fusion theme --radius-field0.5rem0.25rem(對齊 MUI Material Design)
  • Chip / Badge 樣式對齊 MUI ChipTagInput / Select multiple 使用)

    • 改用 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 對齊
  • Data Input compact 字級與 chip 結構二次校準(supersedes 上方「Data Input 元件三變體架構」與「Chip / Badge 樣式」中相關細節)

    • 七個元件(Input / Textarea / Select / TagInput / FileInput / MediaInput / RichTextEditorcompact floating 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-3 padding(comfortable)/ px-2(compact),唯讀 chip 文字置中(修正 pl-3 pr-[5px] 造成的文字偏右)
      • gap-1.5 對齊 MUI text-to-X 視覺間距
      • X 按鈕重構為雙層結構:外層 size-[22px](compact size-4)透明 hit area,內層 size-[18px](compact size-[14px])可見實心圓 bg-current/15;對齊 MUI deleteIcon SVG 框與可見圓直徑,同時擴大 click target 改善 accessibility
      • -mr-[7px](compact -mr-[3px])負 margin 把 X 按鈕拉回距 chip 右邊 5px,對齊 MUI deleteIcon 位置
    • TagInput / Select underlined 容器加 pt-1 形成對稱 padding,文字中心對齊 Input 元素中點(修正 2.5px 偏上;瀏覽器對單行 input 文字置中時會忽略 asymmetric padding)
    • Select / TagInput / FileInput bordered notch 與 FileInput underlined 補 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),一覽變體 × 密度排列組合
    • 新增 MuiBenchmark story:與 MUI 對應元件(TextField / Autocomplete)並列校準
    • 新增 Select.TableEdit / TagInput.TableEdit story:展示 ghost 在表格 cell 內聯編輯
    • InlineEdit 加入 multiple Select / Input + TagInput 並排示範
  • Input handleClearonClear 提供時優先呼叫,不再重複觸發 onChange(對齊 JSDoc 與既有測試「onClear 優先於 onChange」)

  • Radio 對齊 MUI 視覺語意(仿 commit 971efd2 的 Checkbox 對齊)

    • 色階分層仿 MUI FormLabel vs FormControlLabel 語意:RadioGroup label 用 text-base-content/60(欄位名稱色,同 Input 浮動 label);個別 Radio label 與未選邊框用 text-base-content(欄位內容色,同 Input 文字);error 才轉紅
    • 個別 Radio label 不再隨 color prop 變色(之前 color="primary" 會讓 label 跟著變 text-primary),對齊 MUI FormControlLabel.label 不隨 Radio color 變色行為
    • gap-3gap-1.5(radio↔label 間距,對齊 MUI)
    • RadioGroup 群組錯誤訊息與輔助文字統一用 mt-2(不再依 density 調整 helperMargin),與 CheckboxGroup 一致
  • Switch 對齊 MUI 視覺語意(仿 commit 971efd2 / 402595b 的 Checkbox / Radio 對齊)

    • 個別 Switch label 不再隨 color prop 變色(之前 color="primary" 會讓 label 跟著變 text-primary),永遠使用 text-base-content,僅 errortext-error;對齊 MUI FormControlLabel.label 不隨 Switch color 變色行為

    • gap-3gap-1.5(toggle↔label 間距,對齊 Checkbox/Radio 與 MUI)

    • helperMargin 對應 gap-1.5 重算:compact ml-[calc(26px+6px)]、comfortable ml-[calc(33px+6px)]

    • 之前:compactcomfortable 都使用 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.tsresolveChipColorCHIP_ROTATION_COLORS
  • FileInput chip Popover 內容擴充 + 氣泡外觀

    • Popover 上半新增檔案資訊區(檔名、類型、大小);外框與氣泡尾巴呼應該 chip 顏色
    • 新增 i18n key:TypeSize(下游專案需在 nls/term 補上翻譯)
  • Dropdown.Content 新增 arrow prop(預設 false,不影響既有 dropdown)

    • 開啟後外框呈現對話氣泡外觀,氣泡尾巴指向 trigger(position 決定朝上/朝下、align 決定水平位置)
    • 尾巴顏色透過 border-inherit 跟隨外框邊框色
  • FileInput 新增上傳標誌鈕bordered / underlined variant)

    • 右側顯示 Upload 圖示鈕作為「這是檔案欄位」的視覺標誌(類似 Select 的 chevron),解決無值時看不出是 FileInput 的問題
    • 永遠顯示,readOnly / disabled 時隱藏;ghost(inline edit)不顯示以維持融入文字流
    • 與「下載全部」鈕並排於右側按鈕群組,chip 容器依鈕數動態預留右側空間
    • 視覺變更:升級後 bordered / underlined 的 FileInput 右側會多一個上傳圖示鈕
    • 新增 i18n key:Upload files(下游專案需在 nls/term 補上翻譯)
  • Dialog 動作按鈕的主次階層改以「填充 + 色相」雙軸降階,並改為次要在左、主要在右

    • 次要鍵改中性色:原本主次只降填充solidoutline)、次要沿用 severity 色, 於是 error / warning 對話框會出現「紅底刪除 + 紅框紅字取消」兩個同色鈕互相競爭。 取消是安全的逃生口,語意上不該漆成危險色。現在次要一律 color="neutral" + variant="outline", severity 色只保留給主要動作
    • 渲染順序反轉:由「主要在左」改為 [次要…] [主要](主要鍵置於最右)。 宣告面不變——actions 仍是「主要寫在前」(actions[0] 為主要、MessageDialog 的 關閉 / ESC 仍 resolve 最後一項為否定選項),只有畫面左右順序改變,故無需改 call site。 此舉使 Dialog 與應用層既有的自訂對話框(一律次要在左)一致
    • densitycomfortable(48px)改為 compact(40px)
    • severity="info" 改用 info 色(標題列 bg-base-200bg-info、按鈕 neutralinfo)。 此前五個 severity 中唯獨 Info 不使用自己的顏色而走中性灰,且與兩個孿生元件相衝—— ToastAlert 皆將 Info 對應到 alert-info。該特例無註解說明、亦無設計記錄, 判定為遺留而非設計。現五個 severity 一律「標題列色 = 按鈕色 = severity 色」
    • 視覺變更:升級後所有經 Dialog / MessageDialog / prompt.confirm / prompt.error 顯示的對話框,其動作鍵的顏色、左右順序與高度皆會改變。無 API 變更
  • Dialog / Toast / Alert / Modal 的字級與字重統一為 text-base + font-normal

    • 此前四者各行其是:Dialog 標題 text-lg font-normalModal 標題 text-lg font-semiboldToast / Alert 標題 font-medium;且 Alert 在有標題時把內文降為 text-sm opacity-90Toast 卻不降——同一組訊息面元件出現四種排版處理
    • 層級改由顏色與版面承擔(severity 標題列、邊框、位置、圖示),不由字級字重承擔。 設計語言因此更單純,下游 design-system 也更容易對齊
    • Alert / Toast 的容器另加顯式 text-base——daisyUI 的 .alert 自帶 font-size: .875rem, 未顯式覆寫時標題與內文會繼承成 14px 而非 16px
    • 動作鍵的字級隨 Buttondensity="compact" 一併改為 16px(見下條)
    • 視覺變更:升級後對話框與訊息面的標題不再放大或加粗;Alert 帶標題時內文不再縮小。無 API 變更
  • DataTable / VirtualTabledensity="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_CLASSEStable.config.ts
    • 表頭底色由 bg-base-100 改為 bg-base-300:字級 / 字重 / 文字色與表身一致後,表頭的 識別度全靠表面色,而原本的 bg-base-100 與表身同色、等於沒有區隔。不可用 bg-base-200—— table-zebra 的斑馬列正是該色(實測色值完全相同),表頭會被誤讀成另一條斑馬紋
    • ⚠️ 下游若在 tailwind.css@layer daisyui 覆寫 .table 的字級/字重/顏色,請移除—— 該覆寫會蓋掉元件的 density 字級,使 compactcomfortable 渲染完全相同(此坑在框架自身 與參考實作中都曾實際發生,本版一併清除)。移除後表頭顏色由元件補上,不會變淡
  • Buttondensity 不再改變字級——tight / compact / comfortable 一律 text-base(16px), density 只管高度(32 / 40 / 48px),對齊 Input

    • Input 各密度一律 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" 覆寫 tighttext-sm;本次一併移除該覆寫,行為不變
    • 影響:所有 density="tight" / "compact"Button(含 Dialog / MessageDialog 的動作鍵、 各元件的關閉鈕、表格內操作鈕)字級變大 2px;按鈕高度不變(btn-sm / btn-md 固定), 實測文字未溢出(tight 內容高 30px / 可用 30px)
  • Dialog / Modal 的 Storybook Docs 頁改為 iframe 渲染 storydocs.story.inline: false

    • 兩者為 portal modal(position: fixed),Docs 頁原本把所有 story inline 渲染在同一個文件流裡, 導致每個 open 的對話框蓋滿整頁、背景幕層層相疊,Docs 完全無法閱讀
    • 改為 iframe 後 fixed 定位被限縮在各自的 iframe 內;DesignLanguage 另行加高
    • 僅影響 Storybook 文件呈現,不影響元件行為

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 匯入的 ModalDialog<Modal … ><Dialog … >;型別同名替換
    • Note: 同檔若另有 import { Dialog } from '@headlessui/react'(手刻外殼的遺留)會撞名—— 該類檔案本就應改用框架元件,或將 Headless UI 的匯入別名為 HeadlessDialog。 命名沿革見 ADR-010
  • 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 盤點顯示零直接匯入
  • manualPaginationDataTableProps)— 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: 新增的 getRowId fallback 是「頁面偏移序號」,前端分頁下 data 為全量、索引已是全域 序號,再加一次 offset 會讓 row id 錯亂——此 prop 與新的 row id 邏輯無法並存
  • stripedDataTableProps / 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} 下留著的是 DaisyUI base-content/5 的原生淡線,肉眼近乎無感,語意上更接近「無列區分」
  • 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 匯入。
  • 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
  • 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 契約收斂。
  • VirtualTable / DataTable 的 per-action 檢視控制開關 — 由 showTableActions 統一取代

    • 涵蓋:showColumnVisibilityMenushowDensityToggleshowResetLayoutshowFullscreenonDensityChangeonResetLayout(密度切換與重置版面動作一併移除)
    • 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 同名透傳);import CollapsibleCardCard。不需收合的靜態卡可省略 collapsibledivided 預設 true(如需舊的無分隔外觀傳 divided={false}
  • RichTextEditor 移除 Slate-only 工具函式

    • 影響範圍:直接引用 lib/components/data-input/rich-text-editor/rich-text-editor.utils.tsparse / 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>')
  • Badgevariant='dash'

    • 影響範圍:傳 variant="dash"<Badge> 的下游程式碼
    • 移除理由:與 Toastdash 一併清除,使 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 / MessageToastvariant='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 / MessageDialogvariant='outline'

    • 影響範圍:傳 variant="outline"<Dialog> / <MessageDialog> 的下游程式碼
    • 移除理由:原 variant 軸源自與 Toast 的 API 對稱、非 Dialog 自身需求,三值中 softoutline 皆零消費且兩者視覺幾乎難分。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 回到 solidDialogVariant 型別仍存在,僅不再接受 '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,焦點位於控制項時仍保留元件快捷鍵。

  • DataTableVirtualTable 的 checkbox 選取欄可能被壓窄:選取欄現在保留至少 44px 寬度, 對齊完整觸控命中區;多欄位在窄容器中改由水平捲動承擔空間,不再讓 auto table layout 把 選取欄壓回 checkbox 的視覺寬度。

  • InputTextarea 的 helper 與 error 未連結至 control:兩個元件現在會由 control ID 產生穩定的 helper/error ID,分別透過 aria-describedbyaria-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 行為仍維持不變。

  • VirtualListrowPosition 拖曳實際上無法重排:項目把虛擬化位移(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],而 daisyUI table-pin-cols 的設計是 sticky 欄用 <th>、其餘用 <td>——於是把手欄、選取欄、序號欄 與 stickyFirstColumn 全部沒有線,看起來像從第二欄才開始、沒有對齊。tdth 現在都畫。

  • 框架注入的三個內部欄都比宣告寬度寬一倍以上: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 只是提示,剩餘寬度仍會 分配到各欄——fixedWidthSTICKY_COLUMN_WIDTHS 是下限指引而非保證。 stickyFirstColumn 不在此列:那是使用者的資料欄,內距在那裡確實承擔可讀性。

  • MediaInput 的縮圖排序在觸控上沒有長按門檻:同時註冊了 PointerSensor(距離 8px)與 TouchSensor(長按 200ms),但 PointerSensor 監聽 pointerdown、觸控也走這條,於是距離門檻 永遠先成立、TouchSensor 形同不存在——註解寫的「觸控長按 200ms」從未生效。改為 MouseSensor

    • TouchSensor,滑鼠門檻不變。
  • 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() 只在 鍵存在時才翻譯。

  • DialogFileInput 預覽對話框的關閉鈕 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 現在攔下自身的點擊冒泡(僅 clickmousedown / 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,螢幕閱讀器讀不到無效 狀態,每個下游應用都得在呼叫端重複補救。現在所有具 error prop 的欄位一致遵循:呼叫端明確 指定 aria-invalid 時以呼叫端為準;未指定時只要 error 存在即標記 aria-invalid="true"; 無錯誤時維持未設定(不輸出 aria-invalid="false")。涵蓋 InputTextareaCheckboxRadioSwitchSelectTagInputFileInputMediaInputRichTextEditor,以及 委派 InputDatePicker / DateTimePicker / TimePickerlib/form/ 各表單版元件 (FileInput / MediaInput 的檔案驗證錯誤亦納入判斷)。CheckboxGroup / RadioGroup 的 群組層級 error 會落到每個選項控制項上(群組訊息仍只渲染一則,選項不轉紅)。OtpInput 原已正確,行為不變。

  • Checkboxindeterminate 在點擊後失效、錯誤呈現為未勾選:依 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-renderDataTable / 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 的 onClickonKeyDown 與 ref——改為先執行框架的開關/鍵盤 邏輯,再呼叫 child handler,並同步寫入框架與 child ref。觸發鈕現在可直接以 event.stopPropagation() 避免冒泡到 DataTable row click,不需額外 DOM wrapper;React 19 callback ref 回傳的 cleanup 也會保留。FileInput chip 已改把 stopPropagation 直接放在 trigger,移除示範舊 workaround 的外層 wrapper。

  • DataTable 的分頁器與表格內容之間缺少穩定分隔——PaginationBar 補上 border-t border-base-content/10

    • 原本三種 rowSeparator 都受影響:dividernone 完全沒有分隔;zebra 看似有分隔 其實是偶然——最後一列為偶數列才有底色,奇數筆資料就沒有
    • 線加在分頁器而非表格最後一列:它分隔的是「表格區 / 分頁器區」兩個 UI 區塊,屬於 分頁器的上緣。這樣三種 rowSeparator 都受益,且沒有分頁器時表格最後一列維持無 border (下方沒有相鄰區塊要分隔,那會是懸空的收邊);兩者若並存還會出現相距 py-2 的平行線
    • /10:比 DaisyUI 原生列線(base-content/5)明顯以撐起區塊界線,又比 divider 的 列線(/20)輕,避免在 zebra / none 模式下突兀
    • VirtualTable 無內建分頁器,不受影響
  • Buttonoutlinesoft 在鍵盤焦點時填色比滑鼠 hover 淺——補上與 hover 等價的 focus-visible 變深規則,兩者現在完全一致

    • 根因:DaisyUI 把 .btn 的變深規則掛在 @media (hover:hover){&:hover{…}}:focus-visible 沒有對應規則;而 .btn-outline.btn-ghostsoft 的底)都以 :not(…, :focus-visible, …) 排除選擇器實作「常態透明」,focus 時該條停止套用、--btn-bg 回落到 --btn-color 原色而非 hover 的變深色
    • outline:所有 color 皆受影響(實測 primary:focus L=0.5 / hover L=0.465
    • soft:只有 color="default" 受影響(實測:focus L=0.97 / hover L=0.90)——有色時 softTextColorClasses 自帶的 focus-visible:bg-{color} 直接設 background-color、勝過 --btn-bg 變數,早已把 focus 拉齊 hover,但 default 那格是空字串、無人補救
    • 本次把該式子抽成共用常數,由 solid(原已手寫同式)、outlinesoft 共用,避免三者 各自漂移;ghost 刻意無填色互動故不補,link 走 underline
    • 影響:受影響組合的焦點填色略為變深,與各自的 hover 一致;有色的 soft 行為不變(新常數 對它是 no-op)。color="default"--btn-color 未定義而 fallback 到 --color-base-200, 焦點填色雖已對齊 hover 但仍偏淺,該色的焦點可見度另案處理
  • 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 DatePickerDateTimePicker 把型別不符的欄位值靜默吞成 null——name 現依 picker 模式 限制到相符值型別;執行期錯誤型別或 Invalid Date 會在欄位顯示 inline contract error,不會跨型別 轉換、改寫原始表單值或從 render 丟出例外。只有 nullish 代表缺值。

  • Refresh error 判準不再由下游複製 status === 401:framework 的共用 classifier 僅把 stable refresh-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+FF3 等不與打字衝突者除外)。

  • MediaInput:鍵盤 focus 時出現兩個外框,其中一個壓在 floating label 上——內嵌的 MediaViewer viewport 是 tab stop 卻沒有處理瀏覽器預設 focus ring,鍵盤(:focus-visible) 進入時 Chrome 會在欄位內側再畫一圈藍框,位置正好穿過 notch / label(滑鼠點擊不觸發 :focus-visible,所以只有鍵盤看得到)。MediaViewer 改為一律關閉 UA outline、需要時改畫 主題化 inset ring,並新增 focusIndicator(預設 true)讓宿主關閉;MediaInputfalse (欄位外框與 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-kit useSortableattributes,帶進 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,刪除後仍在原位。
  • MediaInput:觸控裝置無法刪除已上傳的媒體——縮圖的刪除鈕只以 group-hover:opacity-100 顯示,觸控裝置沒有 hover 事件、該鈕永不出現;它又是 tabIndex={-1},鍵盤也到不了,等於功能 不可達。改為 (hover: none) 時恆顯示(與其他元件的 (hover: hover) 判準互補)。

  • 浮動控制項隱藏時仍會吃掉點擊——PdfViewer(工具列 / 頁碼列)、MediaViewer(工具列 / 檔案導航 / 操作環 / 下載鈕)、MediaInput(縮圖刪除鈕)、DataTable / VirtualTableTableActionSpeedDialVirtualTable / 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 設定——無 /api proxy 時為 SPA fallback index.html200 + text/html)、有 proxy 但後端未啟動時為 500有 proxy 且後端已啟動時為 200 + application/json + 真實後端資料。最後一種只有 mock-only marker 分辨得出來,且因請求「成功」, 重試機制連觸發的機會都沒有——這也是「靠 React Query retry 救回攔截失效」這個說法不成立的原因 (重試會喚醒 worker,但喚醒 ≠ 重新註冊 client)。

  • RichTextEditor:整頁載入(deep link / 重新整理)時整棵子樹被 error boundary 接走: 受控同步 effect 以 if (!editor) return 當守衛,但 useEditorStrictMode 重掛載 (及卸載競態)期間會先回傳已 destroy 的舊實例——該實例非 null,其 view / schema 卻已 釋放,editor.getHTML() 於是在 ProseMirror 的 DOMSerializer.fromSchema(null)TypeError: Cannot read properties of null (reading 'cached')。症狀是「從列表點進編輯頁正常、 直接輸入網址或按 F5 就整頁壞掉」,使用者編輯中重新整理會看到錯誤頁。修正:受控同步、 setEditableimageUpload storage 三個 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),由 SelectTagInput 共用,不重複實作
  • 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 讀到尚未更新的舊 valuessetDraft(舊值),把分段 draft 停在舊值(清除時更會洗掉 reset() 剛清空的 draft),編輯態顯示走 draft → 輸入框停在舊值(uncontrolled 尤其明顯,如 Storybook DatePicker/Default)。修正兩處:① useSegmentedMask 於編輯期間、外部值確有值且與 draft 內容不同時把 draft 追平到外部值(空值不覆蓋半途輸入、相同值不觸發迴圈);② 移除 onFocus 內讀舊 values 覆蓋 draft 的 邏輯(同步統一由前者處理),讓清除後的空 draft 得以保留。四個 picker 共用此遮罩,一併修復。

  • Select / TagInput 停用(disabled)降淡樣式從未生效,外觀與 Input 不一致:兩者的容器是 <div>(非表單元素),Tailwind disabled: 變體所需的 :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 的相依),而該值 useMemocolumns prop—— 呼叫端若把 columns 定義在 render 內而未 useMemo(常見寫法,且元件從未要求要 memo),每次 render 都是新參照,於是任何一次 re-render 都會把欄序重設回自然順序。使用者拖好欄位,下一次 狀態更新就白拖。修正:改以欄位 id 的內容比對,且欄位真的變動時採調和而非整份丟棄—— 仍存在的欄保留使用者順序、新欄插回自然位置、已刪除的欄移除(可見性同步調和,丟棄已不存在的欄)。 連帶把 reconcile 由 useEffect 改到 render 期間進行,少一輪串聯重繪。 兩個表格的版面狀態邏輯一併收斂到共用的 useColumnLayout

  • 表單版 <FileInput>lib/form/)未提供 onDownloadAll 時,「下載全部」按鈕完全無作用useFileInput 無條件把 onDownloadAll 包成一個恆為 truthy 的 wrapper 函式傳給 BaseFileInput, 而 BaseFileInputif (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() 明確排除 BlobErrorResponse 被建成空殼(只有 status,detail / violations 全丟)→ getApiErrorMessage 顯示不出後端具體訊息。修正:攔截器在建 ErrorResponse 前,若 data instanceof Blob 先把 JSON 錯誤 body 從 Blob 讀回物件(非 JSON 則退回空物件,與現狀同)。此為通用修正,惠及所有 responseType: 'blob' 的下載請求,非僅 FileInput。401 auto-refresh 不受影響(於 blob 轉換前處理)

  • VirtualTable height 傳百分比時,在非 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-1flex-basis: 0% 仍主導主軸尺寸(h-full 無害、行為不變),在 grid cell / block 內則由 h-full 生效。實測:grid cell 內 0 → 400px,flex column 內維持既有的「填滿剩餘空間」語意。同時把此佈局約束補進 height 的 JSDoc(原本完全沒有文件)

  • VirtualTable showScrollToTop / 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 日期 → response decode() 將其 parseISODate(本地午夜的時間戳);一旦以 UTC 取日期(toISOString().slice(0,10))或跨時區使用即偏移一天,且守衛測試 should reject incomplete ISO 8601 實為紅燈。修正:regex 要求帶 offset/Z——唯有時間軸上的 instant 才轉 DateLocalDate / LocalDateTime 一律留字串。連帶 form Inputtype=date / datetime-local)與 form DatePicker 不再把輸入轉 Date(見上方 Added):表單值即後端 LocalDate / LocalDateTime 的 JSON 字串形狀,寫出免轉換、免時區陷阱;讀回(decode 保留字串)與寫出(encode 原樣送字串)對稱

  • RichTextEditor autoResize(預設開啟)時編輯區塌成只剩一行高:autoResize 內容區同時掛了 inline min-height: <minRows×1.5>em 與 class !min-h-0min-height: 0 !important),而 stylesheet 的 !important 會蓋掉非 important 的 inline style,使 min-height 解析為 0、編輯區塌到內容自然高度(約一行)。改為比照 container 既有作法(!autoResize && densityStyles.container):autoResize 時不套 density 預設 min-h class、純由 inline minHeight 控制,移除 !important 衝突。density 的 min-h 從 densityConfig.content 拆到新欄位 contentMinHeight(僅非 autoResize 時套用)。預設用法 <RichTextEditor … />(autoResize、minRows=3)現正確呈現 ~3 行(4.5em)下限

  • FileInput ghost variant 在多 chip 排滿時,最後一個 chip 會與「下載全部」鈕重疊(chip 容器未為絕對定位的右側按鈕群組預留空間);改以動態 pr 依右側鈕數預留空間修正

  • RadioGroup 群組 label 現在會自動透過 t() 翻譯,與其他表單組件(InputSelect 等)的 i18n 自動翻譯規範一致。先前必須在呼叫端手動 t()

  • RadioGroup.required 不再連帶在個別 Radio label 顯示 *,僅在群組 label 顯示一次(對齊 CheckboxGroup 行為與 MUI FormControl required 設計)。先前每個選項 label 後都會被加上 *

  • Radio 未選取邊框:覆蓋 daisyUI 預設 /20 透明度為 text-base-content(100%),把 radio 視為「欄位內容」一員,與 Input 文字內容色一致

  • Checkbox / Radio / Switch hideLabel 且未提供 icon 時,label 容器使用 sr-only(避免空 label 仍佔 flex item 並 trigger 外層 gap-1.5,在 flex justify-center 的表格 cell 內造成 3px 居中偏移)。有 icon 時仍保留 visible flex container 以實現 icon-only 視覺

  • Checkbox / Radio / Switch inline 模式下,外層 wrapper 為 <span>(避免嵌入 <p> prose 段落時觸發 HTML 違規 <div> cannot be a descendant of <p> 與 React hydration warning),內層 inline-flex items-center,視覺上 input ↔ label 排列方式不變

    • 行為變更:inline 模式下不再渲染 errorhelperText(這兩個訊息在 prose 文字流中顯示本來就會破壞段落結構,應由外圍表單統一呈現)
    • Ref 變更:inline 模式下 ref 指向 HTMLSpanElement;非 inline 模式仍為 HTMLDivElement。下游若有透過 ref 操作 inline Switch/Radio/Checkbox 的需求,需自行用 Ref<HTMLElement> 或 union 型別接收
  • MediaInput / MediaViewer toolbar 與縮圖刪除鈕統一 hover-to-show 行為

    • 先前 MediaViewer 內部 renderNavigationControls / renderImageControls(含 Settings2 expand 與展開後的 Zoom/Rotate/Reset fan)已採「hover 才顯示」模式(透過 hovering React 狀態),但 renderToolbar(Delete / Upload / Toggle Grid / Fullscreen)與 SortableThumbnail 刪除鈕仍永遠顯示,造成不一致
    • 統一修正:renderToolbar 改為 hovering ? 'opacity-100' : 'opacity-0' 與其他控制群一致;SortableThumbnail 刪除鈕 hover:opacity-100group-hover:opacity-100,並在 MediaInput 的內層 positioning wrapper 加 group class 提供 CSS hover 上下文
    • 結果:hover MediaInput 任意處 → toolbar 與縮圖刪除鈕全部淡入;mouse leave → 全部淡出。先前 SortableThumbnailhover: 需要 hover 到(透明的)按鈕本身才出現,使用者幾乎無法觸發
  • MediaInput / MediaViewer 控制鈕尺寸統一為 24px

    • MediaViewerMediaInput 的平移功能 prop 由 scrollable 直接更名為 pannable(預設仍為 true),精確反映其控制的是媒體拖曳平移而非容器捲動;未保留向後相容 alias
    • MediaInput single mode toolbar 的 Delete / Upload 按鈕由 btn-sm(32×32px)改為 btn-xs(24×24px),圖示 w-4 h-4w-3.5 h-3.5
    • MediaInput 的單檔與縮圖刪除按鈕不再固定使用 error / destructive 色彩,改為與 FileInput 等輸入元件一致的中性移除語意;hover 樣式同步採用 toolbar 的 ghost 控制鈕規格
    • 下載能力由 MediaInput 遷移至 MediaViewer:新增預設開啟的 downloadabledownloadAllFileName,single/carousel 下載目前項目、thumbnail 打包全部項目;下載按鈕維持 24×24px 中性 ghost 樣式與 bottom-4 right-4 定位,進度/成功/失敗狀態改由 viewer 左上角疊層呈現。MediaInput 僅轉交設定並沿用相同行為
    • fullscreenable 預設值由 false 改為 true;全螢幕控制由右上 toolbar 收入 Settings 展開選單,固定插在 Settings 與 Rotate Counter-clockwise 的中點,不參與既有扇形操作的角度重分配;縮圖/輪播模式也可透過該選單進入全螢幕,F 快捷鍵維持不變
    • MediaViewermediaViewerControlButtonVariants 基底 btn-smbtn-xs,影響所有控制按鈕(Zoom In/Out、Rotate CW/CCW、Reset、Expand/Collapse Controls、Page Navigation、Play/Pause、View Mode Toggle、Fullscreen Toggle),對應圖示 w-4 h-4w-3.5 h-3.5
    • 統一規格:所有 MediaInput 與 MediaViewer 工具按鈕現為 24×24px,與 SortableThumbnail 縮圖刪除鈕(已是 24px)以及 MUI IconButton sizeSmall(24px)一致
    • 規避方式:若應用層需要保留原本 32px 觸控目標,可透過 className 覆蓋傳入自訂尺寸
  • MediaViewer viewModeResizeObserver 覆蓋 bug

    • 症狀:multiple 模式下使用者點 toggle 切換到 Show Single 後會「閃一下又切回 thumbnail」
    • 根因:響應式 displayMode 的 useEffect 在 deps 中包含 setViewMode,而 setViewModeuseCallback deps 含 viewMode,導致使用者每次 toggle 後 setViewMode reference 變更 → effect 重跑 → 重設 ResizeObserver → 首次 fire 立刻執行 else { setViewMode(initialDisplayMode) } 分支,把剛切到的 single 強制改回 thumbnail
    • 修法:用 constrainedRef 追蹤前一次的「受限狀態」(isSmall || items.length <= 1),只在條件轉換時才動作。初次掛載與 effect 重跑都不主動覆寫使用者選擇——使用者透過 toggle 按鈕的選擇被保留
  • MediaViewer disabled 不阻擋 wheel zoom / mouse drag bug

    • 症狀:MediaInput disabled 時,圖片仍可用滑鼠滾輪或 macOS trackpad 兩指 pinch 手勢(觸發 wheel + ctrlKey)縮放,亦可拖曳平移
    • 根因:MediaViewer.handleWheel / handleMouseDown 未檢查 disabled prop(僅 handleKeyDown 有檢查)
    • 修法:兩個 handler 開頭加 if (disabled) return,與鍵盤一致;readOnly 不影響檢視操作(縮放/平移為檢視輔助而非編輯動作),維持原行為
  • MediaInput / MediaViewer toolbar 視覺統一

    • 修正 ResetRotate Counter-clockwise 共用同一個 RotateCcw 圖示無法分辨的問題:Reset 改用 lucide RefreshCw(完整圓圈雙箭頭,與單向 RotateCw / RotateCcw 半圓箭頭明顯不同)
    • 三邊 toolbar 按鈕 hover 風格統一:
      • 先前 MediaInput Upload(bg-base-100/90 backdrop-blur-sm shadow-lg、無顯式 hover)、MediaViewer ghost variant(bg-base-100/50 hover:bg-base-100/80、無 shadow、無 backdrop blur)、MediaInput Delete(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.solid variant 也補 shadow-md 保持一致
    • 修正 ghost variant hover 時白底白字看不見 bug:daisyUI 在 btn-ghost hover 時會把文字色還原為 btn-primaryprimary-content(白色),與本次強制保持白色背景衝突;於 ghost variant className 補 text-base-content/70 hover:text-base-content 強制覆蓋——ghost variant 上 color prop 對視覺無效果,符合「toolbar overlay 應該中性」的設計直覺
  • MediaInput / MediaViewer single 模式 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 視覺一致
  • MediaInput JSX 結構對齊 Input / Textarea / Select

    • bordered variant 外層 wrapper 新增 pt-3(對齊 Input/Textarea/Select 的浮動 label buffer 慣例);先前外層無 buffer,浮動 label 渲染於外層上緣 −10 ~ −12px 處,在 grid items-start 並排其他 bordered Data Input 時,label 會「凸出 grid row」造成視覺不對齊
    • dc.containerh-[200|280px])由原本套在最外層 wrapper 改為套在內層新增的 positioning wrapper <div className="relative">;fieldset notch / floating label 改為此內層的絕對定位子,外層改為 auto height
    • error / 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-separatefileUpload.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 的後備判斷(向後相容)
  • 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)未套用 client baseURL,瀏覽器改以「頁面 origin」解析相對 URL → 掉了 baseURL 承載的子路徑前綴 → 指向不存在的位址。prepare 走 axios(有套 baseURL)故正常,兩者不一致
    • 修法:依「絕對 vs 相對 URL」二分決定傳輸——相對 URL(自家 API)改走 axios instanceinstance.put),與其他 API 呼叫一視同仁:自動套 baseURL(補回子路徑前綴)、由 request 攔截器附 Bearer、由 response 攔截器統一處理 401 refresh 與錯誤轉 ErrorResponse絕對 URL(presigned,S3/Azure/GCS 等第三方)維持原生 fetch 中性傳輸(不套 baseURLcredentials: 'omit'、不附自家 Bearer,避免洩漏憑證給第三方)。handleFileUpload 攔截器新增「body 本身為 File/Blob 即原樣放行」守衛,作為自家 PUT 重入的遞迴防護
    • 行為變更(自家 API 上傳):PUT 失敗現由 response 攔截器轉為 ErrorResponse(原為通用 Error),並納入 401 自動 refresh 重試——與其他 API 呼叫一致
  • DataTable / VirtualTablestriped={false} 時 sticky 欄 hover 底色比整列深一階

    • 症狀:關閉斑馬紋的表格,滑鼠移到列上時最左的 sticky 欄(選取欄/序號欄/stickyFirstColumn) 明顯比同列其他欄深,像多了一塊色塊;開啟斑馬紋時正常
    • 根因:sticky 欄的 <th> 有不透明底色(table-pin-cols 給 base-100、斑馬偶數列給 base-200), 會蓋住套在 <tr> 上的 hover 色,故 sticky cell 需自行補色;但補色寫死 group-hover:bg-base-300, 只對齊了 DaisyUI row-hover 的斑馬規則——非斑馬表的 hover 實為 base-200 (實測 light 主題:oklch(0.94) vs oklch(0.97)
    • 修法:補色改由 getStickyHoverBgClass(striped) 依斑馬紋分流(斑馬 base-300/非斑馬 base-200), DataTable 與 VirtualTable 共用 shared/table.config.ts 的同一來源

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 匯入的 ModalDialog<Modal … ><Dialog … >ModalProps / ModalSizeDialogProps / DialogSize。 若直接使用過原 Dialog,改為 PromptDialogDialogPropsPromptDialogPropsDialogVariantPromptDialogVariant)。注意同檔若另有 import { Dialog } from '@headlessui/react'(手刻外殼的遺留)會撞名——該類檔案本就 應改用框架元件,或將 Headless UI 的匯入別名為 HeadlessDialog
  • DatePickerDateTimePicker 現以必填 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" 或明確鐘面時間宣告補時策略。
  • 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 事件
  • 下游自有的 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 appfuseUtils feature detection 自動 停用三者,讓新版 framework 成為唯一 refresh lifecycle policy owner
  • DataTable / VirtualTable欄位排序改到齒輪「欄位」面板操作,表頭不再有拖曳把手;欄位順序一併改為純非受控

    • UI 變更:表頭移除拖曳把手(⋮⋮),只剩排序 + 欄名。欄位的顯示 / 隱藏與排序統一收在齒輪的「欄位」面板——每列一個拖曳把手 + 勾選,拖動列重排、點列切換顯示(支援鍵盤:把手 focus → 空白鍵拾起 → 上下移動 → 空白鍵放下)
    • API 變更
      • 移除 enableColumnReordering prop——排序是「欄位」面板的常駐功能,沒有東西要 gate
      • 移除受控 columnOrder prop,改為 defaultColumnOrder(種子;只在首次渲染取值)
    • 影響範圍:以 columnOrder 受控驅動、或以 enableColumnReordering 開關欄位拖拽的下游
    • Impact: 編譯期型別錯誤(columnOrder / enableColumnReordering prop 不存在);倚賴「表頭拖曳」操作的使用者改用「欄位」面板
    • Migration:
      • enableColumnReordering移除(排序恆在「欄位」面板可用)
      • 受控 columnOrder={x}defaultColumnOrder={x}(種子),onColumnOrderChange 保留(非受控下仍會觸發,可持久化;Redux 場景照舊 dispatch)
      • 程式化驅動欄序(切換檢視預設集)→ 用 onTableChange 取得的 table 實例呼叫 table.setColumnOrder(...)
  • DataTable / VirtualTable欄位可見性改為純非受控:移除受控 columnVisibility prop,改為 defaultColumnVisibility(種子;只在首次渲染取值)

    • 影響範圍:以 columnVisibility 受控驅動欄位顯示 / 隱藏的下游
    • Impact: 編譯期型別錯誤(columnVisibility prop 不存在);VirtualTable 先前更是恆受控卻無未控 fallback,未同時傳 columnVisibility + onColumnVisibilityChange 時齒輪隱藏欄完全無效(本次一併修正)
    • Migration:
      • 只想讓使用者自由切欄位 → 移除 columnVisibility prop(零 props 即可運作)
      • 需持久化 → 保留 onColumnVisibilityChange(非受控下仍會觸發),初始值改用 defaultColumnVisibility(例:Redux 場景 columnVisibility={x}defaultColumnVisibility={x},callback 不變)
      • 需初始隱藏某欄 → defaultColumnVisibility={{ colId: false }}
      • 需程式化驅動(切換檢視預設集)→ 用 onTableChange 取得的 table 實例呼叫 table.setColumnVisibility(...)
      • 逐欄禁止隱藏 → column 的 enableHiding: false(不變)
  • RichTextEditor 公開 API 由 Slate Descendant[] 改為 HTML 字串

    • 影響範圍:使用 <RichTextEditor>(base 元件或 form 包裝)並透過 value / defaultValue / onChange 操作內容的下游 applet
    • Impact: 編譯期型別錯誤(Descendant[]string),執行期既有資料若仍為 Slate JSON 字串會以原樣顯示為文字
    • Migration:
      • value: Descendant[] | nullvalue: string | null(HTML,如 '<p>Hello <strong>world</strong></p>'
      • onChange: (value: Descendant[]) => voidonChange: (value: string) => void
      • 移除 import type { Descendant } from 'slate'
      • 移除 EMPTY: Descendant[] 引用,改為 EMPTY: '<p></p>'(從 @appfuse/appfuse-web/components re-export)
      • Form 層:react-hook-form 的 defaultValues 由 '' / Slate JSON 字串改為 HTML 字串,欄位值序列化也直接是 HTML
      • 進階用途(取 Editor 實例):ref.current?.getEditor() 回傳的型別由 Slate Editor 改為 Tiptap Editor,commands API 完全不同
    • 設計取捨:HTML 為跨工具事實標準(後端 sanitize、Email/PDF 模板、SSR、全文搜尋皆受惠),且未來若改換底層引擎不必再破 API。需要 ProseMirror JSON 的進階場景仍可透過 ref.current?.getEditor()?.getJSON() 取得