Stabilize themed DataGrid and pagination UI
This commit is contained in:
parent
e2f8fd812c
commit
7ee8d65679
16
README.md
16
README.md
@ -36,6 +36,22 @@ 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.
|
||||
|
||||
`etaf-number-input` is a small controlled input primitive for values edited
|
||||
through Emacs's native minibuffer. It accepts `:value`, `:label`, `:on-change`,
|
||||
`:min`, `:max`, `:ref`, and `:disabled`; the callback receives the validated
|
||||
integer while the caller remains the owner of the ref.
|
||||
|
||||
The catalog uses one semantic Theme vocabulary for color roles. The main
|
||||
tokens are `:ui-fg`, `:ui-bg`, `:ui-border`, `:ui-muted-fg`,
|
||||
`:ui-disabled-*`, `:ui-button-primary-*`, `:ui-button-secondary-*`,
|
||||
`:ui-button-ghost-*`, `:ui-checkbox-*`, `:ui-grid-*`, and
|
||||
`:ui-panel-*`. Applications provide these through ETAF's
|
||||
`etaf-theme-provide`; legacy `:ui-button-color`-style aliases remain accepted
|
||||
for compatibility. `etaf-ui` does not depend on TP or on a renderer-specific
|
||||
palette registry. If an application wants TP's light/dark palette data, the
|
||||
optional `etaf-theme-tp` adapter converts it into the same ETAF Theme plist
|
||||
before the UI catalog sees it.
|
||||
|
||||
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
|
||||
|
||||
@ -33,6 +33,19 @@ Button 和 Checkbox 都是受控组件。`etaf-button` 支持 `:label`、
|
||||
`checkbox` role、稳定的 `:ref` 和 `tab-index 0`;`:on-change` 接收下一个
|
||||
布尔值。禁用 checkbox 没有回调或 tab stop。
|
||||
|
||||
`etaf-number-input` 是一个受控的轻量输入组件;需要编辑数值时,它使用
|
||||
Emacs 原生 minibuffer。它支持 `:value`、`:label`、`:on-change`、`:min`、
|
||||
`:max`、`:ref` 和 `:disabled`,回调接收校验后的整数,ref 仍由调用方持有。
|
||||
|
||||
目录统一使用语义化的 Theme 色彩 vocabulary,主要 token 包括
|
||||
`:ui-fg`、`:ui-bg`、`:ui-border`、`:ui-muted-fg`、`:ui-disabled-*`、
|
||||
`:ui-button-primary-*`、`:ui-button-secondary-*`、`:ui-button-ghost-*`、
|
||||
`:ui-checkbox-*`、`:ui-grid-*` 和 `:ui-panel-*`。应用通过 ETAF 的
|
||||
`etaf-theme-provide` 提供这些 token;旧的 `:ui-button-color` 一类 alias
|
||||
仍兼容。`etaf-ui` 不依赖 TP,也不直接依赖 renderer-specific palette
|
||||
registry;如果应用想使用 TP 的亮/暗 palette 数据,应先通过可选的
|
||||
`etaf-theme-tp` adapter 转成同一份 ETAF Theme plist,再交给 UI 目录。
|
||||
|
||||
Component 的 `:styles` 声明负责默认外观。调用者提供的非 nil presentation
|
||||
属性会覆盖默认值;省略或传 nil 时,继承的 Theme/默认样式仍可生效。Button
|
||||
只提供刻意收敛的 `primary`、`secondary`、`ghost` 三种视觉 variant;这是
|
||||
|
||||
379
etaf-ui.el
379
etaf-ui.el
@ -24,6 +24,7 @@
|
||||
(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 etaf-theme-defaults "etaf-context" (&optional default))
|
||||
(declare-function text "etaf-view" (&rest arguments))
|
||||
(declare-function row "etaf-view" (&rest arguments))
|
||||
(declare-function column "etaf-view" (&rest arguments))
|
||||
@ -31,6 +32,110 @@
|
||||
(declare-function expr "etaf-view" (&rest arguments))
|
||||
(declare-function slot "etaf-view" (&rest arguments))
|
||||
|
||||
(defconst etaf-ui--default-theme-palette
|
||||
'(:ui-fg "#252A2E"
|
||||
:ui-bg "#FFFDF8"
|
||||
:ui-border "#687386"
|
||||
:ui-muted-fg "#526174"
|
||||
:ui-danger-fg "#FF6B6B"
|
||||
:ui-success-fg "#2F6B43"
|
||||
:ui-disabled-fg "#687386"
|
||||
:ui-disabled-bg "#E5E7EB"
|
||||
:ui-disabled-border "#9CA3AF"
|
||||
:ui-button-primary-fg "#FFFFFF"
|
||||
:ui-button-primary-bg "#2F6B43"
|
||||
:ui-button-primary-border "#2F6B43"
|
||||
:ui-button-secondary-fg "#142235"
|
||||
:ui-button-secondary-bg "#D9EEEA"
|
||||
:ui-button-secondary-border "#2E8B83"
|
||||
:ui-button-ghost-fg "#142235"
|
||||
:ui-button-ghost-bg "#FFFDF8"
|
||||
:ui-button-ghost-border "#C8C1B6"
|
||||
:ui-checkbox-enabled-fg "#252A2E"
|
||||
:ui-checkbox-enabled-bg "#DCEBDD"
|
||||
:ui-checkbox-enabled-border "#6D8A73"
|
||||
:ui-checkbox-disabled-fg "#6B7280"
|
||||
:ui-checkbox-disabled-bg "#EEEAE2"
|
||||
:ui-checkbox-disabled-border "#9CA3AF"
|
||||
:ui-grid-border "#687386"
|
||||
:ui-grid-selected-fg "#2F6B43"
|
||||
:ui-grid-selected-bg "#DCEBDD"
|
||||
:ui-grid-error-fg "#FF6B6B"
|
||||
:ui-pagination-muted-fg "#526174"
|
||||
:ui-panel-fg "#252A2E"
|
||||
:ui-panel-bg "#FFFDF8"
|
||||
:ui-panel-border "#687386")
|
||||
"Default semantic UI palette, centralized outside Component definitions.
|
||||
|
||||
Applications normally override these tokens through ETAF Theme. Keeping the
|
||||
fallback palette here gives the catalog a useful standalone appearance while
|
||||
ensuring every Component reads one shared semantic vocabulary.")
|
||||
|
||||
(defconst etaf-ui--legacy-theme-aliases
|
||||
'((:ui-fg :color)
|
||||
(:ui-bg :bgcolor)
|
||||
(:ui-border :border)
|
||||
(:ui-button-primary-fg :ui-button-color)
|
||||
(:ui-button-primary-bg :ui-button-bgcolor)
|
||||
(:ui-button-primary-border :ui-button-border)
|
||||
(:ui-button-secondary-fg :ui-button-secondary-color)
|
||||
(:ui-button-secondary-bg :ui-button-secondary-bgcolor)
|
||||
(:ui-button-secondary-border :ui-button-secondary-border)
|
||||
(:ui-button-ghost-fg :ui-button-ghost-color)
|
||||
(:ui-button-ghost-bg :ui-button-ghost-bgcolor)
|
||||
(:ui-button-ghost-border :ui-button-ghost-border)
|
||||
(:ui-disabled-fg :ui-button-disabled-color)
|
||||
(:ui-disabled-bg :ui-button-disabled-bgcolor)
|
||||
(:ui-disabled-border :ui-button-disabled-border))
|
||||
"Compatibility aliases for the first ETAF UI Theme token spelling.")
|
||||
|
||||
;;;###autoload
|
||||
(defun etaf-ui-theme-values (&rest requested)
|
||||
"Return merged semantic UI Theme values for REQUESTED tokens.
|
||||
|
||||
Inherited application tokens win, legacy aliases remain readable, and the
|
||||
central catalog palette fills only omitted values. This is the boundary
|
||||
between generic ETAF Theme Context and etaf-ui's product-independent visual
|
||||
semantics; individual Components do not own separate color systems. When
|
||||
REQUESTED is nil, return the complete catalog token map."
|
||||
(let* ((inherited (etaf-theme-defaults))
|
||||
(defaults etaf-ui--default-theme-palette)
|
||||
;; Callers that request a subset only need that semantic subset.
|
||||
;; Keeping the full inherited plist is useful for the no-argument
|
||||
;; catalog query, but copying it for every Button/Panel/DataGrid
|
||||
;; render needlessly scales Theme work with application token count.
|
||||
(result (unless requested (copy-sequence inherited)))
|
||||
(keys (or requested
|
||||
(cl-loop for (key _spec) on defaults by #'cddr
|
||||
collect key))))
|
||||
(dolist (key keys)
|
||||
(let ((found
|
||||
(cond
|
||||
((plist-member inherited key)
|
||||
(cons t (plist-get inherited key)))
|
||||
(t
|
||||
(cl-loop for alias in etaf-ui--legacy-theme-aliases
|
||||
when (and (eq (car alias) key)
|
||||
(plist-member inherited (cadr alias)))
|
||||
return
|
||||
(cons t (plist-get inherited (cadr alias))))))))
|
||||
(setq result
|
||||
(plist-put result key
|
||||
(if found (cdr found) (plist-get defaults key)))))
|
||||
)
|
||||
result))
|
||||
|
||||
(defun etaf-ui--theme-border (value)
|
||||
"Return Ebox border VALUE, preserving complete caller-owned specs.
|
||||
|
||||
Semantic Theme border tokens conventionally contain a color string. The
|
||||
catalog turns a hex color into its one-cell border shape; an existing border
|
||||
plist or a legacy caller-owned string remains untouched for compatibility."
|
||||
(if (and (stringp value)
|
||||
(string-match-p "\\`#[[:xdigit:]]+\\'" value))
|
||||
(list (list 1) 'solid value)
|
||||
value))
|
||||
|
||||
(defun etaf-ui--class-value (base state custom)
|
||||
"Return BASE and STATE classes with optional CUSTOM classes."
|
||||
(let ((custom (cond
|
||||
@ -56,6 +161,26 @@ the optional text properties and keep the semantic role/event contract."
|
||||
'mouse-face 'highlight
|
||||
'help-echo (format "%s · RET or mouse-1" (or label "Activate")))))
|
||||
|
||||
(defun etaf-ui--button-variant-values (variant disabled)
|
||||
"Return themed presentation defaults for Button VARIANT and DISABLED."
|
||||
(let* ((fg (cond (disabled :ui-disabled-fg)
|
||||
((eq variant 'secondary) :ui-button-secondary-fg)
|
||||
((eq variant 'ghost) :ui-button-ghost-fg)
|
||||
(t :ui-button-primary-fg)))
|
||||
(bg (cond (disabled :ui-disabled-bg)
|
||||
((eq variant 'secondary) :ui-button-secondary-bg)
|
||||
((eq variant 'ghost) :ui-button-ghost-bg)
|
||||
(t :ui-button-primary-bg)))
|
||||
(border (cond (disabled :ui-disabled-border)
|
||||
((eq variant 'secondary) :ui-button-secondary-border)
|
||||
((eq variant 'ghost) :ui-button-ghost-border)
|
||||
(t :ui-button-primary-border)))
|
||||
(theme (etaf-ui-theme-values fg bg border)))
|
||||
(list :color (plist-get theme fg)
|
||||
:bgcolor (plist-get theme bg)
|
||||
:border (etaf-ui--theme-border (plist-get theme border))
|
||||
:face (if (or disabled (eq variant 'ghost)) 'normal 'bold))))
|
||||
|
||||
(defun etaf-ui--button-view
|
||||
(label on-press disabled ref class color bgcolor border padding face
|
||||
tab-index aria-label use)
|
||||
@ -88,6 +213,18 @@ TAB-INDEX, and ARIA-LABEL provide Host identity and presentation."
|
||||
:surface-properties surface
|
||||
:use (unless disabled use) (expr :value label))))))
|
||||
|
||||
(defun etaf-ui--checkbox-variant-values (theme disabled)
|
||||
"Return semantic Theme values from THEME for DISABLED Checkbox state."
|
||||
(let ((prefix (if disabled "disabled" "enabled")))
|
||||
(list :color (plist-get theme
|
||||
(intern (format ":ui-checkbox-%s-fg" prefix)))
|
||||
:bgcolor (plist-get theme
|
||||
(intern (format ":ui-checkbox-%s-bg" prefix)))
|
||||
:border
|
||||
(etaf-ui--theme-border
|
||||
(plist-get theme
|
||||
(intern (format ":ui-checkbox-%s-border" prefix)))))))
|
||||
|
||||
(defun etaf-ui--column-value (column key)
|
||||
"Return KEY from COLUMN, accepting a plist or alist descriptor."
|
||||
(if (and (listp column) (keywordp (car column)))
|
||||
@ -142,10 +279,12 @@ too small; the original ROW remains intact for selection and callbacks."
|
||||
(etaf-ui--column-value column :key))
|
||||
column)))))
|
||||
|
||||
(defun etaf-ui--grid-header (columns)
|
||||
"Return a View header row for COLUMNS."
|
||||
(defun etaf-ui--grid-header (columns theme)
|
||||
"Return a View header row for COLUMNS using semantic THEME colors."
|
||||
(etaf-view
|
||||
(row :class "etaf-data-grid-header"
|
||||
:border (etaf-ui--theme-border
|
||||
(plist-get theme :ui-grid-border))
|
||||
(expr :value
|
||||
(cl-loop for column in columns
|
||||
for tail on columns
|
||||
@ -186,8 +325,8 @@ too small; the original ROW remains intact for selection and callbacks."
|
||||
|
||||
(defun etaf-ui--grid-row
|
||||
(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-actions theme)
|
||||
"Return a View row for ROW and COLUMNS using THEME and the DataGrid contract.
|
||||
|
||||
ROW-KEY returns identity; ROW-REF returns the interactive reference;
|
||||
ON-ROW-PRESS, SELECTED-KEY, and ROW-SELECTED-P control state. ROW-ACTIONS
|
||||
@ -212,29 +351,35 @@ owns stable keyed callbacks across Range reevaluation."
|
||||
: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))
|
||||
:border-bottom-p t
|
||||
:border-bottom-color (plist-get theme :ui-grid-border)
|
||||
:on-press (when interactive-p
|
||||
(etaf-ui--grid-row-action
|
||||
row-actions key row on-row-press))
|
||||
:bgcolor (when selected-p
|
||||
(plist-get theme :ui-grid-selected-bg))
|
||||
(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)
|
||||
row-actions &optional theme)
|
||||
"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)))
|
||||
(let* ((theme (or theme
|
||||
;; A Range owns the data rows, so read Theme once here to
|
||||
;; keep palette changes reactive without doing it per row.
|
||||
(etaf-ui-theme-values :ui-grid-border
|
||||
:ui-grid-selected-fg
|
||||
:ui-grid-selected-bg)))
|
||||
(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))
|
||||
row-selected-p row-actions theme))
|
||||
items)
|
||||
(maphash (lambda (key _entry)
|
||||
(unless (gethash key seen)
|
||||
@ -261,22 +406,7 @@ SELECTED-KEY, and ROW-SELECTED-P provide interaction state."
|
||||
current-press-p press-p)
|
||||
(let* ((variant (and (not disabled) (etaf-current-prop :variant)))
|
||||
(variant-values
|
||||
(cond
|
||||
(disabled
|
||||
'(:color "#687386" :bgcolor "#E5E7EB"
|
||||
:border ((1) solid "#9CA3AF") :face normal))
|
||||
((eq variant 'secondary)
|
||||
'(:color "#142235" :bgcolor "#D9EEEA"
|
||||
:border ((1) solid "#2E8B83") :face bold))
|
||||
((eq variant 'ghost)
|
||||
'(:color "#142235" :bgcolor "#FFFDF8"
|
||||
:border ((1) solid "#C8C1B6") :face normal))
|
||||
;; The enabled catalog defaults used to come from state
|
||||
;; selectors alone. Resolve them as props too so an
|
||||
;; explicit theme can override the same state boundary.
|
||||
(t
|
||||
'(:color "#FFFFFF" :bgcolor "#2F6B43"
|
||||
:border ((1) solid "#2F6B43") :face bold)))))
|
||||
(etaf-ui--button-variant-values variant disabled)))
|
||||
(etaf-ui--button-view
|
||||
label (and press-p press) disabled (etaf-current-prop :ref)
|
||||
(etaf-current-prop :class)
|
||||
@ -313,6 +443,42 @@ contract; callers can still override presentation with the ordinary props."
|
||||
:setup
|
||||
(etaf-ui--button-setup))
|
||||
|
||||
(etaf-define-component etaf-number-input
|
||||
(&key value label on-change ref disabled min max aria-label)
|
||||
"Render a controlled minibuffer-backed numeric input.
|
||||
|
||||
VALUE is displayed as a Button. Activating it reads a number through
|
||||
Emacs's native minibuffer, validates optional MIN and MAX bounds, and calls
|
||||
ON-CHANGE with the accepted integer. The Component owns prompting and
|
||||
validation; the caller owns the value and subsequent state write."
|
||||
:setup
|
||||
(let ((callback (etaf-current-prop :on-change)))
|
||||
(lambda ()
|
||||
(let* ((value (etaf-current-prop :value))
|
||||
(label (or (etaf-current-prop :label) "Value"))
|
||||
(min-value (etaf-current-prop :min))
|
||||
(max-value (etaf-current-prop :max))
|
||||
(disabled (etaf-current-prop :disabled))
|
||||
(ref (etaf-current-prop :ref))
|
||||
(aria-label (etaf-current-prop :aria-label)))
|
||||
(etaf-view
|
||||
(button
|
||||
:label (format "%s %s ✎" label (or value "—"))
|
||||
:ref ref :disabled disabled
|
||||
:aria-label (or aria-label label)
|
||||
:variant 'ghost
|
||||
:on-press
|
||||
(unless disabled
|
||||
(lambda ()
|
||||
(let ((next (read-number
|
||||
(format "%s: " label) (or value 0))))
|
||||
(unless (and (integerp next)
|
||||
(or (null min-value) (>= next min-value))
|
||||
(or (null max-value) (<= next max-value)))
|
||||
(user-error "%s must be an integer from %s to %s"
|
||||
label (or min-value "—") (or max-value "—")))
|
||||
(when callback (funcall callback next)))))))))))
|
||||
|
||||
(defun etaf-ui--checkbox-view
|
||||
(checked label on-change ref disabled class color bgcolor border padding
|
||||
face tab-index aria-label)
|
||||
@ -343,10 +509,10 @@ caller, so the Component works with local refs or Data-backed forms."
|
||||
: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))
|
||||
;; Color, background, and border are resolved through the shared semantic
|
||||
;; Theme map in :setup; styles keep only geometry defaults.
|
||||
("&.disabled" :padding (0 1))
|
||||
("&.enabled" :padding (0 1))
|
||||
(".etaf-checkbox-mark" :face bold :width 1))
|
||||
:setup
|
||||
(let* ((current-checked nil)
|
||||
@ -356,19 +522,34 @@ caller, so the Component works with local refs or Data-backed forms."
|
||||
(when current-callback
|
||||
(funcall current-callback (not current-checked))))))
|
||||
(lambda ()
|
||||
(setq current-checked (etaf-current-prop :checked)
|
||||
(let* ((theme (etaf-ui-theme-values :ui-fg :ui-bg
|
||||
:ui-checkbox-enabled-fg
|
||||
:ui-checkbox-enabled-bg
|
||||
:ui-checkbox-enabled-border
|
||||
:ui-checkbox-disabled-fg
|
||||
:ui-checkbox-disabled-bg
|
||||
:ui-checkbox-disabled-border))
|
||||
(variant-values
|
||||
(etaf-ui--checkbox-variant-values
|
||||
theme (etaf-current-prop :disabled))))
|
||||
(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)))))
|
||||
(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)
|
||||
(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) (etaf-current-prop :face)
|
||||
(etaf-current-prop :tab-index) (etaf-current-prop :aria-label))))))
|
||||
|
||||
;;;###autoload
|
||||
(etaf-define-component etaf-label
|
||||
@ -381,29 +562,39 @@ caller, so the Component works with local refs or Data-backed forms."
|
||||
(text :class class :face face :color color :bgcolor bgcolor :border border
|
||||
:padding padding :ref ref :width width (expr :value text)))))
|
||||
|
||||
(defun etaf-ui--panel-view (title class color bgcolor border padding ref)
|
||||
"Render a themed Panel View.
|
||||
Arguments are TITLE, CLASS, COLOR, BGCOLOR, BORDER, PADDING, and REF."
|
||||
(let ((theme (etaf-ui-theme-values :ui-panel-fg :ui-panel-bg
|
||||
:ui-panel-border)))
|
||||
(etaf-view
|
||||
(column
|
||||
:class (etaf-ui--class-value "etaf-panel" nil class)
|
||||
:color (or color (plist-get theme :ui-panel-fg))
|
||||
:bgcolor (or bgcolor (plist-get theme :ui-panel-bg))
|
||||
:border (or border
|
||||
(etaf-ui--theme-border
|
||||
(plist-get theme :ui-panel-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-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")
|
||||
:color "#252A2E" :bgcolor "#FFFDF8")
|
||||
("&" :padding (1 2))
|
||||
(".etaf-panel-title" :face bold))
|
||||
:view
|
||||
(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)))))
|
||||
(expr :value
|
||||
(etaf-ui--panel-view title class color bgcolor border padding ref)))
|
||||
|
||||
;;;###autoload
|
||||
(etaf-define-component etaf-data-grid
|
||||
@ -418,32 +609,43 @@ errors, pagination, mutation, and selection; this Component only projects
|
||||
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"))
|
||||
(styles
|
||||
(".etaf-data-grid-header" :face bold :padding (0 1))
|
||||
(".etaf-data-grid-header-cell" :face bold)
|
||||
(".etaf-data-grid-row" :padding (0 1)
|
||||
:border ((1) solid "#687386"))
|
||||
(".selected" :color "#2F6B43"
|
||||
:border ((1) solid "#73A982"))
|
||||
(".etaf-data-grid-error" :color "#FF6B6B"))
|
||||
(".etaf-data-grid-row" :padding (0 1))
|
||||
;; Selection color and error color are dynamic semantic props below, so
|
||||
;; this Component style scope contains geometry only.
|
||||
)
|
||||
: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)))
|
||||
(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))
|
||||
;; Resolve the shared semantic UI Theme once at the DataGrid
|
||||
;; boundary. Rows reuse this immutable snapshot instead of
|
||||
;; allocating or resolving border values independently during
|
||||
;; every retained Range update.
|
||||
(raw-theme (etaf-ui-theme-values :ui-fg
|
||||
:ui-grid-border
|
||||
:ui-grid-selected-bg))
|
||||
(theme (plist-put (copy-sequence raw-theme)
|
||||
:ui-grid-border
|
||||
(etaf-ui--theme-border
|
||||
(plist-get raw-theme :ui-grid-border))))
|
||||
(theme-color (plist-get theme :ui-fg)))
|
||||
(etaf-view
|
||||
(column
|
||||
:class "etaf-data-grid"
|
||||
(expr :value (etaf-ui--grid-header columns))
|
||||
:class "etaf-data-grid" :color theme-color
|
||||
(expr :value (etaf-ui--grid-header
|
||||
columns theme))
|
||||
(column
|
||||
:class "etaf-data-grid-body"
|
||||
(expr
|
||||
@ -466,12 +668,18 @@ a stable Host reference."
|
||||
((eq status 'error)
|
||||
(etaf-view
|
||||
(text :class "etaf-data-grid-error"
|
||||
:color (plist-get (etaf-ui-theme-values
|
||||
:ui-grid-error-fg)
|
||||
:ui-grid-error-fg)
|
||||
(expr :value
|
||||
(or error-label "Unable to load data.")))))
|
||||
((null items)
|
||||
(etaf-view
|
||||
(text (expr :value (or empty-label "No data.")))))
|
||||
(t
|
||||
;; Read Theme inside this Range's evaluator so retained row
|
||||
;; Hosts follow palette changes without requiring the parent
|
||||
;; DataGrid Component to be rebuilt.
|
||||
(etaf-ui--grid-rows
|
||||
items columns row-key row-ref on-row-press selected-key
|
||||
row-selected-p row-actions))))))
|
||||
@ -491,8 +699,7 @@ readable in both GUI and text review."
|
||||
:styles
|
||||
(styles
|
||||
("&" :width stretch)
|
||||
(".etaf-pagination-label" :face bold)
|
||||
(".etaf-pagination-summary" :color "#526174"))
|
||||
(".etaf-pagination-label" :face bold))
|
||||
:setup
|
||||
(let* ((current-controller nil)
|
||||
(previous
|
||||
@ -514,8 +721,13 @@ readable in both GUI and text review."
|
||||
(last-item (min total (* page page-size)))
|
||||
(previous-disabled (or (eq status 'loading) (<= page 1)))
|
||||
(next-disabled (or (eq status 'loading) (>= page pages)))
|
||||
(parent-color (etaf-current-prop :color))
|
||||
(parent-bgcolor (etaf-current-prop :bgcolor))
|
||||
(theme (etaf-ui-theme-values :ui-fg :ui-bg
|
||||
:ui-disabled-fg
|
||||
:ui-pagination-muted-fg))
|
||||
(parent-color (or (etaf-current-prop :color)
|
||||
(plist-get theme :ui-fg)))
|
||||
(parent-bgcolor (or (etaf-current-prop :bgcolor)
|
||||
(plist-get theme :ui-bg)))
|
||||
(arrow-border '((0) solid "transparent")))
|
||||
(setq current-controller controller-value)
|
||||
(etaf-view
|
||||
@ -539,7 +751,9 @@ readable in both GUI and text review."
|
||||
:disabled previous-disabled
|
||||
:padding '(0 0)
|
||||
:border arrow-border
|
||||
:color (if previous-disabled "#9CA3AF" parent-color)
|
||||
:color (if previous-disabled
|
||||
(plist-get theme :ui-disabled-fg)
|
||||
parent-color)
|
||||
:bgcolor parent-bgcolor
|
||||
:face 'bold
|
||||
:on-press (unless previous-disabled previous)))
|
||||
@ -547,6 +761,7 @@ readable in both GUI and text review."
|
||||
(text :class "etaf-pagination-label" :text-align 'center
|
||||
(expr :value (format "Page %d / %d" page pages)))
|
||||
(text :class "etaf-pagination-summary" :text-align 'center
|
||||
:color (plist-get theme :ui-pagination-muted-fg)
|
||||
(expr :value (format "%d–%d of %d"
|
||||
first-item last-item total))))
|
||||
(column :width 'max-content
|
||||
@ -556,7 +771,9 @@ readable in both GUI and text review."
|
||||
:disabled next-disabled
|
||||
:padding '(0 0)
|
||||
:border arrow-border
|
||||
:color (if next-disabled "#9CA3AF" parent-color)
|
||||
:color (if next-disabled
|
||||
(plist-get theme :ui-disabled-fg)
|
||||
parent-color)
|
||||
:bgcolor parent-bgcolor
|
||||
:face 'bold
|
||||
:on-press (unless next-disabled next)))))))))
|
||||
|
||||
@ -19,7 +19,17 @@
|
||||
(let (found)
|
||||
(maphash
|
||||
(lambda (_ref props)
|
||||
(when (equal (plist-get props :key) key)
|
||||
(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--props-with-class (buffer-name class)
|
||||
"Return mounted Host properties with CLASS in BUFFER-NAME."
|
||||
(let (found)
|
||||
(maphash
|
||||
(lambda (_ref props)
|
||||
(when (equal (plist-get props :class) class)
|
||||
(setq found props)))
|
||||
(etaf-runtime-host-props (etaf-runtime-for-buffer buffer-name)))
|
||||
found))
|
||||
@ -60,6 +70,38 @@
|
||||
:color nil :bgcolor nil)
|
||||
(panel :title "Styled panel" :ref 'styled-panel))))))
|
||||
|
||||
(etaf-define-component etaf-ui-test-token-theme-fixture ()
|
||||
"Provide explicit UI Button tokens through ETAF Theme."
|
||||
:setup
|
||||
(progn
|
||||
(etaf-theme-provide
|
||||
'(:ui-button-color "token-fg"
|
||||
:ui-button-bgcolor "token-bg"
|
||||
:ui-button-border "token-border"
|
||||
:ui-button-secondary-color "secondary-fg"
|
||||
:ui-button-secondary-bgcolor "secondary-bg"
|
||||
:ui-button-secondary-border "secondary-border"))
|
||||
(lambda ()
|
||||
(etaf-view
|
||||
(row
|
||||
(button :label "Token" :ref 'token-button)
|
||||
(button :label "Secondary" :ref 'token-secondary
|
||||
:variant 'secondary))))))
|
||||
|
||||
(etaf-define-component etaf-ui-test-grid-theme-fixture
|
||||
(&key controller theme)
|
||||
"Provide a reactive Theme around one DataGrid for palette tests."
|
||||
:setup
|
||||
(progn
|
||||
(etaf-theme-provide (etaf-current-prop :theme))
|
||||
(lambda ()
|
||||
(etaf-view
|
||||
(data-grid
|
||||
:controller (etaf-current-prop :controller)
|
||||
:columns '((:key :id :label "ID") (:key :name :label "Name"))
|
||||
:row-key (lambda (row) (plist-get row :id))
|
||||
:selected-key 1)))))
|
||||
|
||||
(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*"))
|
||||
@ -106,6 +148,49 @@
|
||||
(when-let ((buffer (get-buffer buffer-name)))
|
||||
(kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest etaf-ui-button-reads-variant-tokens-from-theme ()
|
||||
"Resolve Button presentation defaults from inherited Theme tokens."
|
||||
(let ((buffer-name " *etaf-ui-button-token-theme-test*"))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(etaf-mount buffer-name
|
||||
(etaf-view (ui-test-token-theme-fixture)))
|
||||
(let ((token (etaf-ui-test--props buffer-name 'token-button))
|
||||
(secondary (etaf-ui-test--props buffer-name 'token-secondary)))
|
||||
(should (equal "token-fg" (plist-get token :color)))
|
||||
(should (equal "token-bg" (plist-get token :bgcolor)))
|
||||
(should (equal "token-border" (plist-get token :border)))
|
||||
(should (equal "secondary-fg" (plist-get secondary :color)))
|
||||
(should (equal "secondary-bg" (plist-get secondary :bgcolor)))
|
||||
(should (equal "secondary-border"
|
||||
(plist-get secondary :border)))))
|
||||
(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-number-input-validates-and-emits-value ()
|
||||
"Prompt-backed number input owns bounds and emits the accepted value."
|
||||
(let ((buffer-name " *etaf-ui-number-input-test*")
|
||||
(value 3))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(etaf-mount
|
||||
buffer-name
|
||||
(etaf-view
|
||||
(number-input :label "Rows" :value value :ref 'rows
|
||||
:min 1 :max 10
|
||||
:on-change (lambda (next) (setq value next)))))
|
||||
(cl-letf (((symbol-function 'read-number)
|
||||
(lambda (&rest _args) 7)))
|
||||
(etaf-dispatch-event (etaf-runtime-for-buffer buffer-name)
|
||||
'rows 'press))
|
||||
(should (= 7 value)))
|
||||
(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*")
|
||||
@ -409,6 +494,48 @@
|
||||
(when-let ((buffer (get-buffer buffer-name)))
|
||||
(kill-buffer buffer)))))
|
||||
|
||||
(ert-deftest etaf-ui-data-grid-follows-inherited-theme-color ()
|
||||
"Re-render DataGrid rows when only inherited Theme color changes."
|
||||
(let* ((theme (etaf-ref '(:color "light-ink"
|
||||
:ui-grid-border "light-grid-border"
|
||||
:ui-grid-selected-fg "light-selected"
|
||||
:ui-grid-selected-bg "light-selected-bg")))
|
||||
(source (etaf-data-memory-source
|
||||
'((:id 1 :name "Ada") (:id 2 :name "Grace")) :id-key :id))
|
||||
(controller (etaf-data-controller source :auto-load t))
|
||||
(buffer-name " *etaf-ui-grid-theme-test*"))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(etaf-mount
|
||||
buffer-name
|
||||
(etaf-view
|
||||
(ui-test-grid-theme-fixture :controller controller :theme theme)))
|
||||
(should (equal "light-selected-bg"
|
||||
(plist-get (etaf-ui-test--props-with-key buffer-name 1)
|
||||
:bgcolor)))
|
||||
(should (equal "light-grid-border"
|
||||
(plist-get (etaf-ui-test--props-with-key
|
||||
buffer-name 1)
|
||||
:border-bottom-color)))
|
||||
(should (plist-get (etaf-ui-test--props-with-key buffer-name 1)
|
||||
:border-bottom-p))
|
||||
(setf (etaf-value theme) '(:color "dark-ink"
|
||||
:ui-grid-border "dark-grid-border"
|
||||
:ui-grid-selected-fg "dark-selected"
|
||||
:ui-grid-selected-bg "dark-selected-bg"))
|
||||
(should (equal "dark-selected-bg"
|
||||
(plist-get (etaf-ui-test--props-with-key buffer-name 1)
|
||||
:bgcolor)))
|
||||
(should (equal "dark-grid-border"
|
||||
(plist-get (etaf-ui-test--props-with-key
|
||||
buffer-name 1)
|
||||
:border-bottom-color))))
|
||||
(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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user