diff --git a/README.md b/README.md index 10b8c8f..2911b39 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ integer while the caller remains the owner of the ref. The catalog uses one semantic Theme vocabulary for color roles. The main tokens are `:ui-fg`, `:ui-bg`, `:ui-border`, `:ui-muted-fg`, `:ui-disabled-*`, `:ui-button-primary-*`, `:ui-button-secondary-*`, -`:ui-button-ghost-*`, `:ui-checkbox-*`, `:ui-grid-*`, and -`:ui-panel-*`. Applications provide these through ETAF's +`:ui-button-ghost-*`, `:ui-checkbox-*`, `:ui-table-*`, +`:ui-data-grid-error-fg`, `:ui-pagination-muted-fg`, and `:ui-panel-*`. +Applications provide these through ETAF's `etaf-theme-provide`; legacy `:ui-button-color`-style aliases remain accepted for compatibility. `etaf-ui` does not depend on TP or on a renderer-specific palette registry. If an application wants TP's light/dark palette data, the @@ -76,16 +77,41 @@ mouse-face and hit range semantically separate. surface presentation props, and projects the named `header` slot plus the default slot. + +The public Component business props are derived from the definitions and kept +separate from forwarded Host attributes: + +| Component | Business props | +| --- | --- | +| `etaf-label` | `:text`, `:variant` | +| `etaf-button` | `:label`, `:on-press`, `:disabled`, `:ref`, `:class`, `:color`, `:bgcolor`, `:border`, `:padding`, `:font-weight`, `:tab-index`, `:aria-label`, `:use`, `:variant` | +| `etaf-checkbox` | `:checked`, `:label`, `:on-change`, `:disabled` | +| `etaf-panel` | `:title`, `:variant` | +| `etaf-number-input` | `:value`, `:label`, `:on-change`, `:disabled`, `:min`, `:max` | +| `etaf-table` | `:columns`, `:rows`, `:row-key`, `:row-ref`, `:on-row-press`, `:row-selected-p` | +| `etaf-data-grid` | `:controller`, `:columns`, `:row-key`, `:on-row-press`, `:row-ref`, `:row-selected-p`, `:loading-label`, `:error-label`, `:empty-label` | +| `etaf-pagination` | `:controller`, `:previous-ref`, `:next-ref`, `:class`, `:color`, `:bgcolor`, `:border`, `:padding`, `:aria-label` | + + +Every public catalog Component mounts exactly one Host root. All valid Host +attributes that are not consumed as business props—such as `:ref`, `:class`, +and `:aria-label`—are forwarded to that root. This forwarding rule does not +make those attributes business props of every Component. + DataGrid accepts column descriptors, a function-valued `:row-key`, and the -optional `:loading-label`, `:error-label`, `:empty-label`, `:selected-key`, or -function-valued `:row-selected-p`. When `:on-row-press` is supplied, every -row is an interactive button: `:row-ref` is required, must return a non-nil -stable Host reference for each row, and the row receives role `button` and -tab index `0`. `:on-row-press` receives the row. Without `:on-row-press`, rows -have no callback or tab stop. `:row-key` remains a required non-nil stable -scalar identity for retained rows. DataGrid retains one action closure per -row key, so selection or data Range updates do not recreate handlers for -unchanged rows. +optional `:loading-label`, `:error-label`, `:empty-label`, or function-valued +`:row-selected-p`. When `:on-row-press` is supplied, every row is an +interactive button with role `button` and tab index `0`; the callback receives +the row. Without `:on-row-press`, rows have no callback or tab stop. + + +`:row-ref` is optional for DataGrid. When it is omitted on an interactive +grid, DataGrid derives a stable internal Host reference from the row identity +owned by the controller. When it is supplied, the callback owns that identity +and must return a non-nil stable Host reference for every row. `:row-key` +remains a required non-nil stable scalar identity for retained rows. DataGrid +retains one action closure per row key, so selection or Data Range updates do +not recreate handlers for unchanged rows. An integer column `:width` is readable character capacity, not a raw pixel value. DataGrid passes Ebox's native character unit through and reserves one native character between non-final tracks. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5bd9988..d16a597 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,7 +40,8 @@ Emacs 原生 minibuffer。它支持 `:value`、`:label`、`:on-change`、`:min` 目录统一使用语义化的 Theme 色彩 vocabulary,主要 token 包括 `:ui-fg`、`:ui-bg`、`:ui-border`、`:ui-muted-fg`、`:ui-disabled-*`、 `:ui-button-primary-*`、`:ui-button-secondary-*`、`:ui-button-ghost-*`、 -`:ui-checkbox-*`、`:ui-grid-*` 和 `:ui-panel-*`。应用通过 ETAF 的 +`:ui-checkbox-*`、`:ui-table-*`、`:ui-data-grid-error-fg`、 +`:ui-pagination-muted-fg` 和 `:ui-panel-*`。应用通过 ETAF 的 `etaf-theme-provide` 提供这些 token;旧的 `:ui-button-color` 一类 alias 仍兼容。`etaf-ui` 不依赖 TP,也不直接依赖 renderer-specific palette registry;如果应用想使用 TP 的亮/暗 palette 数据,应先通过可选的 @@ -66,14 +67,38 @@ Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil pre `etaf-panel` 支持 `:title`、相同的 surface presentation 属性,并投影命名的 `header` slot 和默认 slot。 + +公共 Component 的 business props 由实际定义导出,并与透传的 Host 属性分开: + +| Component | Business props | +| --- | --- | +| `etaf-label` | `:text`、`:variant` | +| `etaf-button` | `:label`、`:on-press`、`:disabled`、`:ref`、`:class`、`:color`、`:bgcolor`、`:border`、`:padding`、`:font-weight`、`:tab-index`、`:aria-label`、`:use`、`:variant` | +| `etaf-checkbox` | `:checked`、`:label`、`:on-change`、`:disabled` | +| `etaf-panel` | `:title`、`:variant` | +| `etaf-number-input` | `:value`、`:label`、`:on-change`、`:disabled`、`:min`、`:max` | +| `etaf-table` | `:columns`、`:rows`、`:row-key`、`:row-ref`、`:on-row-press`、`:row-selected-p` | +| `etaf-data-grid` | `:controller`、`:columns`、`:row-key`、`:on-row-press`、`:row-ref`、`:row-selected-p`、`:loading-label`、`:error-label`、`:empty-label` | +| `etaf-pagination` | `:controller`、`:previous-ref`、`:next-ref`、`:class`、`:color`、`:bgcolor`、`:border`、`:padding`、`:aria-label` | + + +目录中的每个公共 Component 都只挂载一个 Host root。未被作为 business prop +消费的所有合法 Host 属性(例如 `:ref`、`:class` 和 `:aria-label`)都会透传到 +该 root;透传规则并不意味着这些属性都是每个 Component 的 business prop。 + DataGrid 支持列描述、函数型 `:row-key`,以及可选的 -`:loading-label`、`:error-label`、`:empty-label`、`:selected-key` 或函数型 -`:row-selected-p`。提供 `:on-row-press` 后,每一行都是可交互 button:必须 -提供 `:row-ref`,它对每一行返回非 nil 的稳定 Host ref;行会获得 -`button` role 和 `tab-index 0`,且回调接收该行。没有 `:on-row-press` 时, -行没有回调或 tab stop。`:row-key` 仍然是 retained row 必须具备的非 nil -稳定标量 identity。DataGrid 按 row key 保留唯一 action closure,因此 selection -或 Data Range 更新不会为未变化的行重建 handler。 +`:loading-label`、`:error-label`、`:empty-label` 或函数型 +`:row-selected-p`。提供 `:on-row-press` 后,每一行都是带 `button` role 和 +`tab-index 0` 的可交互 button,且回调接收该行;没有 `:on-row-press` 时,行 +没有回调或 tab stop。 + + +DataGrid 的 `:row-ref` 是可选项。交互式 grid 省略它时,DataGrid 会根据 +controller 持有的 row identity 派生稳定的内部 Host ref;显式提供它时, +该回调拥有 identity,并且必须为每一行返回非 nil 的稳定 Host ref。 +`:row-key` 仍然是 retained row 必须具备的非 nil 稳定标量 identity。 +DataGrid 按 row key 保留唯一 action closure,因此 selection 或 Data Range +更新不会为未变化的行重建 handler。 整数列 `:width` 表示可读字符容量,不是裸像素;DataGrid 原样使用 Ebox 原生字符单位, 并在非末列之间保留一个原生字符间距。 diff --git a/tests/etaf-ui-m0b1-doc-contract-tests.el b/tests/etaf-ui-m0b1-doc-contract-tests.el new file mode 100644 index 0000000..5ca451f --- /dev/null +++ b/tests/etaf-ui-m0b1-doc-contract-tests.el @@ -0,0 +1,108 @@ +;;; etaf-ui-m0b1-doc-contract-tests.el --- M0b1 UI documentation contracts -*- lexical-binding: t; -*- + +;;; Code: +(require 'ert) +(require 'seq) +(require 'etaf-ui) +(declare-function etaf-ui-m0a-component-inventory + "../scripts/etaf-ui-m0a-inventory") +(load-file (expand-file-name "scripts/etaf-ui-m0a-inventory.el" + default-directory)) + +(defconst etaf-ui-m0b1-doc--readmes + '("README.md" "README.zh-CN.md") + "README files governed by the M0b1 etaf-ui documentation contract.") + +(defun etaf-ui-m0b1-doc--contents (file) + "Return FILE contents from the package root." + (with-temp-buffer + (insert-file-contents (expand-file-name file default-directory)) + (buffer-string))) + +(defun etaf-ui-m0b1-doc--component-table-row (component chinese-p) + "Return COMPONENT's exact README table row. + +Use Chinese punctuation when CHINESE-P is non-nil." + (format "| `%s` | %s |" + (plist-get component :name) + (mapconcat + (lambda (prop) (format "`:%s`" prop)) + (plist-get component :observed-business-props) + (if chinese-p "、" ", ")))) + +(ert-deftest etaf-ui-m0b1-readmes-match-runtime-component-manifest () + "Keep the README contract aligned with the runtime-derived inventory." + (let* ((components (etaf-ui-m0a-component-inventory)) + (grid (seq-find (lambda (entry) + (eq (plist-get entry :name) 'etaf-data-grid)) + components))) + (should (= 8 (length components))) + (should + (equal '(controller columns row-key on-row-press row-ref row-selected-p + loading-label error-label empty-label) + (plist-get grid :observed-business-props))) + (dolist (file etaf-ui-m0b1-doc--readmes) + (let ((contents (etaf-ui-m0b1-doc--contents file)) + (chinese-p (string-match-p "zh-CN" file))) + (should-not (string-match-p ":selected-key" contents)) + (dolist (component components) + (should + (string-match-p + (regexp-quote + (etaf-ui-m0b1-doc--component-table-row component chinese-p)) + contents)) + (should (eq 'all-valid-host-attrs + (plist-get component :forwarded-host-attrs))) + (should (eq 'single-host-root + (plist-get component :root-guarantee)))))))) + +(ert-deftest etaf-ui-m0b1-readmes-document-forwarding-and-row-ref-fallback () + "Document the single-root forwarding and optional row-ref contracts." + (dolist (file etaf-ui-m0b1-doc--readmes) + (let ((contents (etaf-ui-m0b1-doc--contents file))) + (should (string-match-p "M0b1: single-host-root" contents)) + (should (string-match-p "M0b1: row-ref-optional" contents)) + (should (string-match-p "M0b1: business-props" contents))))) + +(ert-deftest etaf-ui-m0b1-data-grid-row-ref-current-contract () + "Use a controller fallback when row-ref is nil and honor an explicit ref." + (dolist (explicit '(nil explicit)) + (let* ((source (etaf-data-memory-source '((:id 7 :name "Ada")) + :id-key :id)) + (controller (etaf-data-controller source :auto-load t)) + (buffer-name (generate-new-buffer-name " *etaf-ui-m0b1-row-ref*")) + pressed) + (unwind-protect + (progn + (etaf-mount + buffer-name + (etaf-view + (etaf-data-grid + :controller controller + :columns '((:key :id :label "ID")) + :row-key (lambda (row) (plist-get row :id)) + :row-ref (when explicit + (lambda (row) + (intern (format "documented-row-%s" + (plist-get row :id))))) + :on-row-press (lambda (row) (setq pressed row))))) + (let ((runtime (etaf-runtime-for-buffer buffer-name)) + host-ref) + (maphash + (lambda (ref props) + (when (equal 7 (plist-get props :key)) + (setq host-ref ref))) + (etaf-runtime-host-props runtime)) + (should host-ref) + (when explicit + (should (eq 'documented-row-7 host-ref))) + (etaf-dispatch-event runtime host-ref 'press) + (should (equal 7 (plist-get pressed :id))))) + (when-let* ((runtime (etaf-runtime-for-buffer buffer-name))) + (etaf-unmount runtime)) + (etaf-data-stop controller) + (when-let* ((buffer (get-buffer buffer-name))) + (kill-buffer buffer)))))) + +(provide 'etaf-ui-m0b1-doc-contract-tests) +;;; etaf-ui-m0b1-doc-contract-tests.el ends here