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:
commit
4497121162
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*.elc
|
||||
tests/*.elc
|
||||
27
Makefile
Normal file
27
Makefile
Normal 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
16
README.md
Normal 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
16
README.zh-CN.md
Normal 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
189
etaf-ui.el
Normal 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
132
tests/etaf-ui-tests.el
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user