Polish public ETAF UI components

This commit is contained in:
Kinneyzhang 2026-08-22 06:19:05 +08:00
parent 2d463bb784
commit cef776e545
7 changed files with 1022 additions and 123 deletions

View File

@ -1,5 +1,5 @@
EMACS ?= emacs
LOAD_PATH = -L . -L ../etaf -L ../ebox
LOAD_PATH = -L . -L ../etaf -L ../ebox -L ../ecss -L ../tp
.PHONY: all compile test check checkdoc load clean

View File

@ -7,10 +7,78 @@
(let ((done (etaf-ref nil)))
(etaf-view
(etaf-checkbox
:checked (etaf-value done)
:label "Done"
:on-change (lambda (next) (setf (etaf-value done) next)))))
(panel :title "Account"
(slot :name 'header
(button :label "Save" :ref 'save-button
:on-press (lambda () (message "saved"))))
(label :text "Preferences")
(checkbox :checked (etaf-value done) :label "Done"
:ref 'done-checkbox
:on-change (lambda (next)
(setf (etaf-value done) next))))))
```
In `.etaf` files use the short registered tags (`panel`, `label`, `button`,
`checkbox`, and `data-grid`) for structure. The companion `.el` file owns
reactive values, Actions, Behaviors, and callbacks; keep the `etaf-view` tree
made from those same short tags. Canonical `etaf-*` names remain the Elisp
definition and API names.
Button and Checkbox are controlled Components. `etaf-button` accepts `:label`,
`:on-press`, `:disabled`, `:ref`, and the small presentation set
`:class`, `:color`, `:bgcolor`, `:border`, `:padding`, `:face`, `:tab-index`,
and `:aria-label`, plus `:use` for a public Behavior list. An enabled button
defaults to role `button` and tab index `0`; a disabled button keeps its label
and disabled styling but has no press handler, Behavior, or tab stop.
`etaf-checkbox` accepts `:checked`, `:label`,
`:on-change`, `:disabled`, `:ref`, and the same presentation props. It exposes
role `checkbox`, a stable `:ref`, and tab index `0` while enabled. Its
`:on-change` receives the next boolean value; disabled checkboxes have no
handler or tab stop.
Component `:styles` declarations own default appearance. A non-nil
presentation prop supplied by the caller wins; an omitted or nil prop leaves
the inherited Theme/default style available. Button has the deliberately small
`primary`, `secondary`, and `ghost` visual vocabulary; it is a visual variant
set, not a second widget taxonomy.
Enabled buttons and checkboxes expose the same interaction affordances: a hand
pointer, `mouse-face` hover feedback, a readable `help-echo`, a numeric tab
stop, and a semantic role. Button presses briefly enter a `pressed` visual
state. Disabled controls retain their text and disabled appearance but expose
no callback, pointer activation, or tab stop. The Runtime owns composition of
an explicit `:on-*` callback with a Behavior callback; the UI package only
declares the control and its visual state.
Spacing is owned by the parent layout, not by Button. Put adjacent controls in
a `row`/`flex` with an explicit horizontal `:gap`; this keeps each control's
mouse-face and hit range semantically separate.
`etaf-label` accepts `:text`, `:face`, `:class`, `:color`, `:bgcolor`, `:width`,
`:border`, `:padding`, and `:ref`. `etaf-panel` accepts `:title`, the same
surface presentation props, and projects the named `header` slot plus the
default slot.
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.
`etaf-pagination` is a controlled Data Component. It accepts a Data controller
plus stable `:previous-ref` and `:next-ref` values, renders readable ``/``
controls and a `Page N / M` summary, and disables the boundary action while a
page is loading or already at the first/last page. It owns no page state and
uses the same Button interaction contract.
Keyboard focus uses ETAF's public runtime ports: call
`(etaf-focus-next runtime)` to move through visible Hosts with a numeric
non-negative `:tab-index`, and dispatch through
`(etaf-dispatch-event runtime ref 'press)` when an action must be invoked by
code. Disabled controls and non-interactive DataGrid rows are omitted from
that tab order.
Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` from this directory.

View File

@ -7,10 +7,67 @@
(let ((done (etaf-ref nil)))
(etaf-view
(etaf-checkbox
:checked (etaf-value done)
:label "Done"
:on-change (lambda (next) (setf (etaf-value done) next)))))
(panel :title "Account"
(slot :name 'header
(button :label "Save" :ref 'save-button
:on-press (lambda () (message "saved"))))
(label :text "Preferences")
(checkbox :checked (etaf-value done) :label "Done"
:ref 'done-checkbox
:on-change (lambda (next)
(setf (etaf-value done) next))))))
```
`.etaf` 文件的结构使用已注册的短标签(`panel`、`label`、`button`、
`checkbox`、`data-grid`)。配套 `.el` 文件负责响应式值、Action、Behavior
和回调;其中 `etaf-view` 也使用同一组短标签。Elisp 定义和 API 文档仍使用
规范的 `etaf-*` 名称。
Button 和 Checkbox 都是受控组件。`etaf-button` 支持 `:label`
`:on-press`、`:disabled`、`:ref`,以及最小 presentation 属性
`:class`、`:color`、`:bgcolor`、`:border`、`:padding`、`:face`、
`:tab-index`、`:aria-label` 和公共 Behavior 列表 `:use`。启用按钮默认使用
`button` role 和 `tab-index 0`;禁用按钮仍显示标签和禁用样式,但没有 press
回调、Behavior也不进入 tab 顺序。`etaf-checkbox` 支持 `:checked`、`:label`、`:on-change`、
`:disabled`、`:ref` 及相同的 presentation 属性。启用时它提供
`checkbox` role、稳定的 `:ref``tab-index 0``:on-change` 接收下一个
布尔值。禁用 checkbox 没有回调或 tab stop。
Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil presentation
属性会覆盖默认值;省略或传 nil 时,继承的 Theme/默认样式仍可生效。Button
只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant这是
视觉状态集合,不是第二套 widget 分类体系。
启用的 button 和 checkbox 共享同一套交互反馈:小手指针、`mouse-face` 悬停
反馈、可读的 `help-echo`、数字 tab stop 和语义 role。Button 按下时短暂进入
`pressed` 外观。禁用控件保留文字和禁用样式,但没有回调、指针激活或 tab stop。
显式 `:on-*` 回调和 Behavior 回调的组合由 Runtime 负责UI 包只声明控件和
视觉状态。
间距由父布局负责,而不是由 Button 偷塞。相邻控件应放在带明确横向 `:gap`
`row`/`flex` 中,这样每个控件的 mouse-face 和命中范围才保持语义独立。
`etaf-label` 支持 `:text`、`:face`、`:class`、`:color`、`:bgcolor`、`:width`、
`:border`、`:padding` 和 `:ref`。`etaf-panel` 支持 `:title`、相同的
surface presentation 属性,并投影命名的 `header` slot 和默认 slot。
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。
`etaf-pagination` 是受控 Data Component。它接收 Data controller 以及稳定的
`:previous-ref`、`:next-ref`,显示易读的 ``/`` 控件和 `Page N / M` 摘要;
加载中或已经位于首/末页时会禁用对应动作。它不拥有页码状态,并复用 Button
的交互契约。
键盘焦点使用 ETAF 的公共 Runtime 端口:用
`(etaf-focus-next runtime)` 在带数值且非负 `:tab-index` 的可见 Host 之间
移动;需要代码触发动作时使用
`(etaf-dispatch-event runtime ref 'press)`。禁用控件和不可交互的 DataGrid
行不会进入 tab 顺序。
在该目录运行 `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`

View File

@ -19,12 +19,76 @@
(declare-function etaf-data-status "etaf-data" (controller))
(declare-function etaf-data-items "etaf-data" (controller))
(declare-function etaf-data-page "etaf-data" (controller))
(declare-function etaf-data-page-size "etaf-data" (controller))
(declare-function etaf-data-total "etaf-data" (controller))
(declare-function etaf-data-previous-page "etaf-data" (controller))
(declare-function etaf-data-next-page "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--class-value (base state custom)
"Return BASE and STATE classes with optional CUSTOM classes."
(let ((custom (cond
((null custom) nil)
((listp custom) custom)
(t (list custom)))))
(mapconcat (lambda (class) (format "%s" class))
(cl-remove-if (lambda (class)
(or (null class) (equal class "")))
(append (list base state) custom))
" ")))
(defun etaf-ui--interactive-surface-properties (label disabled)
"Return shared text properties for an interactive LABEL surface.
DISABLED selects a non-pointer help description instead of an activation
affordance.
The properties are deliberately backend-neutral: Ebox turns them into the
native Emacs pointer/hover/help affordances while other renderers may ignore
the optional text properties and keep the semantic role/event contract."
(if disabled
(list 'help-echo (format "%s (disabled)" (or label "Control")))
(list 'pointer 'hand
'mouse-face 'highlight
'help-echo (format "%s · RET or mouse-1" (or label "Activate")))))
(defun etaf-ui--button-view
(label on-press disabled ref class color bgcolor border padding face
tab-index aria-label use pressed)
"Return a Button Host showing LABEL.
ON-PRESS and USE provide callbacks and Behaviors. DISABLED controls whether
the Host is interactive. REF, CLASS, COLOR, BGCOLOR, BORDER, PADDING, FACE,
TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
(let ((class-value (etaf-ui--class-value
"etaf-button"
(cond (disabled "disabled")
(pressed "pressed")
(t "enabled"))
class))
(tab-value (unless disabled (or tab-index 0)))
(label-value (or aria-label label))
(surface (etaf-ui--interactive-surface-properties label disabled)))
(if on-press
(etaf-view
(text :class class-value :role 'button :ref ref :disabled disabled
:tab-index tab-value :aria-label label-value
:color color :bgcolor bgcolor :border border
:padding padding :face face
:surface-properties surface
:use (unless disabled use) :on-press on-press
(expr :value label)))
(etaf-view
(text :class class-value :role 'button :ref ref :disabled disabled
:tab-index tab-value :aria-label label-value
:color color :bgcolor bgcolor :border border
:padding padding :face face
:surface-properties surface
:use (unless disabled use) (expr :value label))))))
(defun etaf-ui--column-value (column key)
"Return KEY from COLUMN, accepting a plist or alist descriptor."
(if (and (listp column) (keywordp (car column)))
@ -45,12 +109,11 @@
(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)))))))
(text :class "etaf-data-grid-header-cell"
: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."
@ -63,127 +126,359 @@
"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) "")))))))
(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)))
(defun etaf-ui--grid-selected-p (row key selected-key row-selected-p)
"Return whether ROW with KEY matches SELECTED-KEY or ROW-SELECTED-P."
(or (and row-selected-p (funcall row-selected-p row))
(and selected-key (equal key selected-key))))
(defun etaf-ui--grid-row
(row columns row-key row-ref on-row-press selected-key row-selected-p)
"Return a View row for ROW and COLUMNS with the DataGrid contract.
ROW-KEY returns identity; ROW-REF returns the interactive reference;
ON-ROW-PRESS, SELECTED-KEY, and ROW-SELECTED-P control state."
(let* ((key (funcall row-key row))
(interactive-p (not (null on-row-press)))
(selected-p (etaf-ui--grid-selected-p
row key selected-key row-selected-p))
host-ref)
(unless key
(error "ETAF DataGrid row-key must return a non-nil stable scalar"))
(when interactive-p
(unless (functionp row-ref)
(error "ETAF DataGrid requires :row-ref for interactive rows"))
(setq host-ref (funcall row-ref row))
(unless host-ref
(error "ETAF DataGrid :row-ref must return a non-nil stable ref")))
(etaf-view
(row
:key key
:class "etaf-data-grid-row"
:on-press (when on-row-press
:class (concat "etaf-data-grid-row"
(if selected-p " selected" ""))
:ref host-ref
:role (when interactive-p 'button)
:tab-index (when interactive-p 0)
:surface-properties
(when interactive-p
(etaf-ui--interactive-surface-properties
(format "Row %s" key) nil))
:on-press (when interactive-p
(lambda () (funcall on-row-press row)))
(expr :value (etaf-ui--grid-cells row columns))))))
(defun etaf-ui--button-setup ()
"Create the retained renderer for one Button instance."
(let* ((pressed (etaf-ref nil))
(timer nil)
(scope (etaf-current-effect-scope))
(current-callback nil)
(current-press-p nil)
(press nil))
(setq press
(lambda ()
(when current-press-p
(when (timerp timer)
(cancel-timer timer))
(setf (etaf-value pressed) t)
(unwind-protect
(when current-callback
(funcall current-callback))
(setq timer
(run-at-time
0.09 nil
(lambda ()
(when (etaf-effect-scope-active-p scope)
(setf (etaf-value pressed) nil)))))))))
(etaf-on-unmounted
(lambda ()
(when (timerp timer)
(cancel-timer timer))))
(lambda ()
(let* ((label (etaf-current-prop :label))
(callback (etaf-current-prop :on-press))
(disabled (etaf-current-prop :disabled))
(use (etaf-current-prop :use))
(press-p (and (not disabled) (or callback use))))
(setq current-callback callback
current-press-p press-p)
(let* ((state-pressed (and (not disabled) (etaf-value pressed)))
(state-class (cond (disabled "disabled")
(state-pressed "pressed")
(t "enabled")))
(variant (and (not disabled) (etaf-current-prop :variant)))
(variant-values
(cond
((and (eq variant 'secondary) state-pressed)
'(:color "#142235" :bgcolor "#B9DED7"
:border ((1) solid "#24736C") :face bold))
((eq variant 'secondary)
'(:color "#142235" :bgcolor "#D9EEEA"
:border ((1) solid "#2E8B83") :face bold))
((and (eq variant 'ghost) state-pressed)
'(:color "#142235" :bgcolor "#EEEAE2"
:border ((1) solid "#A79F93") :face normal))
((eq variant 'ghost)
'(:color "#142235" :bgcolor "#FFFDF8"
:border ((1) solid "#C8C1B6") :face normal)))))
(etaf-ui--button-view
label (and press-p press) disabled (etaf-current-prop :ref)
(let ((custom-class (etaf-current-prop :class)))
(delq nil (list custom-class state-class)))
(or (etaf-current-prop :color)
(plist-get variant-values :color))
(or (etaf-current-prop :bgcolor)
(plist-get variant-values :bgcolor))
(or (etaf-current-prop :border)
(plist-get variant-values :border))
(etaf-current-prop :padding)
(or (etaf-current-prop :face)
(plist-get variant-values :face))
(etaf-current-prop :tab-index)
(etaf-current-prop :aria-label)
use state-pressed))))))
;;;###autoload
(etaf-define-component etaf-button (&key label on-press disabled ref)
(etaf-define-component etaf-button
(&key label on-press disabled ref class color bgcolor border padding face
tab-index aria-label use variant)
"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."
appearance is controlled by VARIANT and the shared interactive surface
contract; callers can still override presentation with the ordinary props."
: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)))
("&" :width max-content)
("&.disabled" :color "#687386" :bgcolor "#E5E7EB"
:border ((1) solid "#9CA3AF") :padding (0 1) :face normal)
("&.enabled" :color "#FFFFFF" :bgcolor "#2F6B43"
:border ((1) solid "#2F6B43") :padding (0 1) :face bold)
("&.pressed" :color "#FFFFFF" :bgcolor "#1E5A56"
:border ((1) solid "#174A47") :padding (0 1) :face bold))
:setup
(etaf-ui--button-setup))
(defun etaf-ui--checkbox-view
(checked label on-change ref disabled class color bgcolor border padding
face tab-index aria-label)
"Return LABEL checkbox View with captured CHECKED and ON-PRESS.
REF, DISABLED, CLASS, COLOR, BGCOLOR, BORDER, PADDING, FACE, TAB-INDEX, and
ARIA-LABEL provide its semantic and presentation properties."
(etaf-view
(row
:class (etaf-ui--class-value
"etaf-checkbox" (if disabled "disabled" "enabled") class)
:role 'checkbox :ref ref :disabled disabled
:aria-label (or aria-label label)
:tab-index (unless disabled (or tab-index 0))
:color color :bgcolor bgcolor :border border :padding padding :face face
:surface-properties (etaf-ui--interactive-surface-properties label disabled)
:on-press on-change
(text :class "etaf-checkbox-mark" (expr :value (if checked "" "")))
(text (expr :value (if label (concat " " label) ""))))))
;;;###autoload
(etaf-define-component etaf-checkbox (&key checked label on-change ref)
(etaf-define-component etaf-checkbox
(&key checked label on-change ref disabled class color bgcolor border padding
face tab-index aria-label)
"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) "")))))
:styles
(styles
("&" :width max-content)
("&.disabled" :color "#6B7280" :bgcolor "#EEEAE2"
:border ((1) solid "#9CA3AF") :padding (0 1))
("&.enabled" :color "#252A2E" :bgcolor "#DCEBDD"
:border ((1) solid "#6D8A73") :padding (0 1))
(".etaf-checkbox-mark" :face bold :width 1))
:setup
(let* ((current-checked nil)
(current-callback nil)
(press
(lambda ()
(when current-callback
(funcall current-callback (not current-checked))))))
(lambda ()
(setq current-checked (etaf-current-prop :checked)
current-callback
(when (and (not (etaf-current-prop :disabled))
(etaf-current-prop :on-change))
(etaf-current-prop :on-change)))
(etaf-ui--checkbox-view
current-checked (etaf-current-prop :label)
(and current-callback press)
(etaf-current-prop :ref) (etaf-current-prop :disabled)
(etaf-current-prop :class) (etaf-current-prop :color)
(etaf-current-prop :bgcolor) (etaf-current-prop :border)
(etaf-current-prop :padding) (etaf-current-prop :face)
(etaf-current-prop :tab-index) (etaf-current-prop :aria-label)))))
;;;###autoload
(etaf-define-component etaf-label (&key text face)
"Render TEXT as a semantic label with optional FACE."
(etaf-define-component etaf-label
(&key text face class color bgcolor border padding ref width)
"Render TEXT as a semantic label with presentation properties."
:view
(text :face face (expr :value text)))
(expr
:value
(etaf-view
(text :class class :face face :color color :bgcolor bgcolor :border border
:padding padding :ref ref :width width (expr :value text)))))
;;;###autoload
(etaf-define-component etaf-panel (&key title)
(etaf-define-component etaf-panel
(&key title class color bgcolor border padding ref)
"Render a titled panel with header and default slot projections."
:styles
(styles
("&" :padding (1 2) :border ((1) solid "#687386"))
("&" :padding (1 2) :border ((1) solid "#687386")
:color "#252A2E" :bgcolor "#FFFDF8")
(".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)))
(expr
:value
(etaf-view
(column
:class (etaf-ui--class-value "etaf-panel" nil class)
:color color :bgcolor bgcolor :border border :padding padding :ref ref
(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)
row-ref selected-key row-selected-p 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."
those values into ordinary Hosts. Interactive rows require ROW-REF to return
a stable Host reference."
:styles
(styles
(".etaf-data-grid-header" :face bold :padding (0 1)
:border ((1) solid "#687386"))
(".etaf-data-grid-header-cell" :face bold)
(".etaf-data-grid-row" :padding (0 1)
:border ((1) solid "#687386"))
(".selected" :color "#2F6B43" :bgcolor "#DCEBDD" :face bold)
(".etaf-data-grid-error" :color "#FF6B6B"))
: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))))))
(column
:class "etaf-data-grid-body"
(expr
:value
(progn
(unless (functionp row-key)
(error "ETAF DataGrid requires a function-valued :row-key"))
(when (and on-row-press (not (functionp on-row-press)))
(error "ETAF DataGrid :on-row-press must be a function"))
(when (and on-row-press (not (functionp row-ref)))
(error "ETAF DataGrid requires :row-ref for interactive rows"))
(when (and row-selected-p (not (functionp row-selected-p)))
(error "ETAF DataGrid :row-selected-p must be a function"))
(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 :class "etaf-data-grid-error"
(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 row-ref on-row-press
selected-key row-selected-p))
items)))))))
(slot :name 'footer)))
;;;###autoload
(etaf-define-component etaf-pagination
(&key controller previous-ref next-ref class color bgcolor border padding
aria-label)
"Render a compact, accessible pager for DATA CONTROLLER.
The pager owns no data state: page, page-size, total, loading, and error stay
with CONTROLLER. PREVIOUS-REF and NEXT-REF should be stable public refs when
the pager participates in keyboard/mouse interaction. The visible glyphs
(`' and `') are paired with labels and help text so the compact control is
readable in both GUI and text review."
:styles
(styles
("&" :width stretch :padding (0 1))
(".etaf-pagination-label" :face bold)
(".etaf-pagination-summary" :color "#526174"))
:setup
(let* ((current-controller nil)
(previous
(lambda ()
(when current-controller
(etaf-data-previous-page current-controller))))
(next
(lambda ()
(when current-controller
(etaf-data-next-page current-controller)))))
(lambda ()
(let* ((controller-value (etaf-current-prop :controller))
(page (max 1 (or (etaf-value (etaf-data-page controller-value)) 1)))
(page-size (max 1 (or (etaf-value (etaf-data-page-size controller-value)) 1)))
(total (max 0 (or (etaf-value (etaf-data-total controller-value)) 0)))
(pages (max 1 (ceiling (/ (float total) page-size))))
(status (etaf-value (etaf-data-status controller-value)))
(first-item (if (zerop total) 0 (1+ (* (1- page) page-size))))
(last-item (min total (* page page-size)))
(previous-disabled (or (eq status 'loading) (<= page 1)))
(next-disabled (or (eq status 'loading) (>= page pages))))
(setq current-controller controller-value)
(etaf-view
(row :class (etaf-ui--class-value "etaf-pagination" nil
(etaf-current-prop :class))
:role 'navigation
:aria-label (or (etaf-current-prop :aria-label) "Pagination")
:color (etaf-current-prop :color)
:bgcolor (etaf-current-prop :bgcolor)
:border (etaf-current-prop :border)
:padding (etaf-current-prop :padding)
(button :label "" :ref (etaf-current-prop :previous-ref)
:aria-label "Previous page"
:disabled previous-disabled
:variant 'secondary
:on-press (unless previous-disabled previous))
(column :width 'stretch
(text :class "etaf-pagination-label" :text-align 'center
(expr :value (format "Page %d / %d" page pages)))
(text :class "etaf-pagination-summary" :text-align 'center
(expr :value (format "%d%d of %d"
first-item last-item total))))
(button :label "" :ref (etaf-current-prop :next-ref)
:aria-label "Next page"
:disabled next-disabled
:variant 'secondary
:on-press (unless next-disabled next))))))))
(provide 'etaf-ui)
;;; etaf-ui.el ends here

View File

@ -0,0 +1,26 @@
# Decision: Keep interaction contracts in `etaf-ui`
## Context
The playground needs components that remain visibly meaningful and keyboard
addressable when its `.etaf` structure and Elisp callbacks are mounted through
the public ETAF Runtime. Bare text Hosts made enabled controls hard to see,
while disabled controls could still look focusable. DataGrid rows also lacked a
public identity for dispatch and focus.
## Decision
Button and Checkbox own their disabled semantics: they keep visible state but
remove their callback and tab index when disabled. Enabled controls publish a
role, stable ref, and default tab index. DataGrid keeps stable scalar
`row-key` validation and requires an explicit `row-ref` callback for
interactive rows; only those rows publish button semantics and tab index.
Selection is represented by the small `selected-key`/`row-selected-p` props.
These rules belong in the catalog because they are component invariants, not
playground styling. Making each example re-add them would create divergent
event and focus behavior and would hide failures behind example-only
compensation.
The dynamic DataGrid rows are nested under a stable body Host so adding or
removing rows does not invalidate the retained header/footer surface scope.

View File

@ -0,0 +1,23 @@
# 决策:由 `etaf-ui` 持有交互契约
## 背景
Playground 需要让 `.etaf` 结构和 Elisp 回调通过公共 ETAF Runtime 挂载后,
组件仍然清晰可见并可用键盘访问。原本的纯文本 Host 不像可操作控件,禁用
控件也可能看起来仍可聚焦DataGrid 行还缺少用于 dispatch 和 focus 的公共
identity。
## 决策
Button 和 Checkbox 自己负责 disabled 语义:保留可见状态,但禁用时移除回调
和 tab index启用时发布 role、稳定 ref 和默认 tab index。DataGrid 保留稳定
标量 `row-key` 校验,并要求交互行通过显式 `row-ref` 回调得到稳定 Host ref
只有交互行发布 button 语义和 tab index。选中状态只使用最小的
`selected-key`/`row-selected-p` 属性。
这些规则属于目录组件,因为它们是组件不变量,而不是 Playground 的样式。
如果每个示例重新补齐,就会产生分叉的事件与焦点行为,并把错误隐藏在仅供
示例使用的补偿逻辑中。
DataGrid 动态行放在稳定的 body Host 下,增删行时不会使 retained header/footer
的 surface scope 失效。

View File

@ -8,17 +8,134 @@
(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."
(defun etaf-ui-test--props (buffer-name ref)
"Return mounted Host properties for REF in BUFFER-NAME."
(gethash ref
(etaf-runtime-host-props
(etaf-runtime-for-buffer buffer-name))))
(defun etaf-ui-test--props-with-key (buffer-name key)
"Return mounted Host properties with Host KEY in BUFFER-NAME."
(let (found)
(maphash
(lambda (_ref props)
(when (equal (plist-get props :key) key)
(setq found props)))
(etaf-runtime-host-props (etaf-runtime-for-buffer buffer-name)))
found))
(defun etaf-ui-test--surface-properties (buffer-name ref)
"Return interactive text properties at REF's first rendered position."
(let* ((runtime (etaf-runtime-for-buffer buffer-name))
(position (etaf-host-ref-position runtime ref)))
(with-current-buffer buffer-name
(list (get-text-property position 'pointer)
(get-text-property position 'mouse-face)
(get-text-property position 'help-echo)))))
(defvar etaf-ui-test-use-count 0)
(etaf-define-behavior etaf-ui-test-press-behavior (&rest attributes)
"Install a press callback that increments `etaf-ui-test-use-count'."
(apply #'etaf-behavior-create
'etaf-ui-test-press
(append attributes
(list :on-press
(lambda () (cl-incf etaf-ui-test-use-count))))))
(etaf-define-component etaf-ui-test-theme-fixture ()
"Provide Theme defaults for official Component presentation tests."
:setup
(progn
(etaf-theme-provide '(:color "theme-color"
:bgcolor "theme-bg"
:padding (9 9)))
(lambda ()
(etaf-view
(row
(button :label "Styled" :ref 'styled-button)
(button :label "Custom" :ref 'custom-button
:color "explicit-color")
(label :text "Themed" :ref 'themed-label
:color nil :bgcolor nil)
(panel :title "Styled panel" :ref 'styled-panel))))))
(ert-deftest etaf-ui-button-use-behavior-dispatches-through-host ()
"Install Button `:use' Behavior and dispatch its merged callback."
(let ((buffer-name " *etaf-ui-button-use-test*"))
(setq etaf-ui-test-use-count 0)
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(button :label "Behavior" :ref 'behavior-button
:use (list (etaf-ui-test-press-behavior)))))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'behavior-button 'press)
(should (= etaf-ui-test-use-count 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-defaults-use-styles-and-preserve-theme ()
"Use Component styles for defaults and Theme for omitted properties."
(let ((buffer-name " *etaf-ui-style-default-test*"))
(unwind-protect
(progn
(etaf-mount buffer-name
(etaf-view (ui-test-theme-fixture)))
(let ((styled (etaf-ui-test--props buffer-name 'styled-button))
(custom (etaf-ui-test--props buffer-name 'custom-button))
(themed (etaf-ui-test--props buffer-name 'themed-label))
(panel (etaf-ui-test--props buffer-name 'styled-panel)))
(should (equal (plist-get styled :color) "#FFFFFF"))
(should (equal (plist-get styled :bgcolor) "#2F6B43"))
(should (equal (plist-get styled :padding) '(0 1)))
(should (equal (plist-get custom :color) "explicit-color"))
(should (equal (plist-get custom :bgcolor) "#2F6B43"))
(should (equal (plist-get themed :color) "theme-color"))
(should (equal (plist-get themed :bgcolor) "theme-bg"))
(should (equal (plist-get themed :padding) '(9 9)))
(should (equal (plist-get panel :color) "#252A2E"))
(should (equal (plist-get panel :bgcolor) "#FFFDF8"))
(should (equal (plist-get panel :padding) '(1 2)))))
(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-button-dispatches-and-exposes-enabled-props ()
"Render an enabled button with semantic and presentation properties."
(let ((buffer-name " *etaf-ui-button-test*")
(presses 0))
(unwind-protect
(progn
(etaf-mount buffer-name
(etaf-view
(etaf-button :label "Save" :ref 'save
(button :label "Save" :ref 'save
:class "primary"
:color "#FFFFFF"
:bgcolor "#2F6B43"
:border "#2F6B43"
:padding '(0 2)
:face 'bold
:tab-index 3
:aria-label "Save changes"
:on-press (lambda () (cl-incf presses)))))
(should (string-match-p "Save" (etaf-ui-test--text buffer-name)))
(let ((props (etaf-ui-test--props buffer-name 'save)))
(should (equal (plist-get props :role) 'button))
(should (eq (plist-get props :width) 'max-content))
(should (equal (plist-get props :tab-index) 3))
(should (equal (plist-get props :aria-label) "Save changes"))
(should (equal (plist-get props :color) "#FFFFFF"))
(should (equal (plist-get props :bgcolor) "#2F6B43"))
(should (equal (plist-get props :border) "#2F6B43"))
(should (equal (plist-get props :padding) '(0 2)))
(should (equal (plist-get props :face) 'bold))
(should (string-match-p "primary" (plist-get props :class))))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'save 'press)
(should (= presses 1)))
@ -27,63 +144,376 @@
(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)
(ert-deftest etaf-ui-button-disabled-is-not-interactive ()
"A disabled button has no callback and is not a focus candidate."
(let ((buffer-name " *etaf-ui-disabled-button-test*")
(presses 0))
(setq etaf-ui-test-use-count 0)
(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)))
(row
(button :label "Save" :ref 'enabled-save
:on-press (lambda () (cl-incf presses)))
(button :label "Delete" :ref 'disabled-delete
:disabled t
:use (list (etaf-ui-test-press-behavior))
:on-press (lambda () (cl-incf presses))))))
(let ((props (etaf-ui-test--props buffer-name 'enabled-save)))
(should (equal (plist-get props :color) "#FFFFFF"))
(should (equal (plist-get props :bgcolor) "#2F6B43"))
(should (equal (plist-get props :padding) '(0 1)))
(should (equal (plist-get props :face) 'bold)))
(let ((props (etaf-ui-test--props buffer-name 'disabled-delete)))
(should (eq (plist-get props :disabled) t))
(should-not (plist-get props :tab-index))
(should (equal (plist-get props :bgcolor) "#E5E7EB"))
(should (string-match-p "disabled" (plist-get props :class))))
(should-error
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'disabled-delete 'press)
:type 'etaf-event-error)
(etaf-focus-next (etaf-runtime-for-buffer buffer-name))
(should (eq (etaf-focused-host-ref
(etaf-runtime-for-buffer buffer-name))
'enabled-save))
(should (= presses 0))
(should (= etaf-ui-test-use-count 0)))
(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*"))
(ert-deftest etaf-ui-button-owns-pointer-hover-and-pressed-feedback ()
"Buttons expose native pointer/hover affordances and a pressed state."
(let ((buffer-name " *etaf-ui-button-surface-test*")
(presses 0))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(etaf-data-grid
(button :label "Run health check" :ref 'health
:variant 'secondary
:on-press (lambda () (cl-incf presses)))))
(let ((surface (etaf-ui-test--surface-properties buffer-name 'health)))
(should (eq (nth 0 surface) 'hand))
(should (eq (nth 1 surface) 'highlight))
(should (string-match-p "RET" (nth 2 surface))))
(let ((props (etaf-ui-test--props buffer-name 'health)))
(should (equal (plist-get props :color) "#142235"))
(should (equal (plist-get props :bgcolor) "#D9EEEA")))
(let* ((runtime (etaf-runtime-for-buffer buffer-name))
(before (cdr (assq 'press
(etaf-runtime-handler-for runtime 'health)))))
(should (functionp before))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'health 'press)
(should (= presses 1))
(should (string-match-p
"pressed"
(plist-get (etaf-ui-test--props buffer-name 'health)
:class)))
(should (eq before
(cdr (assq 'press
(etaf-runtime-handler-for runtime 'health)))))))
(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-is-controlled-and-focusable ()
"Render a controlled checkbox, toggle it, and retain its focus contract."
(let ((buffer-name " *etaf-ui-checkbox-test*")
(checked (etaf-ref nil))
next)
(unwind-protect
(progn
(etaf-mount buffer-name
(etaf-view
(checkbox :label "Done" :ref 'done
:checked (etaf-value checked)
:on-change (lambda (value)
(setq next value)
(setf (etaf-value checked)
value)))))
(should (string-match-p "☐ Done" (etaf-ui-test--text buffer-name)))
(let ((props (etaf-ui-test--props buffer-name 'done)))
(should (equal (plist-get props :role) 'checkbox))
(should (eq (plist-get props :width) 'max-content))
(should (equal (plist-get props :tab-index) 0))
(should (equal (plist-get props :ref) 'done)))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'done 'press)
(should (eq next t))
(should (string-match-p "☑ Done" (etaf-ui-test--text buffer-name)))
(etaf-focus-next (etaf-runtime-for-buffer buffer-name))
(should (eq (etaf-focused-host-ref
(etaf-runtime-for-buffer buffer-name))
'done)))
(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-disabled-is-not-interactive ()
"A disabled checkbox has no callback or tab stop and remains visible."
(let ((buffer-name " *etaf-ui-disabled-checkbox-test*")
(changes 0))
(unwind-protect
(progn
(etaf-mount buffer-name
(etaf-view
(row
(checkbox :label "Open" :ref 'open-box
:on-change (lambda (_value)
(cl-incf changes)))
(checkbox :label "Closed" :ref 'closed-box
:disabled t
:on-change (lambda (_value)
(cl-incf changes))))))
(let ((props (etaf-ui-test--props buffer-name 'closed-box)))
(should (eq (plist-get props :disabled) t))
(should-not (plist-get props :tab-index))
(should (equal (plist-get props :bgcolor) "#EEEAE2"))
(should (string-match-p "disabled" (plist-get props :class))))
(should-error
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'closed-box 'press)
:type 'etaf-event-error)
(etaf-focus-next (etaf-runtime-for-buffer buffer-name))
(should (eq (etaf-focused-host-ref
(etaf-runtime-for-buffer buffer-name))
'open-box))
(should (= changes 0)))
(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-label-and-panel-expose-presentation-and-slots ()
"Render Label and Panel presentation props with named/default slots."
(let ((buffer-name " *etaf-ui-panel-test*"))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(panel :title "Account" :ref 'account-panel
:class "surface" :color "#252A2E" :bgcolor "#FFFDF8"
:border "#687386" :padding '(1 2)
(slot :name 'header
(label :text "Settings" :ref 'settings-label
:class "eyebrow" :color "#66706A"
:face 'bold :width 12))
(label :text "Body"))))
(let ((panel (etaf-ui-test--props buffer-name 'account-panel))
(label (etaf-ui-test--props buffer-name 'settings-label)))
(should (string-match-p "surface" (plist-get panel :class)))
(should (equal (plist-get panel :color) "#252A2E"))
(should (equal (plist-get panel :bgcolor) "#FFFDF8"))
(should (equal (plist-get panel :border) "#687386"))
(should (equal (plist-get panel :padding) '(1 2)))
(should (string-match-p "eyebrow" (plist-get label :class)))
(should (equal (plist-get label :color) "#66706A"))
(should (equal (plist-get label :face) 'bold))
(should (equal (plist-get label :width) 12)))
(dolist (label '("Account" "Settings" "Body"))
(should (string-match-p (regexp-quote label)
(etaf-ui-test--text buffer-name)))))
(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-data-grid-projects-reactive-controller ()
"Render DataGrid rows, refs, semantics, selection, and dispatch."
(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*")
pressed)
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(data-grid
:controller controller
:columns '((:key :id :label "ID")
(:key :name :label "Name"))
:row-key (lambda (row) (plist-get row :id)))))
:row-key (lambda (row) (plist-get row :id))
:row-ref (lambda (row)
(intern (format "row-%d" (plist-get row :id))))
:selected-key 2
:on-row-press (lambda (row) (setq pressed row)))))
(should (string-match-p "Ada" (etaf-ui-test--text buffer-name)))
(let ((first (etaf-ui-test--props buffer-name 'row-1))
(second (etaf-ui-test--props buffer-name 'row-2)))
(should (equal (plist-get first :role) 'button))
(should (equal (plist-get first :tab-index) 0))
(should (equal (plist-get second :role) 'button))
(should (equal (plist-get second :tab-index) 0))
(should (string-match-p "selected" (plist-get second :class)))
(should-not (string-match-p "selected" (plist-get first :class))))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'row-1 'press)
(should (equal (plist-get pressed :id) 1))
(etaf-data-mutate controller 'insert '(:id 3 :name "Alan"))
(should (string-match-p "Alan" (etaf-ui-test--text buffer-name))))
(should (string-match-p "Alan" (etaf-ui-test--text buffer-name)))
(should (equal (plist-get (etaf-ui-test--props buffer-name 'row-3)
:tab-index)
0)))
(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-pagination-is-readable-and-boundary-safe ()
"Pagination renders Unicode controls, stable refs, and page boundaries."
(let* ((source (etaf-data-memory-source
'((:id 1) (:id 2) (:id 3) (:id 4) (:id 5))
:id-key :id))
(controller (etaf-data-controller source :page-size 2 :auto-load t))
(buffer-name " *etaf-ui-pagination-test*"))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(pagination :controller controller
:previous-ref 'page-previous
:next-ref 'page-next)))
(should (string-match-p "Page 1 / 3" (etaf-ui-test--text buffer-name)))
(should (string-match-p "" (etaf-ui-test--text buffer-name)))
(should (string-match-p "" (etaf-ui-test--text buffer-name)))
(should (eq (nth 0 (etaf-ui-test--surface-properties
buffer-name 'page-next))
'hand))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'page-next 'press)
(should (string-match-p "Page 2 / 3" (etaf-ui-test--text buffer-name)))
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'page-previous 'press)
(should (string-match-p "Page 1 / 3" (etaf-ui-test--text buffer-name)))
(should-error
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
'page-previous 'press)
:type 'etaf-event-error))
(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-noninteractive-rows-have-no-focus-contract ()
"Rows without ON-ROW-PRESS have no role, ref callback, or tab stop."
(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-static-row-test*"))
(unwind-protect
(progn
(etaf-mount
buffer-name
(etaf-view
(data-grid
:controller controller
:columns '((:key :id :label "ID"))
:row-key (lambda (row) (plist-get row :id)))))
(let ((props (etaf-ui-test--props-with-key buffer-name 1)))
(should-not (plist-get props :role))
(should-not (plist-get props :tab-index))))
(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-row-ref-for-interaction ()
"Reject an interactive DataGrid without a row-ref callback."
(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-ref-test*"))
(unwind-protect
(should-error
(etaf-mount
buffer-name
(etaf-view
(data-grid
:controller controller
:columns '((:key :id :label "ID"))
:row-key (lambda (row) (plist-get row :id))
:on-row-press (lambda (_row) t)))))
(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-ref ()
"Reject an interactive row-ref callback that returns nil."
(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-ref-test*"))
(unwind-protect
(should-error
(etaf-mount
buffer-name
(etaf-view
(data-grid
:controller controller
:columns '((:key :id :label "ID"))
:row-key (lambda (row) (plist-get row :id))
:row-ref (lambda (_row) nil)
:on-row-press (lambda (_row) t)))))
(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-projects-loading-error-and-empty ()
"Project the Data Controller loading, error, and empty states."
(dolist (case '((loading . "Loading custom")
(error . "Error custom")
(empty . "Empty custom")))
(let* ((source (etaf-data-memory-source '((:id 1 :name "Ada"))
:id-key :id))
(controller (etaf-data-controller source))
(buffer-name (format " *etaf-ui-grid-%s-test*" (car case))))
(unwind-protect
(progn
(setf (etaf-value (etaf-data-status controller))
(car case))
(setf (etaf-value (etaf-data-items controller))
(when (eq (car case) 'empty) nil))
(etaf-mount
buffer-name
(etaf-view
(data-grid
:controller controller
:columns '((:key :id :label "ID"))
:row-key (lambda (row) (plist-get row :id))
:loading-label (when (eq (car case) 'loading)
(cdr case))
:error-label (when (eq (car case) 'error)
(cdr case))
:empty-label (when (eq (car case) 'empty)
(cdr case)))))
(should (string-match-p (regexp-quote (cdr case))
(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
@ -96,7 +526,7 @@
(etaf-mount
buffer-name
(etaf-view
(etaf-data-grid
(data-grid
:controller controller
:columns '((:key :id :label "ID"))))))
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
@ -117,7 +547,7 @@
(etaf-mount
buffer-name
(etaf-view
(etaf-data-grid
(data-grid
:controller controller
:columns '((:key :id :label "ID"))
:row-key (lambda (_row) nil)))))