{% extends "base.html" %} {% from "_parts.html" import band, defs, lesson, prose, stage %} {% from "ui/data.html" import card, code_block %} {% from "ui/tabs.html" import tab_panel, tabs %} {% from "ui/layout.html" import grid, page_header, stack %} {% from "ui/form.html" import reveal_scripts %} {# Components 的中文版。結構、id、macro 呼叫與腳本掛點必須跟英文版逐字對應—— 頁面骨架是同一份 `base.html` 與同一支 components.js,這裡只有文字不同。 章節就是 `src/fjkit/templates/ui/` 的目錄列表,順序與 `ls` 印出來的一致。 唯一沒有章節的檔案是 `attrs.html`:它是其他每支 macro 轉送多餘關鍵字的管線, app 不會呼叫它,它做的事寫在 01 裡第一個 `hx_*` 關鍵字出現的地方。 cheatsheet 仍然列著它,因為那份清單是按檔案列而不是按章節列。 #} {% set sections = [ {"id": "button", "num": "01", "title": "Button"}, {"id": "data", "num": "02", "title": "Data"}, {"id": "disclosure", "num": "03", "title": "Disclosure"}, {"id": "feedback", "num": "04", "title": "Feedback"}, {"id": "form", "num": "05", "title": "Form"}, {"id": "icon", "num": "06", "title": "Icon"}, {"id": "layout", "num": "07", "title": "Layout"}, {"id": "nav", "num": "08", "title": "Nav"}, {"id": "overlay", "num": "09", "title": "Overlay"}, {"id": "shell", "num": "10", "title": "Shell"}, {"id": "sidebar", "num": "11", "title": "Sidebar"}, {"id": "table", "num": "12", "title": "Table"}, {"id": "tabs", "num": "13", "title": "Tabs"}, {"id": "next", "title": "接著讀"}, ] %} {# 底下兩個共用骨架需要的字串。設一次,在 macro 裡讀——換一個語言只改這幾個。 #} {% set t_preview = "預覽" %} {% set t_write = "你寫的" %} {% set t_emit = "它產生的" %} {% set t_jinja = "那行 Jinja 呼叫" %} {% set t_html = "產生的 HTML" %} {# One playground. `slug` names the section, and `components.js` finds its four panes by that name: a tab strip when the file has more than one macro to offer, a bare control row when it has one, and below them the preview, the two representations and the caption. The tablist and its panels are filled by the script, because the list of macros lives there — a hardcoded list here would be a second source of truth that silently loses a tab the day one is added. #} {% macro playground(slug, tabbed=true) %} {% call card(padded=false) %} {% if tabbed %}
{% else %}
{% endif %} {% endcall %} {% call stage(t_preview) %}
{% endcall %} {% call card(padded=false) %} {% call tabs([{"id": slug ~ "-jinja", "label": t_write}, {"id": slug ~ "-html", "label": t_emit}], label="Representation") %} {% call tab_panel(slug ~ "-jinja") %}{{ code_block("", label=t_jinja) }}{% endcall %} {% call tab_panel(slug ~ "-html") %}{{ code_block("", label=t_html, wrap=true) }}{% endcall %} {% endcall %} {% endcall %} {% endmacro %} {# One showcase = one macro that has nothing to configure. The preview and the code are both filled from `SHOWCASE` in components.js, which reads the same data.json every other preview on the site comes from — so adding one here never means pasting markup. #} {% macro showcase(key, name) %} {% call stage(name) %}
{% endcall %} {% call card(padded=false) %} {{ code_block("", data_showcase_code=key) }} {% endcall %} {% endmacro %} {# A call with no preview beside it — the two files whose live instance is this page rather than something that fits in a card. Same source as everything else: `SNIPPET` in components.js. #} {% macro snippet(key, name) %} {% call card(name, padded=false) %} {{ code_block("", data_snippet_code=key) }} {% endcall %} {% endmacro %} {% block content %} {% call stack(8) %} {% set intro %} 每一支 macro 都有實際渲染的預覽與產生它的那行呼叫,依 macro 所在的檔案分節, 順序與 src/fjkit/templates/ui/ 列出來的一致。 調整控制項時,元件與產生它的那行 Jinja 會一起變。 底下每一個預覽都是 build 時由 fjkit 的 Environment 渲染出來的。 {% endset %} {{ page_header("每個 macro,以及產生它的那行呼叫", intro) }} {# ============================================================== 01 #} {% set c02 %}一個元素、七種 variant、七種 size,而且呼叫端一個 class 都不寫。{% endset %} {% call lesson("button", "01", "Button", c02) %} {% call prose() %}

這一頁上的每一個參數都是封閉列舉。沒有 class= 這個逃生口, 也不能傳色相:挑一個 variant,外觀由 token 層決定。 其餘多傳給 macro 的關鍵字一律變成 HTML 屬性,hx_post="/tasks" 會渲染成 hx-post="/tasks"。做這件轉換的是 ui/attrs.html, 每一支 macro 都把多出來的關鍵字轉送給它,而 app 不會自己呼叫它。 所以 htmx 不必出現在 kit 的任何一個 macro 定義裡, macro 沒聽過的關鍵字也變成屬性而不是報錯。

button 是其他每個檔案都會用到的 macro,也最值得先讀簽名: variant 與 size 都是封閉查表,href 會把元素換成 <a> 並拿掉 type,icon_name 收任何 Lucide 名字, 所以呼叫端不必為了在按鈕上放圖示而 import icon。

button_group 是一個名字底下的兩種元件,控制項會顯示是哪一種: horizontal 是一個 row,間距由呼叫端決定;vertical 是 Basecoat 的 .button-group,它把幾個按鈕併成一個控制項並共用邊框,不能設間距。

{% endcall %} {{ playground("button") }} {% endcall %} {# ============================================================== 02 #} {% set c03 %}負責顯示一個值的檔案:數字、狀態、人、定義。{% endset %} {% call lesson("data", "02", "Data", c03) %} {% call prose() %}

ui/ 裡最大的檔案,也是一般頁面呼叫次數最多的一個。 有控制項可調的四支在下面,其餘各自有一個渲染好的範例。

empty_state 值得先知道。rows 是空的時候, table 會自己渲染它,所以呼叫端不必寫 if-rows-else 那段分支。

avatar 值得先讀簽名。名字同時是 alt 文字與退回時的縮寫, 兩者不會互相矛盾。一旦有圖片承載了名字,縮寫就退出無障礙樹, 否則同一個人會被唸兩次:一次是正常的名字,一次是兩個沒有上下文的字母。

{% endcall %} {{ playground("data") }} {{ showcase("empty_state", "empty_state(title, description, icon_name)") }} {{ showcase("metric_group", "metric_group(items, cols=3)") }} {{ showcase("bullet_list", "bullet_list(tone) / list_item()——兩支都收區塊") }} {{ showcase("caption", "caption(text)——也收區塊,用在說明裡要放連結的時候") }} {{ showcase("inline", "link(label, href) 與 kbd(keys)") }} {{ showcase("item_list", "item_list() 的區塊裡放 item(title, description, clamp)") }} {{ showcase("description_list", "description_list(layout) 的區塊裡放 description_item(term, value)") }} {{ showcase("code_block", "code_block(source, label, wrap)") }} {{ showcase("avatar", "avatar(name, src, size, badge_tone) 與 avatar_group(overflow)") }} {% endcall %} {# ============================================================== 03 #} {% set c04 %}兩個收合元件,沒有一行 fjkit 自己的 JavaScript。{% endset %} {% call lesson("disclosure", "03", "Disclosure", c04) %} {% call prose() %}

collapsible 是原生的 <details>:開合狀態、鍵盤操作與 disclosure 角色 全部來自那個元素,Basecoat 用 CSS 替它加動畫。tooltip 完全畫自一個 data-tooltip 屬性,不存在第二個元素。

這個機制有一個後果:CSS content() 不會進無障礙樹。 tooltip 是提示,不能作為一個控制項唯一的標籤。 tooltip 是控制項身上唯一的文字時,另外給它 aria_label, 否則螢幕閱讀器唸不出這個控制項是什麼。

{% endcall %} {{ showcase("collapsible", "accordion(multiple) / collapsible(summary, open)——兩支都收區塊") }} {{ showcase("tooltip", "tooltip(text, side, align)——區塊是被它包住的元素") }} {% endcall %} {# ============================================================== 04 #} {% set c05 %}五種表達「等一下」「完成」與「這件事失敗」的元件。{% endset %} {% call lesson("feedback", "04", "Feedback", c05) %} {% call prose() %}

這幾個的差別在誰在等、等什麼,挑選也照這條分。alert 是一件留在頁面上的事實。 spinner 與 skeleton 都表示還沒好,差別在知不知道等一下會出現什麼形狀: skeleton 畫得出那個形狀,spinner 畫不出來。dialog 是打斷。 toast 是唯一活得比那個回應更久的一個。

其中兩個已經做好無障礙的決定。alert(variant="destructive") 會拿到 role="alert",那會打斷螢幕閱讀器正在唸的句子;其餘每個 variant 都是 role="status",會等到一個停頓。多行的 skeleton 是一個 即時區域而不是六個,因為六根長條是一件事在載入,不是六件。

{% endcall %} {{ showcase("alert", "alert(title, body, variant, icon_name)——區塊是動作按鈕") }} {{ showcase("spinner", "spinner(size, tone, label, indicator)") }} {{ showcase("skeleton", "skeleton(shape, width, lines)") }} {{ showcase("dialog", "dialog(id, title, description, size, footer)——區塊是內容") }} {{ snippet("toaster", "toaster(align) 搭配 toast(title, description, category)") }} {% endcall %} {# ============================================================== 05 #} {% set c06 %}每一支欄位都收同樣五個參數,而且 error 是取代 hint。{% endset %} {% call lesson("form", "05", "Form", c06) %} {% call prose() %}

每一支欄位 macro 都收同樣的 name、label、hint、 error、id。error 取代 hint 而不是疊加, 同時把控制項標成 aria-invalid。底下的控制項會走過同一份表單的五種樣子: htmx 版、有錯誤的欄位、比較長的控制項、radio 與 select 的取捨,以及放在 fieldset 裡的一組 switch。

target= 決定一份表單是不是 htmx 表單。拿掉它,同一支 macro 就吐出一份 沒有 JavaScript 也能用的普通 POST。

底下這兩支欄位針對自己的機制多做了一件事。range_field 會替第一次繪製 寫好軌道已填滿的那一段:Basecoat 從一個自訂屬性畫它,而且只在拖曳時用 JavaScript 更新, 少了這一步,長條會停在上游的預設值,與旁邊的數字對不上。

{% endcall %} {{ playground("form", tabbed=false) }} {{ showcase("range_field", "range_field(name, label, value, min, max, step, output)") }} {{ showcase("input_group_field", "input_group_field(name, label, start, end)") }} {{ showcase("reveal", "input_group_field(name, revealable=true)——需要 reveal_scripts()") }} {{ showcase("file_field", "file_field(name, label, accept, multiple),以及 form(progress=) 會推動的進度條") }} {% endcall %} {# ============================================================== 06 #} {% set c07 %}1,767 個名字,沒有 sprite sheet,沒有 icon font,一個請求都不用發。{% endset %} {% call lesson("icon", "06", "Icon", c07) %} {% call prose() %}

icon("plus") 把 Lucide 的路徑當成 <svg> 內嵌進去。 它繼承 currentColor,所以放進 primary 按鈕裡的 icon 顏色直接就是對的, 而且不論尺寸都不需要一個網路請求。

大部分 macro 直接收 icon_name=:button、stat、 empty_state、brand、sidebar_link, 所以很少需要自己呼叫 icon。這個站側欄裡的圖示全部由那個參數產生。 底下列出其中一部分,任何 Lucide 名字都可以。

{% endcall %} {% call stage("icon(name, size=16)") %}
{% endcall %} {% call card(padded=false) %} {{ code_block("", id="icon-code") }} {% endcall %} {% endcall %} {# ============================================================== 07 #} {% set c08 %}寫下 grid gap-4 的那一刻,零建置這個承諾就結束了。{% endset %} {% call lesson("layout", "07", "Layout", c08) %} {% call prose() %}

只有元件到不了封閉詞彙表。第一次要把兩張 card 並排就會伸手拿 utility class, 而 fjkit 出貨的那份 stylesheet 裡沒有它。所以間距與欄數也是 macro。 每一個值都是查表,表裡的字串原封不動出現在 kit 的原始碼裡,Tailwind 掃得到。

虛線方塊代表你的內容。調整控制項,底下那行原始碼會跟著變。

這個檔案裡另外三支負責資料進來之前的頁面組成。 page_header 收一個 block,那個 block 就是它的動作區: 標題、說明與一排按鈕在任何寬度下都在同一行,呼叫端不必量任何尺寸。 這一頁最上面那個 header 就是同一支 macro,只是沒有動作要放。

{% endcall %} {% call card(padded=false) %}
{% endcall %} {% call stage(t_preview) %}
{% endcall %} {% call card(padded=false) %} {{ code_block("", id="layout-code") }} {% endcall %} {{ showcase("centered", "centered(width=\"sm\", gap=6)——區塊是子元素") }} {{ showcase("page_header", "page_header(title, description)——區塊是動作按鈕") }} {{ showcase("section", "section(title, description, gap=4)——區塊是子元素") }} {{ showcase("divider", "divider()") }} {% endcall %} {# ============================================================== 08 #} {% set c09 %}頁首的三個零件,以及標示目前位置的路徑。{% endset %} {% call lesson("nav", "08", "Nav", c09) %} {% call prose() %}

這些是外殼的頁首,單獨拆成一個檔案,讓 app 可以只 import 它們而不必 import 那份 它 extends 的模板。其中兩支就在這一頁上:側欄最上面那個標記是 brand, 頁首那個切換鈕是 theme_toggle。

nav_links 收的是路由名稱而不是 URL,所以判斷目前在哪一頁是比對路由, 不是字串比對路徑,URL 多一段 query string 之後仍然正確。 目的地超過大約六個,一排頁首就不夠寬,那時改填外殼的 sidebar block。

breadcrumb 收的是 (label, href) 成對的清單,其中 href 為 none 的那一項 就是當前頁。這個形狀是刻意的:這個元件最常見的錯誤是最後一項連到讀者已經在看的頁面, 而這樣的參數寫不出那個錯誤。

{% endcall %} {{ showcase("brand", "brand(label, href, icon_name | icon_src)") }} {{ showcase("nav_links", "nav_links(request, links)——links 是 (route, label) 的序對") }} {{ showcase("theme_toggle", "theme_toggle()——這顆是活的,會切換這一頁") }} {{ showcase("breadcrumb", "breadcrumb(trail, separator)——href=none 標示當前頁") }} {% endcall %} {# ============================================================== 09 #} {% set c10 %}一個 id 進去,四個出來,而且對不上時不會出聲。{% endset %} {% call lesson("overlay", "09", "Overlay", c10) %} {% call prose() %}

ui/overlay.html 裡的每一個都由 Basecoat 自己的 JavaScript 驅動,外殼已經載了它, 而且它會在 htmx:afterSwap 時重新掃描,所以由 swap 送進來的浮層不需要額外處理就能用。

它們的共同點是 id 接線。一個觸發元素與它的面板靠四個相關的 id 互相找到對方, 錯一個就靜靜失敗:面板打得開但不會被唸出來,或者根本打不開。這裡每支 macro 都只收一個 id 再自己推導其餘的,這就是它們是 macro 而不是一段可複製標記的理由。

select_menu 與 combobox 把值放在一個預先填好的隱藏欄位裡。 上游出貨的是 value="" 再用 JS 寫進去,那會讓任何重繪這個片段的來回弄丟已經選好的值。 這兩個都不如 select_field:原生的 <select> 一開始就是正確的, 而且只用一個元素而不是三十個。

兩個都收 multiple=true,此時 selected 收的是一個 list,不再是單一值。隱藏欄位裡放的是 Basecoat 的 JSON 陣列, multiselect_scripts()(一支腳本,由用到這種欄位的頁面自己載入)在送出時 把它改寫成 HTML 既有的重複欄位寫法:labels=bug&labels=ui。 所以路由那邊維持一般寫法:labels: list[str] = Form([]),不需要自己解析。 忘了載那支腳本時,欄位會送出一整串 JSON 字串並換來一個 422,而頁面看起來填得正確。

放在 fieldset 裡的一組 checkbox 送出的內容完全相同,而且不需要腳本。 選項在十來個以內時它是更好的答案,超過那個數量才輪到這兩個。

{% endcall %} {{ showcase("popover", "popover(id, label, side, align, width)——區塊是內容") }} {{ showcase("dropdown_menu", "dropdown_menu(id, label) 搭配 menu_item / menu_group / menu_separator") }} {{ showcase("select_menu", "select_menu(name, options, selected)") }} {{ showcase("combobox", "combobox(name, options, selected, empty)") }} {{ showcase("select_menu_multiple", "select_menu(name, options, selected, multiple)——需要 multiselect_scripts()") }} {{ showcase("combobox_multiple", "combobox(name, options, selected, multiple)——chips 由 JS 畫") }} {{ showcase("select_menu_field", "visible_label / hint / error——同樣兩支控制項,渲染成 field") }} {{ showcase("drawer", "drawer(id, title, side) 搭配 drawer_trigger(label, target)") }} {{ showcase("command", "command(id, dialog) 搭配 command_group / command_item") }} {% endcall %} {# ============================================================== 10 #} {% set c11 %}唯一一個用 extends 而不是 import 的檔案,也就是你正在看的這一頁。{% endset %} {% call lesson("shell", "10", "Shell", c11) %} {% call prose() %}

shell.html 沒有 macro,也沒有預覽,因為預覽就是這一頁。 它擁有 <head>、深色模式的閃爍防護、資源連結、toaster, 以及 header/main/footer 骨架。它不擁有的部分——品牌、導覽、頁尾文字——都是 block。

其中一個 block 改變的是頁面形狀而不是內容。填了 sidebar, 骨架就變成側邊欄加一條細的頂列。外殼自己會判斷那個 block 是不是空的, 並在頁首補上 sidebar_trigger,所以 app 不必另外設旗標宣告要哪一種版面。

{% endcall %} {{ snippet("shell", "你的 base.html,完整版") }} {{ snippet("shell_blocks", "外殼開放的每一個 block,依原始碼順序") }} {% endcall %} {# ============================================================== 11 #} {% set c12 %}一個後台大約在六個目的地之後就撐不住一排頁首導覽。{% endset %} {% call lesson("sidebar", "11", "Sidebar", c12) %} {% call prose() %}

這一頁左邊那條側欄就是這個檔案。底下沒有放進 card 的預覽: 面板是 position: fixed,複製一份放在這裡會蓋住整頁,不會待在卡片裡。 正在執行的那一個就在左邊,產生它的那段呼叫印在下面。

一樣是路由名稱、用名字指定的圖示、封閉列舉,簽名裡沒有 class 字串。 open 只是桌面的起始狀態:窄螢幕上側欄一律從關閉開始, 因為它在那裡是整頁覆蓋的浮層,而一開啟就被自己的導覽蓋住的頁面是 bug,不是偏好。

關掉 JavaScript 時,伺服器端渲染的 aria-hidden="false" 讓寬螢幕照樣可以導覽, 並在窄螢幕上把浮層藏起來。兩種情況下連結都留在 HTML 裡,爬蟲與閱讀模式仍然看得到。

{% endcall %} {{ snippet("sidebar", "sidebar block,就是這個站填的那一份") }} {% endcall %} {# ============================================================== 12 #} {% set c13 %}一份欄位規格,以及一張清單需要的那兩個控制項。{% endset %} {% call lesson("table", "12", "Table", c13) %} {% call prose() %}

columns 是一串 dict:label,需要時再加 align、 width、sort、sort_url 或 select。 rows 則決定這支 macro 要渲染你的內容還是空狀態。 它收下一份自己並不迭代的 rows 就是為了這件事:呼叫端不必重寫 if-rows-else, 也不會有頁面出貨一張只剩表頭的表格。

重複的 row 是你迴圈裡的一次 macro 呼叫。在那裡用 {% raw %}{% include %}{% endraw %} 等於每一輪多一次模板查找與一份新的 context, 那正是「上手」那一頁的 benchmark 量到的差距。

帶了 sort_url 的欄位會變成可排序表頭,pagination 則放在表格底下。 兩者都不自己組網址:query string 歸路由管,因為只有它知道現在開著哪些篩選。 兩者渲染的都是真的 href,再由 target= 疊上 htmx swap—— 排序與頁碼是一張清單裡最常被收進書籤的兩件事,所以關掉 JavaScript 也必須還在。

{% raw %}{"select": true}{% endraw %} 會在最前面加一欄批次選取。 它送出的就是核取方塊欄位一直以來送出的那種重複欄位, 而 select_scripts() 負責讓表頭那一格勾選整欄。

page_size 是一個 <form method="get">, 這是設計本身而不是實作細節:單獨一個 <select> 在值變更時沒有任何辦法做出反應,而這個 kit 不會替它寫一段腳本。 它的 url 不能帶 query string,因為原生的 GET 送出會用表單自己的欄位 取代整段 query——所以篩選要放進 keep,以 hidden 欄位傳遞。 頁碼永遠不放:它指的是一個位置,而改變每頁筆數會讓每一個位置都不一樣。

{% endcall %} {{ playground("table") }} {% endcall %} {# ============================================================== 13 #} {% set c14 %}兩個 id,傳一次,鍵盤操作由 Basecoat 負責。{% endset %} {% call lesson("tabs", "13", "Tabs", c14) %} {% call prose() %}

哪一個分頁被選取、roving tabindex 與方向鍵全部屬於 Basecoat,外殼已經載好了它。 這支 macro 提供那套行為要讀的 aria 契約:一個 [role="tab"], 它的 aria-controls 指到同名 id 的 [role="tabpanel"]。 手寫這組配對正是元件該省掉的工作。

selected 指名一開始就打開的分頁。伺服器知道這次請求是為了哪一個, 所以它是一個參數,而不是繪製之後由腳本決定。 這一頁每一組控制項底下那兩個表示法,用的就是這支 macro。

{% endcall %} {{ showcase("tabs", "tabs(items, label, selected) / tab_panel(id)——兩支都收區塊") }} {% endcall %} {# ============================================================== next #} {% set lead_next %}十四個檔案,以及把它們全部列在同一頁的那份索引。{% endset %} {% call band("next", "接著讀", lead_next) %} {% call grid(cols=2, gap=4) %} {% set d_sheet %}每一支 macro,一個檔案一張表,含它收不收區塊,以及搭配的 htmx 屬性{% endset %} {% set d_learn %}各個零件怎麼接,以及 htmx 送出什麼{% endset %} {{ defs("文件的其他部分", [ ("速查表", d_sheet), ("上手", d_learn), ]) }} {{ defs("原始碼", [ ("examples/fjkit-demo/", "同一批 macro 放在一支能跑的 app 裡,在 repo 中"), ("src/fjkit/templates/ui/", "macro 原始碼——每一個都以自己的簽名註解開頭"), ("src/fjkit/static/src/fjkit.css", "每個 variant 背後那張 token 表"), ]) }} {% endcall %} {% endcall %} {% endcall %} {% endblock %} {% block page_scripts %} {# The reveal showcase is a working control, so the page opts into its script the same way an app's sign-in page does. #} {{ reveal_scripts() }} {% endblock %}