feat(ui): add official component catalog

Provide the optional official Component catalog with controlled Button, Checkbox, Label, Panel, and DataGrid implementations plus public-path tests and bilingual usage documentation.
This commit is contained in:
Kinneyzhang 2026-08-05 06:59:22 +08:00
commit 4497121162
6 changed files with 382 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.elc
tests/*.elc

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
EMACS ?= emacs
LOAD_PATH = -L . -L ../etaf -L ../emacs-box
.PHONY: all compile test check checkdoc load clean
all: check
compile:
rm -f *.elc tests/*.elc
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \
--eval '(load-file "etaf-ui.el")' --eval '(byte-compile-file "etaf-ui.el")'
test: compile
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \
-l tests/etaf-ui-tests.el -f ert-run-tests-batch-and-exit
load: compile
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(require (quote etaf-ui))' \
--eval '(princ "etaf-ui load OK\n")'
checkdoc:
$(EMACS) -Q --batch --eval '(progn (require (quote checkdoc)) (dolist (file (directory-files "." t)) (when (string-suffix-p ".el" file) (checkdoc-file file))))'
check: checkdoc compile test
clean:
rm -f *.elc tests/*.elc

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# etaf-ui
`etaf-ui` is the official ETAF Component catalog. It is a normal optional package above `etaf`; it does not add a second Control or Widget model. Button, Checkbox, Label, Panel, and DataGrid are all ordinary Components with the same View, props, slots, events, and Data contracts.
```elisp
(require 'etaf-ui)
(let ((done (etaf-ref nil)))
(etaf-view
(etaf-checkbox
:checked (etaf-value done)
:label "Done"
:on-change (lambda (next) (setf (etaf-value done) next)))))
```
Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` from this directory.

16
README.zh-CN.md Normal file
View File

@ -0,0 +1,16 @@
# etaf-ui
`etaf-ui` 是 ETAF 的官方 Component 目录,是建立在 `etaf` 之上的可选独立包。它不增加第二套 Control 或 Widget 模型Button、Checkbox、Label、Panel、DataGrid 都是使用同一套 View、属性、slot、事件和 Data 契约的普通 Component。
```elisp
(require 'etaf-ui)
(let ((done (etaf-ref nil)))
(etaf-view
(etaf-checkbox
:checked (etaf-value done)
:label "Done"
:on-change (lambda (next) (setf (etaf-value done) next)))))
```
在该目录运行 `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`

189
etaf-ui.el Normal file
View File

@ -0,0 +1,189 @@
;;; etaf-ui.el --- Official ETAF Component catalog -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;; Author: ETAF contributors
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1") (etaf "0.1.0"))
;; URL: https://github.com/ginqi7/etaf-ui
;;; Commentary:
;; The official ETAF catalog is one ordinary Component library. It does not
;; expose a parallel Control/Widget taxonomy: a DataGrid is a compound
;; Component built from the same View, props, slots, events, and Data APIs.
;;; Code:
(require 'cl-lib)
(require 'etaf)
(declare-function etaf-data-status "etaf-data" (controller))
(declare-function etaf-data-items "etaf-data" (controller))
(declare-function text "etaf-view" (&rest arguments))
(declare-function row "etaf-view" (&rest arguments))
(declare-function column "etaf-view" (&rest arguments))
(declare-function expr "etaf-view" (&rest arguments))
(declare-function slot "etaf-view" (&rest arguments))
(defun etaf-ui--column-value (column key)
"Return KEY from COLUMN, accepting a plist or alist descriptor."
(if (and (listp column) (keywordp (car column)))
(plist-get column key)
(alist-get key column)))
(defun etaf-ui--grid-cell-value (row key)
"Return KEY from data ROW, accepting a plist, alist, or hash table."
(cond
((hash-table-p row) (gethash key row))
((and (proper-list-p row)
(zerop (% (length row) 2))
(keywordp (car row)))
(plist-get row key))
((listp row) (alist-get key row))
(t nil)))
(defun etaf-ui--grid-header-cell (column)
"Return one header View for COLUMN."
(etaf-view
(text
:face 'bold
:width (etaf-ui--column-value column :width)
(expr :value (format "%s"
(or (etaf-ui--column-value column :label)
(etaf-ui--column-value column :key)))))))
(defun etaf-ui--grid-header (columns)
"Return a View header row for COLUMNS."
(etaf-view
(row
:class "etaf-data-grid-header"
(expr :value (mapcar #'etaf-ui--grid-header-cell columns)))))
(defun etaf-ui--grid-cell (row column)
"Return one data cell View for ROW and COLUMN."
(let ((key (etaf-ui--column-value column :key)))
(etaf-view
(text
:width (etaf-ui--column-value column :width)
(expr :value
(format "%s" (or (etaf-ui--grid-cell-value row key) "")))))))
(defun etaf-ui--grid-cells (row columns)
"Return data cell Views for ROW and COLUMNS."
(mapcar (lambda (column) (etaf-ui--grid-cell row column)) columns))
(defun etaf-ui--grid-row (row columns row-key on-row-press)
"Return a View row for ROW, COLUMNS, ROW-KEY, and ON-ROW-PRESS."
(let ((key (funcall row-key row)))
(unless key
(error "ETAF DataGrid row-key must return a non-nil stable scalar"))
(etaf-view
(row
:key key
:class "etaf-data-grid-row"
:on-press (when on-row-press
(lambda () (funcall on-row-press row)))
(expr :value (etaf-ui--grid-cells row columns))))))
;;;###autoload
(etaf-define-component etaf-button (&key label on-press disabled ref)
"Render a standard pressable button with LABEL and ON-PRESS.
DISABLED removes the callback and the default focus tab index. Product
appearance is ordinary Component styling, not a hidden variant taxonomy."
:styles
(styles
("&" :padding (0 1) :border ((1) solid "#687386"))
("&.disabled" :color "#8A93A6")
("&.enabled" :face bold))
:view
(text
:class (if disabled "etaf-button disabled" "etaf-button enabled")
:role 'button
:ref ref
:disabled disabled
:tab-index (unless disabled 0)
:on-press (unless disabled on-press)
(expr :value label)))
;;;###autoload
(etaf-define-component etaf-checkbox (&key checked label on-change ref)
"Render a controlled checkbox with CHECKED, LABEL, and ON-CHANGE.
ON-CHANGE receives the next boolean value. State ownership stays with the
caller, so the Component works with local refs or Data-backed forms."
:view
(row
:class "etaf-checkbox"
:role 'checkbox
:ref ref
:aria-label label
:on-press (lambda ()
(when on-change
(funcall on-change (not checked))))
(text :face 'bold (expr :value (if checked "" "")))
(text (expr :value (if label (concat " " label) "")))))
;;;###autoload
(etaf-define-component etaf-label (&key text face)
"Render TEXT as a semantic label with optional FACE."
:view
(text :face face (expr :value text)))
;;;###autoload
(etaf-define-component etaf-panel (&key title)
"Render a titled panel with header and default slot projections."
:styles
(styles
("&" :padding (1 2) :border ((1) solid "#687386"))
(".etaf-panel-title" :face bold))
:view
(column
:class "etaf-panel"
(expr
:value
(when title
(etaf-view (text :class "etaf-panel-title"
(expr :value title)))))
(slot :name 'header)
(slot)))
;;;###autoload
(etaf-define-component etaf-data-grid
(&key controller columns row-key on-row-press
loading-label error-label empty-label)
"Render rows from reactive DATA CONTROLLER and COLUMNS.
COLUMNS is a list of descriptors such as `(:key :name :label NAME)'. ROW-KEY
receives each row and must return a stable scalar identity. Data owns loading,
errors, pagination, mutation, and selection; this Component only projects
those values into ordinary Hosts."
:view
(column
:class "etaf-data-grid"
(expr :value (etaf-ui--grid-header columns))
(expr
:value
(progn
(unless (functionp row-key)
(error "ETAF DataGrid requires a function-valued :row-key"))
(let ((status (etaf-value (etaf-data-status controller)))
(items (etaf-value (etaf-data-items controller))))
(cond
((eq status 'loading)
(etaf-view (text (expr :value (or loading-label "Loading...")))))
((eq status 'error)
(etaf-view
(text :color "#FF6B6B"
(expr :value (or error-label "Unable to load data.")))))
((null items)
(etaf-view (text (expr :value (or empty-label "No data.")))))
(t
(mapcar (lambda (item)
(etaf-ui--grid-row item columns row-key on-row-press))
items))))))
(slot :name 'footer)))
(provide 'etaf-ui)
;;; etaf-ui.el ends here

132
tests/etaf-ui-tests.el Normal file
View File

@ -0,0 +1,132 @@
;;; etaf-ui-tests.el --- Official ETAF Component tests -*- lexical-binding: t; -*-
(require 'ert)
(require 'etaf-ui)
(defun etaf-ui-test--text (buffer-name)
"Return plain text currently published in BUFFER-NAME."
(with-current-buffer buffer-name
(string-trim-right (substring-no-properties (buffer-string)))))
(ert-deftest etaf-ui-button-dispatches-controlled-press ()
"Render a button and dispatch its public Host callback."
(let ((buffer-name " *etaf-ui-button-test*")
(presses 0))
(unwind-protect
(progn
(etaf-mount buffer-name
(etaf-view
(etaf-button :label "Save" :ref 'save
:on-press (lambda () (cl-incf presses)))))
(should (string-match-p "Save" (etaf-ui-test--text buffer-name)))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'save 'press)
(should (= presses 1)))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-checkbox-emits-next-value ()
"Render a controlled checkbox and emit its next checked value."
(let ((buffer-name " *etaf-ui-checkbox-test*") next)
(unwind-protect
(progn
(etaf-mount buffer-name
(etaf-view
(etaf-checkbox :label "Done" :ref 'done
:on-change (lambda (value)
(setq next value)))))
(should (string-match-p "☐ Done" (etaf-ui-test--text buffer-name)))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'done 'press)
(should (eq next t)))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
(when-let ((buffer (get-buffer buffer-name)))
(kill-buffer buffer)))))
(ert-deftest etaf-ui-panel-uses-common-slots ()
"Project a named header and default body through the common slot model."
(let ((text (substring-no-properties
(ebox-render
(etaf-render
(etaf-view
(etaf-panel :title "Account"
(slot :name 'header (text "Settings"))
(text "Body"))))))))
(dolist (label '("Account" "Settings" "Body"))
(should (string-match-p (regexp-quote label) text)))))
(ert-deftest etaf-ui-data-grid-projects-reactive-controller ()
"Render DataGrid rows and update them through the Data Controller."
(let* ((source (etaf-data-memory-source
'((:id 1 :name "Ada") (:id 2 :name "Grace"))
:id-key :id))
(controller (etaf-data-controller source :page-size 10 :auto-load t))
(buffer-name " *etaf-ui-grid-test*"))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(etaf-data-grid
:controller controller
:columns '((:key :id :label "ID")
(:key :name :label "Name"))
:row-key (lambda (row) (plist-get row :id)))))
(should (string-match-p "Ada" (etaf-ui-test--text buffer-name)))
(etaf-data-mutate controller 'insert '(:id 3 :name "Alan"))
(should (string-match-p "Alan" (etaf-ui-test--text buffer-name))))
(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)))))
(ert-deftest etaf-ui-data-grid-requires-stable-row-key ()
"Reject a DataGrid that cannot identify retained rows."
(let* ((source (etaf-data-memory-source
'((:id 1 :name "Ada"))
:id-key :id))
(controller (etaf-data-controller source :auto-load t))
(buffer-name " *etaf-ui-grid-row-key-test*"))
(unwind-protect
(should-error
(etaf-mount
buffer-name
(etaf-view
(etaf-data-grid
:controller controller
:columns '((:key :id :label "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)))))
(ert-deftest etaf-ui-data-grid-rejects-nil-row-key ()
"Reject a DataGrid row-key function that returns no identity."
(let* ((source (etaf-data-memory-source
'((:id 1 :name "Ada"))
:id-key :id))
(controller (etaf-data-controller source :auto-load t))
(buffer-name " *etaf-ui-grid-nil-row-key-test*"))
(unwind-protect
(should-error
(etaf-mount
buffer-name
(etaf-view
(etaf-data-grid
:controller controller
:columns '((:key :id :label "ID"))
:row-key (lambda (_row) nil)))))
(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-tests)
;;; etaf-ui-tests.el ends here