Retain DataGrid actions and bound Pagination

This commit is contained in:
Kinneyzhang 2026-08-22 08:21:19 +08:00
parent 287c202258
commit 0701bc76bb
4 changed files with 127 additions and 54 deletions

View File

@ -66,7 +66,9 @@ 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.
scalar identity for retained rows. DataGrid retains one action closure per
row key, so selection or data Range updates do not recreate handlers for
unchanged rows.
`etaf-pagination` is a controlled Data Component. It accepts a Data controller
plus stable `:previous-ref` and `:next-ref` values, renders readable ``/``

View File

@ -57,7 +57,8 @@ DataGrid 支持列描述、函数型 `:row-key`,以及可选的
提供 `:row-ref`,它对每一行返回非 nil 的稳定 Host ref行会获得
`button` role 和 `tab-index 0`,且回调接收该行。没有 `:on-row-press` 时,
行没有回调或 tab stop。`:row-key` 仍然是 retained row 必须具备的非 nil
稳定标量 identity。
稳定标量 identity。DataGrid 按 row key 保留唯一 action closure因此 selection
或 Data Range 更新不会为未变化的行重建 handler。
`etaf-pagination` 是受控 Data Component。它接收 Data controller 以及稳定的
`:previous-ref`、`:next-ref`,显示易读的 ``/`` 控件和 `Page N / M` 摘要;

View File

@ -139,12 +139,29 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
(or (and row-selected-p (funcall row-selected-p row))
(and selected-key (equal key selected-key))))
(defun etaf-ui--grid-row-action (cache key row callback)
"Return CACHE's stable row action for KEY, refreshing ROW and CALLBACK."
(let ((entry (gethash key cache)))
(unless entry
(setq entry (vector row callback nil))
(aset entry 2
(lambda ()
(let ((current (aref entry 1)))
(when current
(funcall current (aref entry 0))))))
(puthash key entry cache))
(aset entry 0 row)
(aset entry 1 callback)
(aref entry 2)))
(defun etaf-ui--grid-row
(row columns row-key row-ref on-row-press selected-key row-selected-p)
(row columns row-key row-ref on-row-press selected-key row-selected-p
row-actions)
"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."
ON-ROW-PRESS, SELECTED-KEY, and ROW-SELECTED-P control state. ROW-ACTIONS
owns stable keyed callbacks across Range reevaluation."
(let* ((key (funcall row-key row))
(interactive-p (not (null on-row-press)))
(selected-p (etaf-ui--grid-selected-p
@ -171,9 +188,30 @@ ON-ROW-PRESS, SELECTED-KEY, and ROW-SELECTED-P control state."
(etaf-ui--interactive-surface-properties
(format "Row %s" key) nil))
:on-press (when interactive-p
(lambda () (funcall on-row-press row)))
(etaf-ui--grid-row-action
row-actions key row on-row-press))
(expr :value (etaf-ui--grid-cells row columns))))))
(defun etaf-ui--grid-rows
(items columns row-key row-ref on-row-press selected-key row-selected-p
row-actions)
"Return keyed item Views and prune ROW-ACTIONS outside current ITEMS.
COLUMNS and ROW-KEY describe cells and identity. ROW-REF, ON-ROW-PRESS,
SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
(let ((seen (make-hash-table :test #'equal)))
(prog1
(mapcar
(lambda (item)
(puthash (funcall row-key item) t seen)
(etaf-ui--grid-row
item columns row-key row-ref on-row-press selected-key
row-selected-p row-actions))
items)
(maphash (lambda (key _entry)
(unless (gethash key seen)
(remhash key row-actions)))
row-actions))))
(defun etaf-ui--button-setup ()
"Create the retained renderer for one Button instance."
(let* ((pressed (etaf-ref nil))
@ -377,47 +415,58 @@ a stable Host reference."
(".etaf-data-grid-header-cell" :face bold)
(".etaf-data-grid-row" :padding (0 1)
:border ((1) solid "#687386"))
;; Keep selection visible without painting the row's stretch remainder as
;; a misleading white bar; the row border and emphasized text are the
;; interaction signal, while cell backgrounds remain transparent.
(".selected" :color "#2F6B43"
:border ((1) solid "#73A982") :face bold)
:border ((1) solid "#73A982"))
(".etaf-data-grid-error" :color "#FF6B6B"))
:view
(column
:class "etaf-data-grid"
(expr :value (etaf-ui--grid-header columns))
(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)))
:setup
(let ((row-actions (make-hash-table :test #'equal)))
(lambda ()
(let ((controller (etaf-current-prop :controller))
(columns (etaf-current-prop :columns))
(row-key (etaf-current-prop :row-key))
(row-ref (etaf-current-prop :row-ref))
(on-row-press (etaf-current-prop :on-row-press))
(selected-key (etaf-current-prop :selected-key))
(row-selected-p (etaf-current-prop :row-selected-p))
(loading-label (etaf-current-prop :loading-label))
(error-label (etaf-current-prop :error-label))
(empty-label (etaf-current-prop :empty-label)))
(etaf-view
(column
:class "etaf-data-grid"
(expr :value (etaf-ui--grid-header columns))
(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
(etaf-ui--grid-rows
items columns row-key row-ref on-row-press selected-key
row-selected-p row-actions))))))
(slot :name 'footer))))))))
;;;###autoload
(etaf-define-component etaf-pagination
@ -432,7 +481,7 @@ the pager participates in keyboard/mouse interaction. The visible glyphs
readable in both GUI and text review."
:styles
(styles
("&" :width stretch :padding (0 1))
("&" :width stretch)
(".etaf-pagination-label" :face bold)
(".etaf-pagination-summary" :color "#526174"))
:setup
@ -469,9 +518,11 @@ readable in both GUI and text review."
(button :label "" :ref (etaf-current-prop :previous-ref)
:aria-label "Previous page"
:disabled previous-disabled
:padding '(0 0)
:border '((0) solid "transparent")
:variant 'secondary
:on-press (unless previous-disabled previous))
(column :width 'stretch
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
(text :class "etaf-pagination-label" :text-align 'center
(expr :value (format "Page %d / %d" page pages)))
(text :class "etaf-pagination-summary" :text-align 'center
@ -480,6 +531,8 @@ readable in both GUI and text review."
(button :label "" :ref (etaf-current-prop :next-ref)
:aria-label "Next page"
:disabled next-disabled
:padding '(0 0)
:border '((0) solid "transparent")
:variant 'secondary
:on-press (unless next-disabled next))))))))

View File

@ -358,14 +358,20 @@
(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 (equal (plist-get (etaf-ui-test--props buffer-name 'row-3)
:tab-index)
0)))
(let* ((runtime (etaf-runtime-for-buffer buffer-name))
(handler (cdr (assq 'press
(etaf-runtime-handler-for runtime
'row-1)))))
(etaf-dispatch-event runtime 'row-1 'press)
(should (equal (plist-get pressed :id) 1))
(etaf-data-mutate controller 'insert '(:id 3 :name "Alan"))
(should (eq handler
(cdr (assq 'press
(etaf-runtime-handler-for runtime 'row-1)))))
(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)
@ -390,6 +396,17 @@
(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)))
;; Live Ebox windows reserve two pixels for the exclusive display
;; boundary; the pager must stay inside the corresponding 360px row.
(ebox-surface-update-buffer-viewport (get-buffer buffer-name) 358 20)
(with-current-buffer buffer-name
(goto-char (point-min))
(while (< (point) (point-max))
(should (<= (ebox-string-pixel-width
(buffer-substring (line-beginning-position)
(line-end-position)))
360))
(forward-line 1)))
(should (eq (nth 0 (etaf-ui-test--surface-properties
buffer-name 'page-next))
'hand))