昨天 Day 27,我們終於開始處理 CUI 的另一條輸出路線:CDN Build。
原本只能在 React 專案裡使用的 UI Kit,現在開始有了可以提供給一般 HTML 頁面的 CSS:
dist/
└── cdn/
├── cui.css
└── cui.min.css
我們也重新整理了 CUI 的架構:
CUI
│
Design Tokens
│
Component Contract
│
┌───────────┴───────────┐
↓ ↓
React Legacy
│ │
React Components HTML + CSS
│ │
└───────────┬───────────┘
↓
Shared Styling
不過,成功產生 CSS 檔案,只代表建置流程可以運作。
真正重要的是:
這份 CSS 能不能在一個完全沒有 React 的網站裡正常使用?
例如一個已經運作多年的網站,可能有自己的 CSS、JavaScript、表單與資料表格。
我們不可能為了使用一套 UI Kit,就要求整個網站重新開發。
因此今天要實際建立一個傳統 HTML 頁面,模擬既有網站導入 CUI 的情境。
看看我們前面建立的 Design Tokens、Component Contract 和 Accessibility Rules,能不能真正跨越 Framework 的限制。
今天的目標不是再增加新的 React Component。
而是驗證:
CUI 是否能在沒有 React、TypeScript 與前端建置工具的頁面中正常運作?
預計完成:
今天先以一般 HTML、CSS 與少量 Vanilla JavaScript 完成整合。
首先建立一個測試目錄:
examples/
└── legacy/
├── index.html
├── legacy.css
└── legacy.js
其中:
index.html
→ 模擬既有網站的頁面
legacy.css
→ 模擬網站原本的樣式
legacy.js
→ 模擬原本的互動程式
另外,我們會載入 Day 27 的產物:
dist/cdn/cui.min.css
整體結構:
CUI Project
│
├── src/
│ ├── components/
│ └── styles/
│
├── dist/
│ └── cdn/
│ ├── cui.css
│ └── cui.min.css
│
└── examples/
└── legacy/
├── index.html
├── legacy.css
└── legacy.js
這個測試頁面不需要引入 React。
也不需要執行 Vite 才能理解它的元件結構。
它就是一般瀏覽器可以讀取的 HTML。
為了驗證 CUI 的相容性,我們先模擬一個有自己樣式的舊網站。
例如:
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1"
>
<title>Legacy Web Demo</title>
<link
rel="stylesheet"
href="./legacy.css"
>
</head>
<body>
<main class="legacy-container">
<h1>申請資料管理</h1>
<p>
這是一個既有網站的測試頁面。
</p>
<button type="button">
原本的按鈕
</button>
</main>
</body>
</html>
再建立:
/* legacy.css */
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f5f5f5;
}
.legacy-container {
max-width: 960px;
margin: 40px auto;
padding: 24px;
}
button {
padding: 8px 16px;
border: 1px solid #999;
border-radius: 4px;
background: #eee;
}
這裡故意保留:
button {
...
}
這種比較廣泛的 Selector。
因為很多既有網站都可能有類似的全域樣式。
我們希望測試:
當 CUI CSS 加進來之後,原本的按鈕會不會突然變成 CUI Button?
接著在 HTML 裡加入:
<link
rel="stylesheet"
href="../../dist/cdn/cui.min.css"
>
完整的載入順序:
<link
rel="stylesheet"
href="./legacy.css"
>
<link
rel="stylesheet"
href="../../dist/cdn/cui.min.css"
>
這樣 CUI CSS 就能在頁面裡使用。
不過要注意,CSS 載入順序會影響 Cascade。
如果兩份 CSS 都設定了同一個屬性,瀏覽器會依照來源、Importance、Cascade Layer、Specificity 與順序決定最終樣式。
因此不能只認為:
CUI CSS 放在最後面,就一定不會發生衝突。
實際上仍然要檢查 Selector 與 CSS Cascade。
昨天我們已經建立了 Button 的公開 Contract:
<button
type="button"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
這裡最重要的是:
data-cui-slot="button"
它告訴 CUI:
這個元素要使用 CUI Button 的樣式。
而:
data-cui-variant="primary"
代表:
Primary Variant
data-cui-size 則負責尺寸。
這樣 HTML 不需要知道:
Tailwind Utility Classes
CVA
React Props
只需要遵守 CUI 的 Component Contract。
data-cui-*?假設我們直接寫:
button {
background: var(--cui-primary);
}
就會影響整個頁面的 Button。
但如果改成:
[data-cui-slot="button"] {
...
}
就只會套用在明確使用 CUI 的元素。
例如:
<button type="button">
原本的按鈕
</button>
<button
type="button"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
CUI Button
</button>
預期:
原本的按鈕
→ 維持原有樣式
CUI Button
→ 使用 CUI 樣式
這讓既有網站可以採取漸進式導入。
不需要一次把所有按鈕都替換掉。
不過,原本網站的全域 CSS 仍然可能影響 CUI Button,因此 data-cui-* 是降低衝突的方法,而不是完全隔離 CSS 的保證。
例如 Legacy CSS:
button {
padding: 8px 16px;
background: #eee;
}
CUI CSS:
[data-cui-slot="button"] {
padding: 10px 16px;
background: var(--cui-primary);
}
在相同來源層級、相同 Importance 的情況下,CUI 的 Attribute Selector 通常具有較高的 Specificity。
因此可以覆蓋一般的元素 Selector。
但如果原本網站有:
.legacy-container button.primary {
background: red;
}
或者:
button {
background: red !important;
}
就可能發生其他覆蓋情況。
所以今天不能只看 Button 有沒有出現。
還要打開瀏覽器 DevTools,檢查:
Computed Styles
Matched CSS Rules
Specificity
Overridden Declarations
確認最後套用的是預期的 CUI 樣式。
接著來處理表單。
我們在前面已經做過:
Field
├── Label
├── Input
├── Description
└── Error
但 Legacy 不會使用:
<Field>
<FieldLabel />
<Input />
</Field>
而是直接寫 HTML。
例如:
<div data-cui-slot="field">
<label
for="applicant-name"
data-cui-slot="field-label"
>
申請人姓名
</label>
<input
id="applicant-name"
name="applicantName"
type="text"
data-cui-slot="input"
required
>
</div>
這裡保留原生 HTML:
label
input
required
因為這些本來就具有語意與瀏覽器行為。
CUI 只負責補上:
Styling
Component Contract
不應該用 JavaScript 重新模擬原生 Input。
這裡值得再次強調。
假設只有:
<input
type="text"
placeholder="請輸入姓名"
>
這不是理想的 Label 寫法。
因為 Placeholder 不應該取代真正的 Label。
所以我們使用:
<label for="applicant-name">
申請人姓名
</label>
<input
id="applicant-name"
name="applicantName"
type="text"
>
這樣可以建立明確的關聯。
而且不需要額外加:
aria-label="申請人姓名"
來重複相同資訊。
因為原生 HTML 已經能完成這件事。
這也是 CUI 一直強調的原則:
Semantic HTML First。
如果欄位需要補充說明,可以使用:
<div data-cui-slot="field">
<label
for="contact-email"
data-cui-slot="field-label"
>
電子郵件
</label>
<input
id="contact-email"
name="email"
type="email"
aria-describedby="contact-email-description"
data-cui-slot="input"
>
<p
id="contact-email-description"
data-cui-slot="field-description"
>
請輸入常用的電子郵件地址。
</p>
</div>
這裡的:
aria-describedby="contact-email-description"
會將 Input 與說明文字關聯起來。
因此 Screen Reader 使用者在操作欄位時,也有機會取得相關補充資訊。
接著模擬表單驗證失敗。
例如:
<div
data-cui-slot="field"
data-invalid="true"
>
<label
for="applicant-email"
data-cui-slot="field-label"
>
電子郵件
</label>
<input
id="applicant-email"
name="email"
type="email"
value="abc"
aria-invalid="true"
aria-describedby="applicant-email-error"
data-cui-slot="input"
>
<p
id="applicant-email-error"
data-cui-slot="field-error"
>
請輸入有效的電子郵件地址。
</p>
</div>
這裡同時有:
Visual State
→ data-invalid
Accessibility State
→ aria-invalid
Error Relationship
→ aria-describedby
不過要注意:
這只是在 HTML 裡展示錯誤狀態。
如果要在實際提交後動態顯示錯誤,仍然需要表單驗證邏輯更新:
Error Message
aria-invalid
aria-describedby
不能只把欄位邊框改成紅色。
表單通常會有成功或失敗訊息。
例如:
<div
data-cui-slot="alert"
data-cui-variant="success"
>
<p data-cui-slot="alert-title">
儲存成功
</p>
<p data-cui-slot="alert-description">
申請資料已更新。
</p>
</div>
這裡使用:
data-cui-variant="success"
對應 CUI 的 Semantic Status。
但這個 Attribute 只負責 CUI 的樣式 Contract。
它不會自動讓 Screen Reader 宣告訊息。
如果訊息是在操作後動態出現,我們還需要考慮:
role="status"
例如:
<div
role="status"
data-cui-slot="alert"
data-cui-variant="success"
>
申請資料已儲存。
</div>
不過實際使用時,要避免每個 Alert 都無條件設定 Live Region。
因為:
一般靜態資訊
和:
操作後需要宣告的狀態變化
並不完全相同。
接著加入資料列表。
例如:
<table data-cui-slot="table">
<caption>
申請資料列表
</caption>
<thead data-cui-slot="table-header">
<tr data-cui-slot="table-row">
<th scope="col">
申請人
</th>
<th scope="col">
申請項目
</th>
<th scope="col">
狀態
</th>
</tr>
</thead>
<tbody data-cui-slot="table-body">
<tr data-cui-slot="table-row">
<td>王小明</td>
<td>資料申請</td>
<td>
<span
data-cui-slot="badge"
data-cui-variant="success"
>
已完成
</span>
</td>
</tr>
</tbody>
</table>
這裡直接使用:
table
caption
thead
tbody
th
td
而不是用一堆 div 模擬 Table。
因為 HTML Table 本身就有成熟的語意結構。
CUI 的 CSS 只需要讓它符合 Design System 的視覺規範。
如果只考慮視覺,我們可能會覺得:
<div class="row">
<div>申請人</div>
<div>申請項目</div>
</div>
也可以做出表格。
但真正的 Table 需要讓使用者理解:
Column Header
Row
Cell
因此在適合使用資料表格的情境下,原生 <table> 通常是比較合理的選擇。
例如:
<th scope="col">
申請人
</th>
這樣就能明確標示欄標題。
這也證明:
CUI 的 Accessibility 不應該只存在於 React Component 裡。
即使換成一般 HTML,語意規範仍然要保留。
接著會遇到一個常見問題。
桌面上的 Table:
申請人 | 申請項目 | 狀態 | 操作
可能很正常。
但手機螢幕比較窄。
如果直接讓表格超出畫面,就可能造成水平溢出。
所以可以使用:
<div data-cui-slot="table-container">
<table data-cui-slot="table">
...
</table>
</div>
搭配:
[data-cui-slot="table-container"] {
max-width: 100%;
overflow-x: auto;
}
讓表格在必要時可以水平捲動。
不過要注意,這不是所有 Table 的唯一解法。
如果資料非常複雜,也可能需要重新思考手機版的資訊呈現方式。
今天先以保留原生 Table 語意為優先。
目前我們已經有:
Button
Input
Alert
Table
但如果按下:
儲存
完全沒有反應,就還不能算是一個完整的整合示範。
所以接著加入少量 Vanilla JavaScript。
例如:
<form id="application-form">
<label for="applicant-name">
申請人姓名
</label>
<input
id="applicant-name"
name="applicantName"
data-cui-slot="input"
required
>
<button
type="submit"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
</form>
<div
id="save-status"
role="status"
aria-atomic="true"
></div>
接著:
const form = document.querySelector("#application-form");
const status = document.querySelector("#save-status");
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!form.reportValidity()) {
return;
}
status.textContent = "資料已通過驗證。";
});
這個示範沒有真的把資料送到伺服器。
所以我們不能直接顯示:
儲存成功
而是先用:
資料已通過驗證
確認原生表單行為與 CUI 樣式可以一起運作。
這裡還有一個重要原則。
例如:
<button type="submit">
儲存
</button>
本身就有原生 Submit Behavior。
我們不需要把它改成:
<div onclick="submitForm()">
儲存
</div>
因為這樣反而需要額外處理:
Keyboard
Focus
Role
Accessible Name
所以 Legacy 整合時,能使用原生 HTML,就優先使用。
CUI 的目標不是把原生能力全部重新實作一次。
接著來看比較麻煩的元件。
Day 23 我們做了 Tooltip。
它需要考慮:
Hover
Focus
Escape
Pointer Movement
如果只用 CSS:
.trigger:hover .tooltip {
display: block;
}
看起來可以完成基本效果。
但它不一定能完整處理:
Escape Dismiss
Keyboard Focus
Tooltip Relationship
Pointer Hover Persistence
所以對 CUI 來說,不應該把一個只有 Hover 的提示框,直接宣稱成完整的 Accessible Tooltip。
如果只是提供重要說明文字,很多時候直接顯示在頁面上反而更好。
而真正需要 Tooltip 的情境,未來可以由 Vanilla JavaScript Adapter 處理。
<dialog>相比 Tooltip,Dialog 有一個很值得考慮的原生 HTML 元素:
<dialog>
例如:
<button
type="button"
id="open-dialog"
data-cui-slot="button"
data-cui-variant="outline"
>
查看說明
</button>
<dialog
id="help-dialog"
aria-labelledby="help-dialog-title"
data-cui-slot="dialog-content"
>
<h2 id="help-dialog-title">
使用說明
</h2>
<p>
請確認資料後再送出申請。
</p>
<button
type="button"
id="close-dialog"
data-cui-slot="button"
data-cui-variant="secondary"
>
關閉
</button>
</dialog>
搭配:
const dialog = document.querySelector("#help-dialog");
document
.querySelector("#open-dialog")
.addEventListener("click", () => {
dialog.showModal();
});
document
.querySelector("#close-dialog")
.addEventListener("click", () => {
dialog.close();
});
原生 <dialog> 搭配 showModal() 已經具備不少 Modal 基礎能力。
例如:
Top Layer
Background Inertness
Modal Focus Behavior
Escape Close
不過還是要實際確認:
Accessible Name
Initial Focus
Focus Return
Close Behavior
不能只因為用了原生 <dialog>,就認為所有情境都已經處理完成。
cui.js?這裡就會碰到架構上的選擇。
我們現在有:
cui.min.css
未來是否需要:
cui.js
讓 Legacy 開發者可以直接使用:
<script src="/assets/cui.js"></script>
我覺得可以,但需要先定義範圍。
例如:
CUI JavaScript Adapter
│
├── Dialog
├── Tooltip
└── Other Interactive Components
不過它不應該只是把 React Component 的 JavaScript 打包後直接丟給 Legacy。
因為 React Component 仍然依賴 React Runtime 與其元件模型。
比較合理的方式是:
Shared Contract
│
├── React
│ └── Base UI
│
└── Legacy
└── Vanilla JS Adapter
兩邊遵守相同的 Accessibility Requirements,但使用各自適合的實作方式。
這裡有一個風險。
如果我們在 Legacy Adapter 裡重新定義:
Button Variants
Dialog Styles
Tooltip Colors
就很容易變成:
React UI Kit
+
Legacy UI Kit
兩套系統各自發展。
這就偏離 CUI 原本的目標了。
所以 JavaScript Adapter 應該盡量只負責:
Behavior
State
Focus Management
Event Handling
而:
Design Tokens
Visual Styles
Component Contract
仍然來自 CUI 的共用定義。
這樣才能維持 Single Source 的精神。
經過今天的測試,可以把 Legacy 支援分成兩類。
| Component | Legacy 使用方式 | 額外 JavaScript |
|---|---|---|
| Button | HTML + CUI CSS | 一般按鈕不需要 |
| Badge | HTML + CUI CSS | 不需要 |
| Alert | HTML + CUI CSS | 靜態訊息不需要 |
| Input | HTML + CUI CSS | 原生輸入不需要 |
| Table | HTML + CUI CSS | 靜態表格不需要 |
| Separator | HTML + CUI CSS | 不需要 |
| Tooltip | HTML + CSS + Behavior | 通常需要 |
| Dialog | 原生 <dialog> + CUI CSS |
需要開關控制 |
| Select | 原生 <select> + CUI CSS |
原生選單不需要 |
這樣也讓我們知道,Legacy 支援不一定要一開始就把所有互動元件完整移植。
可以先從最常見、最穩定的 HTML 元件開始。
我認為主要有幾個方向。
第一,CSS 衝突。
原本網站可能有:
button {}
input {}
table {}
甚至還有大量使用 !important 的樣式。
所以需要檢查 CUI 的 Selector 是否能維持預期效果。
第二,HTML 結構不同。
React Component 可以控制內部 DOM。
但 Legacy HTML 是由開發者自行撰寫。
如果結構不符合 Contract,樣式或語意就可能不正確。
第三,JavaScript 衝突。
原本網站可能已經有自己的 Event Listener、Dialog 或其他互動程式。
所以不能隨便讓 CUI 自動接管所有元素。
第四,Accessibility 落差。
CUI 提供的 CSS 不會自動修正原本 HTML 的語意問題。
例如:
<div data-cui-slot="button">
送出
</div>
即使樣式正確,也不等於它具有原生 Button 的操作能力。
今天可以先整理一份檢查清單。
<button>
經過今天的整合,我們可以重新檢查 CUI 的架構。
CUI
│
Shared Contract
│
┌────────────┼────────────┐
↓ ↓ ↓
Design Tokens Styles Accessibility
│ │ Requirements
└────────────┼────────────┘
│
┌───────┴───────┐
↓ ↓
React Legacy
│ │
Components HTML + CSS
│ │
Base UI Native HTML
│ │
└───────┬───────┘
↓
Consistent Usage
這裡的 Consistent Usage,不是指兩邊的程式碼完全一樣。
而是:
相同的語意原則
相同的 Design Tokens
相同的 Component Contract
相同的 Accessibility Requirements
這才是我們真正希望做到的事情。
完成整合後,先執行:
npm run build:cdn
確認 CDN CSS 已經更新。
接著啟動一個靜態檔案伺服器,例如:
npx http-server . -p 8080
再開啟:
http://localhost:8080/examples/legacy/
這樣就能以一般網站的方式載入測試頁面。
比直接使用 file:// 開啟,更接近實際部署情境。
接著測試:
Mouse
Keyboard
Responsive
Screen Reader
並透過 DevTools 確認 CSS 是否正確載入與套用。
先確認 CDN Build:
npm run build:cdn
再執行:
npm run build
npm run lint
npm run check:contrast
最後:
git status
git diff
今天的 Branch 可以使用:
git switch -c feat/legacy-integration
完成後:
git add .
git diff --staged
git commit -m "feat: add legacy HTML integration example"
這樣我們就有了一個可以持續測試的 Legacy Integration Example。
今天我們終於讓 CUI 真正離開 React。
不再只是:
<Button variant="primary">
儲存
</Button>
而是可以在一般 HTML 裡使用:
<button
type="button"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
而且我們不只確認了 CSS 能不能顯示。
也開始檢查:
CSS Compatibility
Semantic HTML
Keyboard Navigation
Responsive Behavior
Accessibility
這次的整合也讓我更確定:
UI Kit 的可重用性,不只是 Component 能不能被 Import,而是它能不能在不同環境裡維持一致的設計與使用規範。
React 有 React 的優勢。
傳統 HTML 也有原生 HTML 的優勢。
CUI 不需要強迫所有環境使用同一種技術。
而是提供一套可以跨環境遵守的 Contract。
這樣即使是沒有 React 的網站,也能逐步使用 CUI 的 Design Tokens、Component Styles 與 Accessibility Guidelines。
當然,目前還不是所有元件都已經具備完整的 Legacy 支援。
尤其是 Tooltip、Dialog 等互動元件,仍然需要進一步設計與驗證。
但至少今天,我們已經完成一個重要的里程碑:
CUI 不再只是 React UI Kit,而是開始具備跨環境使用的能力。
到目前為止,CUI 已經有:
React Components
Storybook
Accessibility Testing
CDN CSS
Legacy HTML Example
這些成果主要都是為了讓開發者更容易使用 UI Kit。
但現在還有另一種使用者。
AI。
例如我們在使用 AI Coding Assistant 時,可能會請它:
幫我建立一個表單
幫我新增一個 Dialog
幫我建立資料列表
但 AI 不一定知道:
CUI 有哪些元件?
哪些 Variants 可以使用?
Field Error 應該怎麼處理?
Dialog 的 Focus Management 有哪些要求?
React 和 Legacy 的使用方式有什麼不同?
如果沒有提供明確的規範,AI 很可能會自行產生另一套 Button、Input 或 Dialog。
所以明天,我們要把前面建立的 Component Contract 與 Accessibility Rules,整理成 AI 可以閱讀與遵守的文件。
Day 29:
AI Skill:讓 AI 也能正確使用 CUI。