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:
21
AGENTS.md
21
AGENTS.md
@@ -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
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -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])]
|
||||||
|
|||||||
Reference in New Issue
Block a user