From cef776e54510a81259563f8a78f924479fe3cb7d Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 22 Aug 2026 06:19:05 +0800 Subject: [PATCH] Polish public ETAF UI components --- Makefile | 2 +- README.md | 76 ++- README.zh-CN.md | 65 ++- etaf-ui.el | 449 +++++++++++++--- .../2026-08-19-interaction-contract.en.md | 26 + .../2026-08-19-interaction-contract.zh-CN.md | 23 + tests/etaf-ui-tests.el | 504 ++++++++++++++++-- 7 files changed, 1022 insertions(+), 123 deletions(-) create mode 100644 postmortem/2026-08-19-interaction-contract.en.md create mode 100644 postmortem/2026-08-19-interaction-contract.zh-CN.md diff --git a/Makefile b/Makefile index 3394438..a052aea 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index e26efe2..e5f9859 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 183b871..4198ec1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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`。 diff --git a/etaf-ui.el b/etaf-ui.el index bbce89e..7acd349 100644 --- a/etaf-ui.el +++ b/etaf-ui.el @@ -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 diff --git a/postmortem/2026-08-19-interaction-contract.en.md b/postmortem/2026-08-19-interaction-contract.en.md new file mode 100644 index 0000000..d8d16d0 --- /dev/null +++ b/postmortem/2026-08-19-interaction-contract.en.md @@ -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. diff --git a/postmortem/2026-08-19-interaction-contract.zh-CN.md b/postmortem/2026-08-19-interaction-contract.zh-CN.md new file mode 100644 index 0000000..d3ccdaf --- /dev/null +++ b/postmortem/2026-08-19-interaction-contract.zh-CN.md @@ -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 失效。 diff --git a/tests/etaf-ui-tests.el b/tests/etaf-ui-tests.el index 19f4c51..97063ee 100644 --- a/tests/etaf-ui-tests.el +++ b/tests/etaf-ui-tests.el @@ -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)))))