feat(button): add link variant and polymorphic <a> rendering
When :href is provided, the button renders as <a> instead of <button>, enabling navigation links with full button styling. New :link variant renders as a minimal text link (underlined, accent color, no background/padding) — works as both <button> and <a>. CSS includes reset styles for a.btn (removes default link decoration) and preserves underline for a.btn-link.
This commit is contained in:
@@ -25,40 +25,45 @@
|
||||
|
||||
(defn button
|
||||
"Render a button element. Works across all targets via reader conditionals.
|
||||
When :href is provided, renders as <a> instead of <button>.
|
||||
|
||||
Props:
|
||||
:variant - :primary, :secondary, :ghost, :danger
|
||||
:variant - :primary, :secondary, :ghost, :danger, :link
|
||||
:size - :sm, :md, :lg
|
||||
:href - URL string; when set, renders as <a> tag
|
||||
:on-click - click handler (ignored in :clj target)
|
||||
:disabled - boolean
|
||||
:class - additional CSS classes (string or vector)
|
||||
:attrs - additional HTML attributes map"
|
||||
[{:keys [variant size on-click disabled class attrs] :as _props} & children]
|
||||
[{:keys [variant size href on-click disabled class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (button-classes {:variant variant :size size})
|
||||
(let [tag (if href :a :button)
|
||||
classes (cond-> (button-classes {:variant variant :size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button (cond-> base-attrs
|
||||
on-click (assoc :on-click on-click))]
|
||||
base-attrs (cond-> (merge {:class classes} attrs)
|
||||
href (assoc :href href)
|
||||
disabled (assoc :disabled true))]
|
||||
(into [tag (cond-> base-attrs
|
||||
on-click (assoc :on-click on-click))]
|
||||
children))
|
||||
|
||||
:cljs
|
||||
(let [cls (button-class-list {:variant variant :size size})
|
||||
(let [tag (if href :a :button)
|
||||
cls (button-class-list {:variant variant :size size})
|
||||
classes (cond-> cls
|
||||
class (conj class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button (cond-> base-attrs
|
||||
on-click (assoc-in [:on :click] on-click))]
|
||||
base-attrs (cond-> (merge {:class classes} attrs)
|
||||
href (assoc :href href)
|
||||
disabled (assoc :disabled true))]
|
||||
(into [tag (cond-> base-attrs
|
||||
on-click (assoc-in [:on :click] on-click))]
|
||||
children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (button-classes {:variant variant :size size})
|
||||
(let [tag (if href :a :button)
|
||||
classes (cond-> (button-classes {:variant variant :size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button base-attrs] children))))
|
||||
base-attrs (cond-> (merge {:class classes} attrs)
|
||||
href (assoc :href href)
|
||||
disabled (assoc :disabled true))]
|
||||
(into [tag base-attrs] children))))
|
||||
|
||||
@@ -46,6 +46,28 @@
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: var(--size-1);
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
.btn-link:hover:not(:disabled) {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
/* Link buttons rendered as <a> need reset styles */
|
||||
a.btn {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
a.btn-link {
|
||||
text-decoration: underline;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--size-1) var(--size-2);
|
||||
font-size: var(--font-xs);
|
||||
|
||||
458
src/ui/form.cljc
Normal file
458
src/ui/form.cljc
Normal file
@@ -0,0 +1,458 @@
|
||||
(ns ui.form
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
;; In squint, keywords are strings — name is identity
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
;; ── Form field wrapper ──────────────────────────────────────────────
|
||||
|
||||
(defn form-field-class-list
|
||||
"Returns a vector of CSS class strings for a form field wrapper."
|
||||
[{:keys [error]}]
|
||||
(cond-> ["form-field"]
|
||||
error (conj "form-field--error")))
|
||||
|
||||
(defn form-field-classes
|
||||
"Returns a space-joined class string for a form field wrapper."
|
||||
[opts]
|
||||
(str/join " " (form-field-class-list opts)))
|
||||
|
||||
(defn form-field
|
||||
"Render a form field wrapper with label, hint, and error support.
|
||||
|
||||
Props:
|
||||
:label - label text
|
||||
:hint - hint text shown below input
|
||||
:error - error text shown below input (also sets error state)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [label hint error class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (form-field-classes {:error error})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> (if label
|
||||
[[:label {:class "form-label"} label]]
|
||||
[])
|
||||
true (into children)
|
||||
hint (conj [:small {:class "form-hint"} hint])
|
||||
error (conj [:small {:class "form-error"} error]))))
|
||||
|
||||
:cljs
|
||||
(let [cls (form-field-class-list {:error error})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> (if label
|
||||
[[:label {:class ["form-label"]} label]]
|
||||
[])
|
||||
true (into children)
|
||||
hint (conj [:small {:class ["form-hint"]} hint])
|
||||
error (conj [:small {:class ["form-error"]} error]))))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (form-field-classes {:error error})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> (if label
|
||||
[[:label {:class "form-label"} label]]
|
||||
[])
|
||||
true (into children)
|
||||
hint (conj [:small {:class "form-hint"} hint])
|
||||
error (conj [:small {:class "form-error"} error]))))))
|
||||
|
||||
;; ── Text input ──────────────────────────────────────────────────────
|
||||
|
||||
(defn form-input-class-list
|
||||
"Returns a vector of CSS class strings for a form input."
|
||||
[{:keys [error]}]
|
||||
(cond-> ["form-input"]
|
||||
error (conj "form-input--error")))
|
||||
|
||||
(defn form-input-classes
|
||||
"Returns a space-joined class string for a form input."
|
||||
[opts]
|
||||
(str/join " " (form-input-class-list opts)))
|
||||
|
||||
(defn form-input
|
||||
"Render a text input element.
|
||||
|
||||
Props:
|
||||
:type - :text, :email, :password, :date, :datetime-local, etc.
|
||||
:placeholder - placeholder text
|
||||
:value - input value
|
||||
:disabled - boolean
|
||||
:error - boolean, adds error styling
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [type placeholder value disabled error on-change class attrs] :as _props}]
|
||||
(let [input-type (or (some-> type kw-name) "text")]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (form-input-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type input-type} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
value (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))])
|
||||
|
||||
:cljs
|
||||
(let [cls (form-input-class-list {:error error})
|
||||
classes (cond-> cls class (conj class))]
|
||||
[:input (cond-> (merge {:class classes :type input-type} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
value (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (form-input-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type input-type} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
value (assoc :value value)
|
||||
disabled (assoc :disabled true))]))))
|
||||
|
||||
;; ── Textarea ────────────────────────────────────────────────────────
|
||||
|
||||
(defn form-textarea-class-list
|
||||
"Returns a vector of CSS class strings for a form textarea."
|
||||
[{:keys [error]}]
|
||||
(cond-> ["form-textarea"]
|
||||
error (conj "form-textarea--error")))
|
||||
|
||||
(defn form-textarea-classes
|
||||
"Returns a space-joined class string for a form textarea."
|
||||
[opts]
|
||||
(str/join " " (form-textarea-class-list opts)))
|
||||
|
||||
(defn form-textarea
|
||||
"Render a textarea element.
|
||||
|
||||
Props:
|
||||
:placeholder - placeholder text
|
||||
:value - textarea value
|
||||
:disabled - boolean
|
||||
:error - boolean, adds error styling
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [placeholder value disabled error on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (form-textarea-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:textarea (cond-> (merge {:class classes} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))
|
||||
(or value "")])
|
||||
|
||||
:cljs
|
||||
(let [cls (form-textarea-class-list {:error error})
|
||||
classes (cond-> cls class (conj class))]
|
||||
[:textarea (cond-> (merge {:class classes} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))
|
||||
(or value "")])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (form-textarea-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:textarea (cond-> (merge {:class classes} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
disabled (assoc :disabled true))
|
||||
(or value "")])))
|
||||
|
||||
;; ── Select ──────────────────────────────────────────────────────────
|
||||
|
||||
(defn form-select
|
||||
"Render a select dropdown element.
|
||||
|
||||
Props:
|
||||
:options - vector of {:value \"v\" :label \"Label\"} or strings
|
||||
:placeholder - placeholder option text
|
||||
:value - currently selected value
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [options placeholder value disabled on-change class attrs] :as _props}]
|
||||
(let [opts (mapv (fn [o]
|
||||
(if (string? o)
|
||||
{:value o :label o}
|
||||
o))
|
||||
options)]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-select"
|
||||
class (str " " class))]
|
||||
(into [:select (cond-> (merge {:class classes} attrs)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))]
|
||||
(cond-> (mapv (fn [o]
|
||||
[:option {:value (:value o)} (:label o)])
|
||||
opts)
|
||||
placeholder (into [[:option {:value "" :disabled true :selected (nil? value)} placeholder]]))))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-select"] class (conj class))]
|
||||
(into [:select (cond-> (merge {:class cls} attrs)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))]
|
||||
(cond-> (mapv (fn [o]
|
||||
[:option {:value (:value o)} (:label o)])
|
||||
opts)
|
||||
placeholder (into [[:option {:value "" :disabled true :selected (nil? value)} placeholder]]))))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-select"
|
||||
class (str " " class))]
|
||||
(into [:select (cond-> (merge {:class classes} attrs)
|
||||
disabled (assoc :disabled true))]
|
||||
(cond-> (mapv (fn [o]
|
||||
[:option {:value (:value o)} (:label o)])
|
||||
opts)
|
||||
placeholder (into [[:option {:value "" :disabled true :selected (nil? value)} placeholder]])))))))
|
||||
|
||||
;; ── Checkbox ────────────────────────────────────────────────────────
|
||||
|
||||
(defn form-checkbox
|
||||
"Render a checkbox with label.
|
||||
|
||||
Props:
|
||||
:label - label text
|
||||
:checked - boolean
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [label checked disabled on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-field form-field--inline"
|
||||
class (str " " class))]
|
||||
[:label (merge {:class classes} attrs)
|
||||
[:input (cond-> {:class "form-checkbox" :type "checkbox"}
|
||||
checked (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))]
|
||||
(when label [:span label])])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-field" "form-field--inline"] class (conj class))]
|
||||
[:label (merge {:class cls} attrs)
|
||||
[:input (cond-> {:class ["form-checkbox"] :type "checkbox"}
|
||||
checked (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))]
|
||||
(when label [:span label])])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-field form-field--inline"
|
||||
class (str " " class))]
|
||||
[:label (merge {:class classes} attrs)
|
||||
[:input (cond-> {:class "form-checkbox" :type "checkbox"}
|
||||
checked (assoc :checked true)
|
||||
disabled (assoc :disabled true))]
|
||||
(when label [:span label])])))
|
||||
|
||||
;; ── Radio group ─────────────────────────────────────────────────────
|
||||
|
||||
(defn form-radio-group
|
||||
"Render a radio button group inside a fieldset.
|
||||
|
||||
Props:
|
||||
:label - legend text
|
||||
:name - radio group name
|
||||
:options - vector of {:value \"v\" :label \"Label\"} or strings
|
||||
:value - currently selected value
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [label radio-name options radio-value disabled on-change class attrs] :as _props}]
|
||||
(let [opts (mapv (fn [o]
|
||||
(if (string? o)
|
||||
{:value o :label o}
|
||||
o))
|
||||
options)]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-fieldset form-fieldset--inline"
|
||||
class (str " " class))]
|
||||
(into [:fieldset (merge {:class classes} attrs)]
|
||||
(cond-> []
|
||||
label (conj [:legend {:class "form-legend"} label])
|
||||
true (into (mapv (fn [o]
|
||||
[:label
|
||||
[:input (cond-> {:class "form-radio" :type "radio"
|
||||
:name (or radio-name "radio")}
|
||||
(some? (:value o)) (assoc :value (:value o))
|
||||
(= radio-value (:value o)) (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))]
|
||||
(:label o)])
|
||||
opts)))))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-fieldset" "form-fieldset--inline"] class (conj class))]
|
||||
(into [:fieldset (merge {:class cls} attrs)]
|
||||
(cond-> []
|
||||
label (conj [:legend {:class ["form-legend"]} label])
|
||||
true (into (mapv (fn [o]
|
||||
[:label
|
||||
[:input (cond-> {:class ["form-radio"] :type "radio"
|
||||
:name (or radio-name "radio")}
|
||||
(some? (:value o)) (assoc :value (:value o))
|
||||
(= radio-value (:value o)) (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))]
|
||||
(:label o)])
|
||||
opts)))))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-fieldset form-fieldset--inline"
|
||||
class (str " " class))]
|
||||
(into [:fieldset (merge {:class classes} attrs)]
|
||||
(cond-> []
|
||||
label (conj [:legend {:class "form-legend"} label])
|
||||
true (into (mapv (fn [o]
|
||||
[:label
|
||||
[:input (cond-> {:class "form-radio" :type "radio"
|
||||
:name (or radio-name "radio")}
|
||||
(some? (:value o)) (assoc :value (:value o))
|
||||
(= radio-value (:value o)) (assoc :checked true)
|
||||
disabled (assoc :disabled true))]
|
||||
(:label o)])
|
||||
opts))))))))
|
||||
|
||||
;; ── File input ──────────────────────────────────────────────────────
|
||||
|
||||
(defn form-file
|
||||
"Render a file input element.
|
||||
|
||||
Props:
|
||||
:accept - accepted file types (e.g. \"image/*,.pdf\")
|
||||
:multiple - boolean, allow multiple files
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [accept multiple disabled on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-file"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "file"} attrs)
|
||||
accept (assoc :accept accept)
|
||||
multiple (assoc :multiple true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-file"] class (conj class))]
|
||||
[:input (cond-> (merge {:class cls :type "file"} attrs)
|
||||
accept (assoc :accept accept)
|
||||
multiple (assoc :multiple true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-file"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "file"} attrs)
|
||||
accept (assoc :accept accept)
|
||||
multiple (assoc :multiple true)
|
||||
disabled (assoc :disabled true))])))
|
||||
|
||||
;; ── Range input ─────────────────────────────────────────────────────
|
||||
|
||||
(defn form-range
|
||||
"Render a range slider input.
|
||||
|
||||
Props:
|
||||
:min - minimum value
|
||||
:max - maximum value
|
||||
:step - step increment
|
||||
:value - current value
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [min max step value disabled on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-range"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "range"} attrs)
|
||||
(some? min) (assoc :min min)
|
||||
(some? max) (assoc :max max)
|
||||
(some? step) (assoc :step step)
|
||||
(some? value) (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-range"] class (conj class))]
|
||||
[:input (cond-> (merge {:class cls :type "range"} attrs)
|
||||
(some? min) (assoc :min min)
|
||||
(some? max) (assoc :max max)
|
||||
(some? step) (assoc :step step)
|
||||
(some? value) (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-range"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "range"} attrs)
|
||||
(some? min) (assoc :min min)
|
||||
(some? max) (assoc :max max)
|
||||
(some? step) (assoc :step step)
|
||||
(some? value) (assoc :value value)
|
||||
disabled (assoc :disabled true))])))
|
||||
|
||||
;; ── Input group ─────────────────────────────────────────────────────
|
||||
|
||||
(defn form-group
|
||||
"Render an input group (combined inputs, addons, and buttons).
|
||||
|
||||
Props:
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-group"
|
||||
class (str " " class))]
|
||||
(into [:div (merge {:class classes} attrs)] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-group"] class (conj class))]
|
||||
(into [:div (merge {:class cls} attrs)] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-group"
|
||||
class (str " " class))]
|
||||
(into [:div (merge {:class classes} attrs)] children))))
|
||||
|
||||
(defn form-group-addon
|
||||
"Render an input group addon (static text before/after an input).
|
||||
|
||||
Props:
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-group-addon"
|
||||
class (str " " class))]
|
||||
(into [:span (merge {:class classes} attrs)] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-group-addon"] class (conj class))]
|
||||
(into [:span (merge {:class cls} attrs)] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-group-addon"
|
||||
class (str " " class))]
|
||||
(into [:span (merge {:class classes} attrs)] children))))
|
||||
171
src/ui/form.css
171
src/ui/form.css
@@ -1,11 +1,45 @@
|
||||
/* ── Form field wrapper ─────────────────────────────────────────── */
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-1);
|
||||
margin-bottom: var(--size-4);
|
||||
}
|
||||
|
||||
.form-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
}
|
||||
|
||||
.form-field--error .form-input,
|
||||
.form-field--error .form-textarea,
|
||||
.form-field--error .form-select {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.form-field--error .form-input:focus,
|
||||
.form-field--error .form-textarea:focus,
|
||||
.form-field--error .form-select:focus {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Label ─────────────────────────────────────────────────────── */
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--fg-0);
|
||||
margin-bottom: var(--size-1);
|
||||
}
|
||||
|
||||
/* ── Inputs (text, email, password, date, etc.) ────────────────── */
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
@@ -38,6 +72,7 @@
|
||||
.form-textarea:disabled,
|
||||
.form-select:disabled {
|
||||
background: var(--bg-2);
|
||||
color: var(--fg-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -51,31 +86,25 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Textarea ──────────────────────────────────────────────────── */
|
||||
|
||||
.form-textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* ── Select ────────────────────────────────────────────────────── */
|
||||
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--size-2) center;
|
||||
padding-right: var(--size-6);
|
||||
padding-right: var(--size-8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--fg-2);
|
||||
margin-top: var(--size-1);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--danger);
|
||||
margin-top: var(--size-1);
|
||||
}
|
||||
/* ── Checkbox & Radio ──────────────────────────────────────────── */
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
@@ -83,6 +112,7 @@
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-0);
|
||||
border: var(--border-1);
|
||||
cursor: pointer;
|
||||
@@ -118,12 +148,57 @@
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.form-checkbox:focus,
|
||||
.form-radio:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.form-checkbox:disabled,
|
||||
.form-radio:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── File input ────────────────────────────────────────────────── */
|
||||
|
||||
.form-file {
|
||||
font-size: var(--font-sm);
|
||||
font-family: inherit;
|
||||
color: var(--fg-0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-file::file-selector-button {
|
||||
padding: var(--size-2) var(--size-3);
|
||||
font-size: var(--font-sm);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
background: var(--bg-2);
|
||||
color: var(--fg-0);
|
||||
border: var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
margin-right: var(--size-3);
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.form-file::file-selector-button:hover {
|
||||
background: var(--bg-1);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-file:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-file:disabled::file-selector-button {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Range input ───────────────────────────────────────────────── */
|
||||
|
||||
.form-range {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
@@ -154,6 +229,76 @@
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.form-range:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-range:focus::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.form-range:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Hint & Error text ─────────────────────────────────────────── */
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── Fieldset & Legend ──────────────────────────────────────────── */
|
||||
|
||||
.form-fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--size-4) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-2);
|
||||
}
|
||||
|
||||
.form-fieldset:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-fieldset--inline {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--size-4);
|
||||
}
|
||||
|
||||
.form-fieldset--inline label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--fg-0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-legend {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--fg-0);
|
||||
margin-bottom: var(--size-1);
|
||||
}
|
||||
|
||||
.form-fieldset--inline .form-legend {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Input group ───────────────────────────────────────────────── */
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
Reference in New Issue
Block a user