{% 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 %} {% from "ui/table.html" import resize_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 依 import 來源的檔案分節。調整控制項時,預覽與 Jinja 呼叫會一起更新。 每一節都適用兩條規則:參數是封閉列舉,不能傳 class= 或顏色; macro 沒有具名的關鍵字一律變成 HTML 屬性,所以 hx_post="/tasks" 會渲染成 hx-post="/tasks"。 {% endset %} {{ page_header("每個 macro,以及產生它的那行呼叫", intro) }} {# ============================================================== 01 #} {% set c02 %}一個元素、七種 variant、七種 size,而且呼叫端一個 class 都不寫。{% endset %} {% call lesson("button", "01", "Button", c02) %} {% call prose() %}

在 button 中,variant 與 size 是封閉查表, href 會把元素換成 <a> 並拿掉 type, icon_name 接受任何 Lucide 名稱,所以呼叫端不必 import icon。 未知的關鍵字會作為屬性傳遞,不會被拒絕;每一個 hx-* 都是這樣送到元素上。

button_group 依方向有不同的渲染方式。horizontal 是一個有間距的 row。 vertical 是 Basecoat 的 .button-group:按鈕併成一個控制項,共用邊框,沒有間距。

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

有控制項可調的四支 macro 排在前面,其餘各附一個渲染好的範例。

rows 為空時,table 會自行渲染 empty_state, 所以呼叫端不寫 if-rows-else 分支。

avatar 以 name 同時作為 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 只在拖曳時更新它, 少了這一步,第一次繪製顯示的是預設位置,而不是實際的值。

{% 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 %}間距與欄數都是 macro,所以頁面不需要 utility class。{% endset %} {% call lesson("layout", "07", "Layout", c08) %} {% call prose() %}

模板裡的 utility class(例如 grid gap-4)不在內附的 stylesheet 裡, 除非 kit 本身剛好用到它。stack、row 與 grid 以參數接受間距與欄數,每個值都對應到 stylesheet 內有的 class。

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

page_header 收一個 block,那個 block 就是它的動作區: 標題、說明與按鈕在任何寬度下都在同一行。這一頁最上方的 header 就是同一支 macro,沒有動作區。

centered 與 fill 各管一個軸。centered 限制寬度; fill 接下 shell 已經配給 main 的高度,再把子元素放進去, 登入卡片因此落在畫面正中央。兩者都需要時將它們套疊, fill 在外層。fill 在下方沒有預覽,因為卡片沒有固定高度可填滿。

{% 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() %}

這些是外殼頁首的零件,可以單獨 import。在這一頁上,側欄最上方的標記是 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,macro 推導出連接觸發元素與面板所需的其餘 id。{% endset %} {% call lesson("overlay", "09", "Overlay", c10) %} {% call prose() %}

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

觸發元素與面板靠四個相關的 id 互相找到對方。錯一個就會靜默失敗:面板打得開但不會被唸出來, 或者打不開。這裡每支 macro 只收一個 id,並推導其餘三個。

select_menu 與 combobox 把值放在伺服器端預先填好的隱藏欄位裡, 所以重繪這個片段的 swap 不會弄丟選取。select_field 夠用時優先使用它: 原生的 <select> 只有一個元素,也不需要腳本。

兩個都收 multiple=true,此時 selected 收的是一個 list,不再是單一值。隱藏欄位裡放的是 Basecoat 的 JSON 陣列, multiselect_scripts() 由用到這種欄位的頁面載入,在送出時把它改寫成重複欄位: labels=bug&labels=ui。路由宣告 labels: list[str] = Form([])。 沒有載入這支腳本時,欄位會送出一整串 JSON 字串,路由回應 422。

multiple 的 combobox 是為窄欄排版的:篩選框自己佔第一行,chips 在它下方換行, 控制項撐滿整欄,清單錨定在篩選框而不是 chips 的底部,所以連續選取幾個選項時, 游標下的選項不會移動。過長的標籤在清單裡換行,在 chip 裡截斷。

選項在十來個以內時,放在 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 %}你的 base.html 所 extends 的文件模板。這一頁就建立在它之上。{% endset %} {% call lesson("shell", "10", "Shell", c11) %} {% call prose() %}

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

填入 sidebar block 後,版面會變成側邊欄加頂列,外殼並在頁首加上 sidebar_trigger。不需要設定任何旗標。

頁面捲動時,頁首固定在視窗頂端。它使用 position: sticky 而不是 fixed, 所以頂列保留自己的空間,main 不需要額外的內距來避開它。 若填了 sidebar,頂列會橫跨整個寬度,側邊欄從頂列下方開始。 需要頁首留在一般文件流中的頁面(例如嵌入式或列印版面),請以自己的 <header> 覆寫 header block。

{% endcall %} {{ snippet("shell", "你的 base.html,完整版") }} {{ snippet("shell_blocks", "外殼開放的每一個 block,依原始碼順序") }} {% endcall %} {# ============================================================== 11 #} {% set c12 %}適用於一排頁首放不下的導覽,約六個目的地以上。{% endset %} {% call lesson("sidebar", "11", "Sidebar", c12) %} {% call prose() %}

這一頁左邊的側欄就是這支 macro。它是 position: fixed,所以沒有預覽卡片; 產生它的呼叫印在下方。

連結接受路由名稱,圖示接受 Lucide 名稱。open 只設定寬螢幕上的起始狀態。 窄螢幕上側欄是整頁覆蓋的浮層,一律從關閉開始。

關掉 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 分支。

請在迴圈裡以 macro 呼叫渲染每一列。在迴圈裡使用 {% raw %}{% include %}{% endraw %} 會對每一列做一次模板查找並建立新的 context,速度慢 20–30% (jinja-performance.md)。

帶了 sort_url 的欄位會變成可排序表頭,pagination 則放在表格底下。 兩者都不自己組網址:query string 歸路由管,因為只有它知道現在開著哪些篩選。 兩者渲染的都是真的 href,再由 target= 疊上 htmx swap, 所以排序與換頁在關掉 JavaScript 時仍可運作,URL 也可以加入書籤。

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

resizable=true 會在每個有 label 的欄位右緣放一個把手。訪客可以拖曳它, 或是聚焦後按方向鍵;按 Home 或雙擊則把欄寬還原為自然寬度。 欄寬存在 localStorage,以表格的 id 為鍵,所以可調寬的表格必須有 id;重新載入或經過 htmx swap 後,欄寬都會還原。頁面要載入 resize_scripts();沒有載入時把手不會出現,表格就是一般的表格。 CSS 做不到這件事:resize 只作用於帶 overflow 的區塊盒,而表格儲存格不是。

page_size 是一個 <form method="get">,因為單獨一個 <select> 沒有腳本就無法送出。它的 url 不能帶 query string: GET 送出會以表單欄位取代整段 query,所以篩選要放進 keep。 不要保留頁碼;改變每頁筆數後,原本的頁碼就不再有效。

{% 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 指名一開始就打開的分頁。伺服器知道這次請求是為了哪一個, 所以它是一個參數,而不是繪製之後由腳本決定。 這一頁每個 playground 底下的程式碼分頁,用的就是這支 macro。

分頁放在另一個分頁的面板裡時(例如頁面某一區塊內的模式切換),內層那組請使用 variant="segmented"。它渲染成膠囊形分頁列,兩層因此外觀不同。 這裡的 variant 表示形狀,不是 button 或 badge 上那種顏色角色;可用值為 underline(預設)與 segmented, 其他值一律退回預設。

{% endcall %} {{ showcase("tabs", "tabs(items, label, selected) / tab_panel(id)——兩支都收區塊") }} {{ showcase("tabs_segmented", "tabs(items, label, variant=\"segmented\")——放在 tab_panel 裡") }} {% 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), ]) }} {% set d_demo %}同一批 macro 放在一支能跑的 app 裡{% endset %} {% set d_ui %}macro 原始碼——每一個都以自己的簽名註解開頭{% endset %} {% set d_tokens %}每個 variant 背後那張 token 表{% endset %} {{ defs("原始碼", [ ("examples/fjkit-demo/", d_demo), ("src/fjkit/templates/ui/", d_ui), ("src/fjkit/static/src/fjkit.css", d_tokens), ]) }} {% 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. The resizable table is the same. #} {{ reveal_scripts() }} {{ resize_scripts() }} {% endblock %}