Simplify UI interaction and stabilize DataGrid tracks
This commit is contained in:
parent
5333d607ed
commit
e2f8fd812c
11
README.md
11
README.md
@ -43,9 +43,9 @@ the inherited Theme/default style available. Button has the deliberately small
|
|||||||
set, not a second widget taxonomy.
|
set, not a second widget taxonomy.
|
||||||
|
|
||||||
Enabled buttons and checkboxes expose the same interaction affordances: a hand
|
Enabled buttons and checkboxes expose the same interaction affordances: a hand
|
||||||
pointer, `mouse-face` hover feedback, a readable `help-echo`, a numeric tab
|
pointer, native `mouse-face` hover feedback, a readable `help-echo`, a numeric
|
||||||
stop, and a semantic role. Button presses briefly enter a `pressed` visual
|
tab stop, and a semantic role. Hover ends when the pointer leaves; activation
|
||||||
state. Disabled controls retain their text and disabled appearance but expose
|
does not create retained visual state. Disabled controls retain their text and disabled appearance but expose
|
||||||
no callback, pointer activation, or tab stop. The Runtime owns composition of
|
no callback, pointer activation, or tab stop. The Runtime owns composition of
|
||||||
an explicit `:on-*` callback with a Behavior callback; the UI package only
|
an explicit `:on-*` callback with a Behavior callback; the UI package only
|
||||||
declares the control and its visual state.
|
declares the control and its visual state.
|
||||||
@ -69,9 +69,12 @@ have no callback or tab stop. `:row-key` remains a required non-nil stable
|
|||||||
scalar identity for retained rows. DataGrid retains one action closure per
|
scalar identity for retained rows. DataGrid retains one action closure per
|
||||||
row key, so selection or data Range updates do not recreate handlers for
|
row key, so selection or data Range updates do not recreate handlers for
|
||||||
unchanged rows.
|
unchanged rows.
|
||||||
|
An integer column `:width` is readable character capacity, not a raw pixel
|
||||||
|
value. DataGrid passes Ebox's native character unit through and reserves one
|
||||||
|
native character between non-final tracks.
|
||||||
|
|
||||||
`etaf-pagination` is a controlled Data Component. It accepts a Data controller
|
`etaf-pagination` is a controlled Data Component. It accepts a Data controller
|
||||||
plus stable `:previous-ref` and `:next-ref` values, renders readable `‹`/`›`
|
plus stable `:previous-ref` and `:next-ref` values, renders readable `←`/`→`
|
||||||
controls and a `Page N / M` summary, and disables the boundary action while a
|
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
|
page is loading or already at the first/last page. It owns no page state and
|
||||||
uses the same Button interaction contract.
|
uses the same Button interaction contract.
|
||||||
|
|||||||
@ -38,9 +38,10 @@ Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil pre
|
|||||||
只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant;这是
|
只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant;这是
|
||||||
视觉状态集合,不是第二套 widget 分类体系。
|
视觉状态集合,不是第二套 widget 分类体系。
|
||||||
|
|
||||||
启用的 button 和 checkbox 共享同一套交互反馈:小手指针、`mouse-face` 悬停
|
启用的 button 和 checkbox 共享同一套交互反馈:小手指针、原生 `mouse-face`
|
||||||
反馈、可读的 `help-echo`、数字 tab stop 和语义 role。Button 按下时短暂进入
|
悬停反馈、可读的 `help-echo`、数字 tab stop 和语义 role。指针离开后 hover
|
||||||
`pressed` 外观。禁用控件保留文字和禁用样式,但没有回调、指针激活或 tab stop。
|
自动结束,点击不会产生 retained 视觉状态。禁用控件保留文字和禁用样式,但
|
||||||
|
没有回调、指针激活或 tab stop。
|
||||||
显式 `:on-*` 回调和 Behavior 回调的组合由 Runtime 负责;UI 包只声明控件和
|
显式 `:on-*` 回调和 Behavior 回调的组合由 Runtime 负责;UI 包只声明控件和
|
||||||
视觉状态。
|
视觉状态。
|
||||||
|
|
||||||
@ -59,9 +60,11 @@ DataGrid 支持列描述、函数型 `:row-key`,以及可选的
|
|||||||
行没有回调或 tab stop。`:row-key` 仍然是 retained row 必须具备的非 nil
|
行没有回调或 tab stop。`:row-key` 仍然是 retained row 必须具备的非 nil
|
||||||
稳定标量 identity。DataGrid 按 row key 保留唯一 action closure,因此 selection
|
稳定标量 identity。DataGrid 按 row key 保留唯一 action closure,因此 selection
|
||||||
或 Data Range 更新不会为未变化的行重建 handler。
|
或 Data Range 更新不会为未变化的行重建 handler。
|
||||||
|
整数列 `:width` 表示可读字符容量,不是裸像素;DataGrid 原样使用 Ebox 原生字符单位,
|
||||||
|
并在非末列之间保留一个原生字符间距。
|
||||||
|
|
||||||
`etaf-pagination` 是受控 Data Component。它接收 Data controller 以及稳定的
|
`etaf-pagination` 是受控 Data Component。它接收 Data controller 以及稳定的
|
||||||
`:previous-ref`、`:next-ref`,显示易读的 `‹`/`›` 控件和 `Page N / M` 摘要;
|
`:previous-ref`、`:next-ref`,显示易读的 `←`/`→` 控件和 `Page N / M` 摘要;
|
||||||
加载中或已经位于首/末页时会禁用对应动作。它不拥有页码状态,并复用 Button
|
加载中或已经位于首/末页时会禁用对应动作。它不拥有页码状态,并复用 Button
|
||||||
的交互契约。
|
的交互契约。
|
||||||
|
|
||||||
|
|||||||
191
etaf-ui.el
191
etaf-ui.el
@ -27,6 +27,7 @@
|
|||||||
(declare-function text "etaf-view" (&rest arguments))
|
(declare-function text "etaf-view" (&rest arguments))
|
||||||
(declare-function row "etaf-view" (&rest arguments))
|
(declare-function row "etaf-view" (&rest arguments))
|
||||||
(declare-function column "etaf-view" (&rest arguments))
|
(declare-function column "etaf-view" (&rest arguments))
|
||||||
|
(declare-function flex "etaf-view" (&rest arguments))
|
||||||
(declare-function expr "etaf-view" (&rest arguments))
|
(declare-function expr "etaf-view" (&rest arguments))
|
||||||
(declare-function slot "etaf-view" (&rest arguments))
|
(declare-function slot "etaf-view" (&rest arguments))
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ the optional text properties and keep the semantic role/event contract."
|
|||||||
|
|
||||||
(defun etaf-ui--button-view
|
(defun etaf-ui--button-view
|
||||||
(label on-press disabled ref class color bgcolor border padding face
|
(label on-press disabled ref class color bgcolor border padding face
|
||||||
tab-index aria-label use pressed)
|
tab-index aria-label use)
|
||||||
"Return a Button Host showing LABEL.
|
"Return a Button Host showing LABEL.
|
||||||
|
|
||||||
ON-PRESS and USE provide callbacks and Behaviors. DISABLED controls whether
|
ON-PRESS and USE provide callbacks and Behaviors. DISABLED controls whether
|
||||||
@ -65,9 +66,7 @@ the Host is interactive. REF, CLASS, COLOR, BGCOLOR, BORDER, PADDING, FACE,
|
|||||||
TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
|
TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
|
||||||
(let ((class-value (etaf-ui--class-value
|
(let ((class-value (etaf-ui--class-value
|
||||||
"etaf-button"
|
"etaf-button"
|
||||||
(cond (disabled "disabled")
|
(if disabled "disabled" "enabled")
|
||||||
(pressed "pressed")
|
|
||||||
(t "enabled"))
|
|
||||||
class))
|
class))
|
||||||
(tab-value (unless disabled (or tab-index 0)))
|
(tab-value (unless disabled (or tab-index 0)))
|
||||||
(label-value (or aria-label label))
|
(label-value (or aria-label label))
|
||||||
@ -106,49 +105,64 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
|
|||||||
((listp row) (alist-get key row))
|
((listp row) (alist-get key row))
|
||||||
(t nil)))
|
(t nil)))
|
||||||
|
|
||||||
|
(defun etaf-ui--grid-fit-text (value column)
|
||||||
|
"Return VALUE fitted to COLUMN's declared character capacity."
|
||||||
|
(let* ((value (format "%s" (or value "")))
|
||||||
|
(width (etaf-ui--column-value column :width)))
|
||||||
|
(if (and (integerp width) (> width 0)
|
||||||
|
(> (string-width value) width))
|
||||||
|
(truncate-string-to-width value width 0 nil "…")
|
||||||
|
value)))
|
||||||
|
|
||||||
(defun etaf-ui--grid-display-value (row column)
|
(defun etaf-ui--grid-display-value (row column)
|
||||||
"Return one single-line display value for ROW and COLUMN.
|
"Return one single-line display value for ROW and COLUMN.
|
||||||
|
|
||||||
DataGrid columns are tabular tracks, not prose paragraphs. Keep each cell on
|
DataGrid columns are tabular tracks, not prose paragraphs. Keep each cell on
|
||||||
one visual line and use an ellipsis when a fixed character-width descriptor is
|
one visual line and use an ellipsis when a fixed character-width descriptor is
|
||||||
too small; the original ROW remains intact for selection and callbacks."
|
too small; the original ROW remains intact for selection and callbacks."
|
||||||
(let* ((value (format "%s"
|
(etaf-ui--grid-fit-text
|
||||||
(or (etaf-ui--grid-cell-value
|
(etaf-ui--grid-cell-value row (etaf-ui--column-value column :key))
|
||||||
row (etaf-ui--column-value column :key)) "")))
|
column))
|
||||||
(width (etaf-ui--column-value column :width)))
|
|
||||||
(if (and (integerp width) (> width 1)
|
|
||||||
(> (string-width value) width))
|
|
||||||
;; Leave one character of the declared track for inter-column air;
|
|
||||||
;; Ebox's left justification fills that remainder without changing
|
|
||||||
;; the stable column geometry.
|
|
||||||
(truncate-string-to-width value (1- width) 0 nil "…")
|
|
||||||
value)))
|
|
||||||
|
|
||||||
(defun etaf-ui--grid-header-cell (column)
|
(defun etaf-ui--grid-track-width (column gap-p)
|
||||||
"Return one header View for COLUMN."
|
"Return COLUMN width with one native-character gap when GAP-P is non-nil."
|
||||||
|
(let ((width (etaf-ui--column-value column :width)))
|
||||||
|
(if (and gap-p (integerp width) (> width 0))
|
||||||
|
(1+ width)
|
||||||
|
width)))
|
||||||
|
|
||||||
|
(defun etaf-ui--grid-header-cell (column gap-p)
|
||||||
|
"Return one header View for COLUMN, adding air when GAP-P is non-nil."
|
||||||
(etaf-view
|
(etaf-view
|
||||||
(text :class "etaf-data-grid-header-cell"
|
(text :class "etaf-data-grid-header-cell"
|
||||||
:width (etaf-ui--column-value column :width)
|
:width (etaf-ui--grid-track-width column gap-p)
|
||||||
(expr :value (format "%s"
|
(expr :value
|
||||||
(or (etaf-ui--column-value column :label)
|
(etaf-ui--grid-fit-text
|
||||||
(etaf-ui--column-value column :key)))))))
|
(or (etaf-ui--column-value column :label)
|
||||||
|
(etaf-ui--column-value column :key))
|
||||||
|
column)))))
|
||||||
|
|
||||||
(defun etaf-ui--grid-header (columns)
|
(defun etaf-ui--grid-header (columns)
|
||||||
"Return a View header row for COLUMNS."
|
"Return a View header row for COLUMNS."
|
||||||
(etaf-view
|
(etaf-view
|
||||||
(row
|
(row :class "etaf-data-grid-header"
|
||||||
:class "etaf-data-grid-header"
|
(expr :value
|
||||||
(expr :value (mapcar #'etaf-ui--grid-header-cell columns)))))
|
(cl-loop for column in columns
|
||||||
|
for tail on columns
|
||||||
|
collect (etaf-ui--grid-header-cell
|
||||||
|
column (cdr tail)))))))
|
||||||
|
|
||||||
(defun etaf-ui--grid-cell (row column)
|
(defun etaf-ui--grid-cell (row column gap-p)
|
||||||
"Return one data cell View for ROW and COLUMN."
|
"Return one data cell View for ROW and COLUMN, using GAP-P for air."
|
||||||
(etaf-view
|
(etaf-view
|
||||||
(text :width (etaf-ui--column-value column :width)
|
(text :width (etaf-ui--grid-track-width column gap-p)
|
||||||
(expr :value (etaf-ui--grid-display-value row column)))))
|
(expr :value (etaf-ui--grid-display-value row column)))))
|
||||||
|
|
||||||
(defun etaf-ui--grid-cells (row columns)
|
(defun etaf-ui--grid-cells (row columns)
|
||||||
"Return data cell Views for ROW and COLUMNS."
|
"Return data cell Views for ROW and COLUMNS."
|
||||||
(mapcar (lambda (column) (etaf-ui--grid-cell row column)) columns))
|
(cl-loop for column in columns
|
||||||
|
for tail on columns
|
||||||
|
collect (etaf-ui--grid-cell row column (cdr tail))))
|
||||||
|
|
||||||
(defun etaf-ui--grid-selected-p (row key selected-key row-selected-p)
|
(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."
|
"Return whether ROW with KEY matches SELECTED-KEY or ROW-SELECTED-P."
|
||||||
@ -192,10 +206,9 @@ owns stable keyed callbacks across Range reevaluation."
|
|||||||
(unless host-ref
|
(unless host-ref
|
||||||
(error "ETAF DataGrid :row-ref must return a non-nil stable ref")))
|
(error "ETAF DataGrid :row-ref must return a non-nil stable ref")))
|
||||||
(etaf-view
|
(etaf-view
|
||||||
(row
|
(row :key key
|
||||||
:key key
|
:class (concat "etaf-data-grid-row"
|
||||||
:class (concat "etaf-data-grid-row"
|
(if selected-p " selected" ""))
|
||||||
(if selected-p " selected" ""))
|
|
||||||
:ref host-ref
|
:ref host-ref
|
||||||
:role (when interactive-p 'button)
|
:role (when interactive-p 'button)
|
||||||
:tab-index (when interactive-p 0)
|
:tab-index (when interactive-p 0)
|
||||||
@ -230,31 +243,14 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
|
|||||||
|
|
||||||
(defun etaf-ui--button-setup ()
|
(defun etaf-ui--button-setup ()
|
||||||
"Create the retained renderer for one Button instance."
|
"Create the retained renderer for one Button instance."
|
||||||
(let* ((pressed (etaf-ref nil))
|
(let* ((current-callback nil)
|
||||||
(timer nil)
|
|
||||||
(scope (etaf-current-effect-scope))
|
|
||||||
(current-callback nil)
|
|
||||||
(current-press-p nil)
|
(current-press-p nil)
|
||||||
(press nil))
|
(press nil))
|
||||||
(setq press
|
(setq press
|
||||||
(lambda ()
|
(lambda ()
|
||||||
(when current-press-p
|
(when current-press-p
|
||||||
(when (timerp timer)
|
(when current-callback
|
||||||
(cancel-timer timer))
|
(funcall current-callback)))))
|
||||||
(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 ()
|
(lambda ()
|
||||||
(let* ((label (etaf-current-prop :label))
|
(let* ((label (etaf-current-prop :label))
|
||||||
(callback (etaf-current-prop :on-press))
|
(callback (etaf-current-prop :on-press))
|
||||||
@ -263,41 +259,27 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
|
|||||||
(press-p (and (not disabled) (or callback use))))
|
(press-p (and (not disabled) (or callback use))))
|
||||||
(setq current-callback callback
|
(setq current-callback callback
|
||||||
current-press-p press-p)
|
current-press-p press-p)
|
||||||
(let* ((state-pressed (and (not disabled) (etaf-value pressed)))
|
(let* ((variant (and (not disabled) (etaf-current-prop :variant)))
|
||||||
(state-class (cond (disabled "disabled")
|
|
||||||
(state-pressed "pressed")
|
|
||||||
(t "enabled")))
|
|
||||||
(variant (and (not disabled) (etaf-current-prop :variant)))
|
|
||||||
(variant-values
|
(variant-values
|
||||||
(cond
|
(cond
|
||||||
(disabled
|
(disabled
|
||||||
'(:color "#687386" :bgcolor "#E5E7EB"
|
'(:color "#687386" :bgcolor "#E5E7EB"
|
||||||
:border ((1) solid "#9CA3AF") :face normal))
|
:border ((1) solid "#9CA3AF") :face normal))
|
||||||
((and (eq variant 'secondary) state-pressed)
|
|
||||||
'(:color "#142235" :bgcolor "#B9DED7"
|
|
||||||
:border ((1) solid "#24736C") :face bold))
|
|
||||||
((eq variant 'secondary)
|
((eq variant 'secondary)
|
||||||
'(:color "#142235" :bgcolor "#D9EEEA"
|
'(:color "#142235" :bgcolor "#D9EEEA"
|
||||||
:border ((1) solid "#2E8B83") :face bold))
|
: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)
|
((eq variant 'ghost)
|
||||||
'(:color "#142235" :bgcolor "#FFFDF8"
|
'(:color "#142235" :bgcolor "#FFFDF8"
|
||||||
:border ((1) solid "#C8C1B6") :face normal))
|
:border ((1) solid "#C8C1B6") :face normal))
|
||||||
;; The enabled/pressed catalog defaults used to come from
|
;; The enabled catalog defaults used to come from state
|
||||||
;; state selectors alone. Resolve them as props too so an
|
;; selectors alone. Resolve them as props too so an
|
||||||
;; explicit theme can override the same state boundary.
|
;; explicit theme can override the same state boundary.
|
||||||
(state-pressed
|
|
||||||
'(:color "#FFFFFF" :bgcolor "#1E5A56"
|
|
||||||
:border ((1) solid "#174A47") :face bold))
|
|
||||||
(t
|
(t
|
||||||
'(:color "#FFFFFF" :bgcolor "#2F6B43"
|
'(:color "#FFFFFF" :bgcolor "#2F6B43"
|
||||||
:border ((1) solid "#2F6B43") :face bold)))))
|
:border ((1) solid "#2F6B43") :face bold)))))
|
||||||
(etaf-ui--button-view
|
(etaf-ui--button-view
|
||||||
label (and press-p press) disabled (etaf-current-prop :ref)
|
label (and press-p press) disabled (etaf-current-prop :ref)
|
||||||
(let ((custom-class (etaf-current-prop :class)))
|
(etaf-current-prop :class)
|
||||||
(delq nil (list custom-class state-class)))
|
|
||||||
(or (etaf-current-prop :color)
|
(or (etaf-current-prop :color)
|
||||||
(plist-get variant-values :color))
|
(plist-get variant-values :color))
|
||||||
(or (etaf-current-prop :bgcolor)
|
(or (etaf-current-prop :bgcolor)
|
||||||
@ -309,7 +291,7 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
|
|||||||
(plist-get variant-values :face))
|
(plist-get variant-values :face))
|
||||||
(etaf-current-prop :tab-index)
|
(etaf-current-prop :tab-index)
|
||||||
(etaf-current-prop :aria-label)
|
(etaf-current-prop :aria-label)
|
||||||
use state-pressed))))))
|
use))))))
|
||||||
|
|
||||||
;;;###autoload
|
;;;###autoload
|
||||||
(etaf-define-component etaf-button
|
(etaf-define-component etaf-button
|
||||||
@ -327,8 +309,7 @@ contract; callers can still override presentation with the ordinary props."
|
|||||||
;; above remain authoritative, so a themed disabled Button cannot inherit
|
;; above remain authoritative, so a themed disabled Button cannot inherit
|
||||||
;; the catalog's light default surface.
|
;; the catalog's light default surface.
|
||||||
("&.disabled" :padding (0 1) :face normal)
|
("&.disabled" :padding (0 1) :face normal)
|
||||||
("&.enabled" :padding (0 1) :face bold)
|
("&.enabled" :padding (0 1) :face bold))
|
||||||
("&.pressed" :padding (0 1) :face bold))
|
|
||||||
:setup
|
:setup
|
||||||
(etaf-ui--button-setup))
|
(etaf-ui--button-setup))
|
||||||
|
|
||||||
@ -505,7 +486,7 @@ a stable Host reference."
|
|||||||
The pager owns no data state: page, page-size, total, loading, and error stay
|
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
|
with CONTROLLER. PREVIOUS-REF and NEXT-REF should be stable public refs when
|
||||||
the pager participates in keyboard/mouse interaction. The visible glyphs
|
the pager participates in keyboard/mouse interaction. The visible glyphs
|
||||||
(`‹' and `›') are paired with labels and help text so the compact control is
|
(`←' and `→') are paired with labels and help text so the compact control is
|
||||||
readable in both GUI and text review."
|
readable in both GUI and text review."
|
||||||
:styles
|
:styles
|
||||||
(styles
|
(styles
|
||||||
@ -538,39 +519,47 @@ readable in both GUI and text review."
|
|||||||
(arrow-border '((0) solid "transparent")))
|
(arrow-border '((0) solid "transparent")))
|
||||||
(setq current-controller controller-value)
|
(setq current-controller controller-value)
|
||||||
(etaf-view
|
(etaf-view
|
||||||
(row :class (etaf-ui--class-value "etaf-pagination" nil
|
(flex :class (etaf-ui--class-value "etaf-pagination" nil
|
||||||
(etaf-current-prop :class))
|
(etaf-current-prop :class))
|
||||||
:role 'navigation
|
:width 'stretch
|
||||||
:aria-label (or (etaf-current-prop :aria-label) "Pagination")
|
:flex-direction 'row
|
||||||
:color parent-color
|
:align-items 'center
|
||||||
:bgcolor parent-bgcolor
|
:role 'navigation
|
||||||
:border (etaf-current-prop :border)
|
:aria-label (or (etaf-current-prop :aria-label) "Pagination")
|
||||||
:padding (or (etaf-current-prop :padding) '(0 1))
|
:color parent-color
|
||||||
:gap '(0 (2))
|
:bgcolor parent-bgcolor
|
||||||
(button :label "‹" :ref (etaf-current-prop :previous-ref)
|
:border (etaf-current-prop :border)
|
||||||
:aria-label "Previous page"
|
:box-sizing 'border-box
|
||||||
:disabled previous-disabled
|
:padding (or (etaf-current-prop :padding) '(0 1))
|
||||||
:padding '(0 0)
|
:gap '(0 (1))
|
||||||
:border arrow-border
|
(column :width 'max-content
|
||||||
:color (if previous-disabled "#9CA3AF" parent-color)
|
:flex-grow 0 :flex-shrink 0 :flex-basis 'auto
|
||||||
:bgcolor parent-bgcolor
|
(button :label "←" :ref (etaf-current-prop :previous-ref)
|
||||||
:face 'bold
|
:aria-label "Previous page"
|
||||||
:on-press (unless previous-disabled previous))
|
:disabled previous-disabled
|
||||||
|
:padding '(0 0)
|
||||||
|
:border arrow-border
|
||||||
|
:color (if previous-disabled "#9CA3AF" parent-color)
|
||||||
|
:bgcolor parent-bgcolor
|
||||||
|
:face 'bold
|
||||||
|
:on-press (unless previous-disabled previous)))
|
||||||
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
|
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
|
||||||
(text :class "etaf-pagination-label" :text-align 'center
|
(text :class "etaf-pagination-label" :text-align 'center
|
||||||
(expr :value (format "Page %d / %d" page pages)))
|
(expr :value (format "Page %d / %d" page pages)))
|
||||||
(text :class "etaf-pagination-summary" :text-align 'center
|
(text :class "etaf-pagination-summary" :text-align 'center
|
||||||
(expr :value (format "%d–%d of %d"
|
(expr :value (format "%d–%d of %d"
|
||||||
first-item last-item total))))
|
first-item last-item total))))
|
||||||
(button :label "›" :ref (etaf-current-prop :next-ref)
|
(column :width 'max-content
|
||||||
:aria-label "Next page"
|
:flex-grow 0 :flex-shrink 0 :flex-basis 'auto
|
||||||
:disabled next-disabled
|
(button :label "→" :ref (etaf-current-prop :next-ref)
|
||||||
:padding '(0 0)
|
:aria-label "Next page"
|
||||||
:border arrow-border
|
:disabled next-disabled
|
||||||
:color (if next-disabled "#9CA3AF" parent-color)
|
:padding '(0 0)
|
||||||
:bgcolor parent-bgcolor
|
:border arrow-border
|
||||||
:face 'bold
|
:color (if next-disabled "#9CA3AF" parent-color)
|
||||||
:on-press (unless next-disabled next))))))))
|
:bgcolor parent-bgcolor
|
||||||
|
:face 'bold
|
||||||
|
:on-press (unless next-disabled next)))))))))
|
||||||
|
|
||||||
(provide 'etaf-ui)
|
(provide 'etaf-ui)
|
||||||
|
|
||||||
|
|||||||
@ -210,8 +210,8 @@
|
|||||||
(when-let ((buffer (get-buffer buffer-name)))
|
(when-let ((buffer (get-buffer buffer-name)))
|
||||||
(kill-buffer buffer))))))
|
(kill-buffer buffer))))))
|
||||||
|
|
||||||
(ert-deftest etaf-ui-button-owns-pointer-hover-and-pressed-feedback ()
|
(ert-deftest etaf-ui-button-owns-native-hover-and-dispatch ()
|
||||||
"Buttons expose native pointer/hover affordances and a pressed state."
|
"Buttons expose native hover affordances without retained press state."
|
||||||
(let ((buffer-name " *etaf-ui-button-surface-test*")
|
(let ((buffer-name " *etaf-ui-button-surface-test*")
|
||||||
(presses 0))
|
(presses 0))
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
@ -237,9 +237,13 @@
|
|||||||
'health 'press)
|
'health 'press)
|
||||||
(should (= presses 1))
|
(should (= presses 1))
|
||||||
(should (string-match-p
|
(should (string-match-p
|
||||||
"pressed"
|
"enabled"
|
||||||
(plist-get (etaf-ui-test--props buffer-name 'health)
|
(plist-get (etaf-ui-test--props buffer-name 'health)
|
||||||
:class)))
|
:class)))
|
||||||
|
(should-not (string-match-p
|
||||||
|
"pressed"
|
||||||
|
(plist-get (etaf-ui-test--props buffer-name 'health)
|
||||||
|
:class)))
|
||||||
(should (eq before
|
(should (eq before
|
||||||
(cdr (assq 'press
|
(cdr (assq 'press
|
||||||
(etaf-runtime-handler-for runtime 'health)))))))
|
(etaf-runtime-handler-for runtime 'health)))))))
|
||||||
@ -381,8 +385,10 @@
|
|||||||
(should (equal (plist-get first :tab-index) 0))
|
(should (equal (plist-get first :tab-index) 0))
|
||||||
(should (equal (plist-get second :role) 'button))
|
(should (equal (plist-get second :role) 'button))
|
||||||
(should (equal (plist-get second :tab-index) 0))
|
(should (equal (plist-get second :tab-index) 0))
|
||||||
(should (string-match-p "selected" (plist-get second :class)))
|
(should (string-match-p
|
||||||
(should-not (string-match-p "selected" (plist-get first :class))))
|
"selected" (or (plist-get second :class) "")))
|
||||||
|
(should-not (string-match-p
|
||||||
|
"selected" (or (plist-get first :class) ""))))
|
||||||
(let* ((runtime (etaf-runtime-for-buffer buffer-name))
|
(let* ((runtime (etaf-runtime-for-buffer buffer-name))
|
||||||
(handler (cdr (assq 'press
|
(handler (cdr (assq 'press
|
||||||
(etaf-runtime-handler-for runtime
|
(etaf-runtime-handler-for runtime
|
||||||
@ -419,8 +425,8 @@
|
|||||||
:previous-ref 'page-previous
|
:previous-ref 'page-previous
|
||||||
:next-ref 'page-next)))
|
:next-ref 'page-next)))
|
||||||
(should (string-match-p "Page 1 / 3" (etaf-ui-test--text buffer-name)))
|
(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 (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
|
;; Live Ebox windows reserve two pixels for the exclusive display
|
||||||
;; boundary; the pager must stay inside the corresponding 360px row.
|
;; boundary; the pager must stay inside the corresponding 360px row.
|
||||||
(ebox-surface-update-buffer-viewport (get-buffer buffer-name) 358 20)
|
(ebox-surface-update-buffer-viewport (get-buffer buffer-name) 358 20)
|
||||||
@ -451,6 +457,40 @@
|
|||||||
(when-let ((buffer (get-buffer buffer-name)))
|
(when-let ((buffer (get-buffer buffer-name)))
|
||||||
(kill-buffer buffer)))))
|
(kill-buffer buffer)))))
|
||||||
|
|
||||||
|
(ert-deftest etaf-ui-pagination-composes-inside-a-grid-track ()
|
||||||
|
"Pagination must distribute free space inside its assigned Grid track."
|
||||||
|
(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-grid-test*"))
|
||||||
|
(unwind-protect
|
||||||
|
(progn
|
||||||
|
(etaf-mount
|
||||||
|
buffer-name
|
||||||
|
(etaf-view
|
||||||
|
(grid :width '(360)
|
||||||
|
:grid-template-columns '((80) (200) (80))
|
||||||
|
(text "Left")
|
||||||
|
(column :width 'stretch :padding '(0 2) :border "#CBD5E1"
|
||||||
|
(pagination :controller controller
|
||||||
|
:previous-ref 'nested-page-previous
|
||||||
|
:next-ref 'nested-page-next))
|
||||||
|
(text "Right"))))
|
||||||
|
(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))))
|
||||||
|
(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-keeps-long-cells-on-one-line ()
|
(ert-deftest etaf-ui-data-grid-keeps-long-cells-on-one-line ()
|
||||||
"Long tabular values use an ellipsis instead of increasing row height."
|
"Long tabular values use an ellipsis instead of increasing row height."
|
||||||
(let* ((source (etaf-data-memory-source
|
(let* ((source (etaf-data-memory-source
|
||||||
@ -468,7 +508,7 @@
|
|||||||
:columns '((:key :title :label "Title" :width 22))
|
:columns '((:key :title :label "Title" :width 22))
|
||||||
:row-key (lambda (row) (plist-get row :id)))))
|
:row-key (lambda (row) (plist-get row :id)))))
|
||||||
(let ((text (etaf-ui-test--text buffer-name)))
|
(let ((text (etaf-ui-test--text buffer-name)))
|
||||||
(should (string-match-p "The Cathedral and th…" text))
|
(should (string-match-p "The Cathedral and the…" text))
|
||||||
(should-not (string-match-p "The Cathedral and the Bazaar" text))))
|
(should-not (string-match-p "The Cathedral and the Bazaar" text))))
|
||||||
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
|
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
|
||||||
(etaf-unmount runtime))
|
(etaf-unmount runtime))
|
||||||
@ -476,6 +516,30 @@
|
|||||||
(when-let ((buffer (get-buffer buffer-name)))
|
(when-let ((buffer (get-buffer buffer-name)))
|
||||||
(kill-buffer buffer)))))
|
(kill-buffer buffer)))))
|
||||||
|
|
||||||
|
(ert-deftest etaf-ui-data-grid-converts-character-columns-and-keeps-air ()
|
||||||
|
"Character column descriptors become pixel widths with a stable gap."
|
||||||
|
(let* ((source (etaf-data-memory-source
|
||||||
|
'((:id 1 :progress 64 :kind "Essay")) :id-key :id))
|
||||||
|
(controller (etaf-data-controller source :auto-load t))
|
||||||
|
(buffer-name " *etaf-ui-grid-column-unit-test*"))
|
||||||
|
(unwind-protect
|
||||||
|
(progn
|
||||||
|
(etaf-mount
|
||||||
|
buffer-name
|
||||||
|
(etaf-view
|
||||||
|
(data-grid
|
||||||
|
:controller controller
|
||||||
|
:columns '((:key :progress :label "Progress" :width 8)
|
||||||
|
(:key :kind :label "Kind" :width 7))
|
||||||
|
:row-key (lambda (row) (plist-get row :id)))))
|
||||||
|
(should (string-match-p "Progress[[:space:]]+Kind"
|
||||||
|
(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-noninteractive-rows-have-no-focus-contract ()
|
(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."
|
"Rows without ON-ROW-PRESS have no role, ref callback, or tab stop."
|
||||||
(let* ((source (etaf-data-memory-source '((:id 1 :name "Ada"))
|
(let* ((source (etaf-data-memory-source '((:id 1 :name "Ada"))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user