refactor(form): replace inline error text with tooltip icon

Instead of rendering error messages as `<small class="form-error">`
below inputs, errors now display as a circle-x icon inside the input
area. Hovering the icon shows the error text in a danger-styled tooltip.

- Wrap children in `.form-field-control` when error is present
- Use existing tooltip + icon components for the error indicator
- Add CSS for positioning, padding-right offset, and danger color overrides
- Update tests to match new structure
- Add pre-commit tmux server check instructions to AGENTS.md
This commit is contained in:
Florian Schroedl
2026-03-05 14:01:26 +01:00
parent d5473b1bbf
commit b52361ebf1
4 changed files with 105 additions and 17 deletions

View File

@@ -190,9 +190,28 @@ Add the component to all three dev targets so it renders in the visual test page
```sh ```sh
bb build-theme # Regenerate CSS with new component styles bb build-theme # Regenerate CSS with new component styles
bb test # All tests pass bb test # All tests pass
bb dev-hiccup # Visual check
``` ```
### 6. Check running dev servers before committing — CRITICAL
A tmux session `ui-dev` runs all three dev servers (`bb dev-all`). **Always check every pane for compile errors before committing:**
```sh
# List panes, then check each for errors
tmux list-panes -t ui-dev -F "#{pane_index}: #{pane_current_command}"
for i in $(tmux list-panes -t ui-dev -F "#{pane_index}"); do
echo "=== pane $i ==="
tmux capture-pane -t "ui-dev:bash.$i" -p -S -30 | grep -v '^$' | tail -10
done
```
Look for:
- **shadow-cljs** (Replicant): `Build failure`, warnings, or `CompilerException`
- **Vite/Squint**: `ERROR`, `SyntaxError`, or failed imports
- **Hiccup** (Babashka): stack traces or `Exception`
Do **not** commit if any pane shows errors. Fix them first.
## Theme System ## Theme System
### Token naming ### Token naming

View File

@@ -1,5 +1,7 @@
(ns ui.form (ns ui.form
(:require [clojure.string :as str])) (:require [clojure.string :as str]
[ui.icon :as icon]
[ui.tooltip :as tooltip]))
;; In squint, keywords are strings — name is identity ;; In squint, keywords are strings — name is identity
#?(:squint (defn- kw-name [s] s) #?(:squint (defn- kw-name [s] s)
@@ -19,6 +21,12 @@
[opts] [opts]
(str/join " " (form-field-class-list opts))) (str/join " " (form-field-class-list opts)))
(defn- error-icon
"Render a circle-x icon wrapped in a tooltip showing the error text."
[error]
(tooltip/tooltip {:text error :class "form-error-icon"}
(icon/icon {:icon-name :circle-x :size :sm})))
(defn form-field (defn form-field
"Render a form field wrapper with label, hint, and error support. "Render a form field wrapper with label, hint, and error support.
@@ -37,9 +45,11 @@
(cond-> (if label (cond-> (if label
[[:label {:class "form-label"} label]] [[:label {:class "form-label"} label]]
[]) [])
true (into children) true (into (if error
hint (conj [:small {:class "form-hint"} hint]) [(into [:div {:class "form-field-control"}]
error (conj [:small {:class "form-error"} error])))) (conj (vec children) (error-icon error)))]
children))
hint (conj [:small {:class "form-hint"} hint]))))
:cljs :cljs
(let [cls (form-field-class-list {:error error}) (let [cls (form-field-class-list {:error error})
@@ -49,9 +59,11 @@
(cond-> (if label (cond-> (if label
[[:label {:class ["form-label"]} label]] [[:label {:class ["form-label"]} label]]
[]) [])
true (into children) true (into (if error
hint (conj [:small {:class ["form-hint"]} hint]) [(into [:div {:class ["form-field-control"]}]
error (conj [:small {:class ["form-error"]} error])))) (conj (vec children) (error-icon error)))]
children))
hint (conj [:small {:class ["form-hint"]} hint]))))
:clj :clj
(let [classes (cond-> (form-field-classes {:error error}) (let [classes (cond-> (form-field-classes {:error error})
@@ -61,9 +73,11 @@
(cond-> (if label (cond-> (if label
[[:label {:class "form-label"} label]] [[:label {:class "form-label"} label]]
[]) [])
true (into children) true (into (if error
hint (conj [:small {:class "form-hint"} hint]) [(into [:div {:class "form-field-control"}]
error (conj [:small {:class "form-error"} error])))))) (conj (vec children) (error-icon error)))]
children))
hint (conj [:small {:class "form-hint"} hint]))))))
;; ── Text input ────────────────────────────────────────────────────── ;; ── Text input ──────────────────────────────────────────────────────

View File

@@ -240,16 +240,63 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Hint & Error text ─────────────────────────────────────────── */ /* ── Hint text ─────────────────────────────────────────────────── */
.form-hint { .form-hint {
font-size: var(--font-xs); font-size: var(--font-xs);
color: var(--fg-2); color: var(--fg-2);
} }
.form-error { /* ── Error icon with tooltip ───────────────────────────────────── */
font-size: var(--font-xs);
.form-field-control {
position: relative;
}
.form-field-control > .form-input,
.form-field-control > .form-textarea,
.form-field-control > .form-select {
padding-right: var(--size-8);
}
.tooltip.form-error-icon {
position: absolute;
right: var(--size-2);
top: 0;
bottom: 0;
display: flex;
align-items: center;
color: var(--danger); color: var(--danger);
cursor: pointer;
z-index: 1;
}
.tooltip.form-error-icon::after {
background: color-mix(in srgb, var(--danger) 18%, var(--bg-1));
color: var(--fg-0);
border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
white-space: normal;
min-width: 12rem;
max-width: 20rem;
line-height: 1.4;
left: auto;
right: 0;
transform: translateY(4px);
}
.tooltip.form-error-icon:hover::after {
transform: translateY(0);
}
.tooltip.form-error-icon::before {
border-top-color: color-mix(in srgb, var(--danger) 18%, var(--bg-1));
left: auto;
right: var(--size-1);
transform: translateY(4px);
}
.tooltip.form-error-icon:hover::before {
transform: translateY(0);
} }
/* ── Fieldset & Legend ──────────────────────────────────────────── */ /* ── Fieldset & Legend ──────────────────────────────────────────── */

View File

@@ -32,11 +32,19 @@
(is (some #(and (vector? %) (= :small (first %)) (= "form-hint" (get-in % [1 :class]))) (is (some #(and (vector? %) (= :small (first %)) (= "form-hint" (get-in % [1 :class])))
result)))) result))))
(testing "renders error text" (testing "renders error icon with tooltip"
(let [result (form/form-field {:label "Email" :error "Invalid email"} [:input])] (let [result (form/form-field {:label "Email" :error "Invalid email"} [:input])]
(is (= "form-field form-field--error" (get-in result [1 :class]))) (is (= "form-field form-field--error" (get-in result [1 :class])))
(is (some #(and (vector? %) (= :small (first %)) (= "form-error" (get-in % [1 :class]))) ;; Children wrapped in form-field-control
result)))) (let [control (nth result 3)]
(is (= :div (first control)))
(is (= "form-field-control" (get-in control [1 :class])))
;; Contains the child input
(is (= :input (first (nth control 2))))
;; Contains the tooltip with error text
(let [tip (nth control 3)]
(is (= :span (first tip)))
(is (= "Invalid email" (get-in tip [1 :data-tooltip])))))))
(testing "no label renders without label element" (testing "no label renders without label element"
(let [result (form/form-field {} [:input])] (let [result (form/form-field {} [:input])]