commit 4497121162e879219aeb7429dabe074e6610b21f Author: Kinneyzhang Date: Wed Aug 5 06:59:22 2026 +0800 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3daa17c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.elc +tests/*.elc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..902a51f --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e26efe2 --- /dev/null +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..183b871 --- /dev/null +++ b/README.zh-CN.md @@ -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`。 diff --git a/etaf-ui.el b/etaf-ui.el new file mode 100644 index 0000000..bbce89e --- /dev/null +++ b/etaf-ui.el @@ -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 diff --git a/tests/etaf-ui-tests.el b/tests/etaf-ui-tests.el new file mode 100644 index 0000000..19f4c51 --- /dev/null +++ b/tests/etaf-ui-tests.el @@ -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