Skip to content

Commit 232f0c8

Browse files
committed
Allow variable-bound attribute maps
1 parent 6300922 commit 232f0c8

5 files changed

Lines changed: 127 additions & 84 deletions

File tree

README.md

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,6 @@ Functions and macros below are a part of `solid.core` namespace:
7070
($ button {:on-click #(prn :pressed)} "press")
7171
```
7272

73-
#### Child nodes
74-
75-
```clj
76-
;; ✅ bound
77-
(let [title "the label"
78-
input ($ :input) ]
79-
($ :label title input))
80-
81-
;; ✅ or explicit
82-
($ :label "the label" ($ :input)))
83-
84-
;; <label> the label <input> </label>
85-
```
86-
8773
### Attributes
8874

8975
The `:class` attribute supports multiple formats:
@@ -102,50 +88,45 @@ The `:class` attribute supports multiple formats:
10288
{:class {:active @is-active?
10389
:disabled @is-disabled?}}
10490
```
105-
106-
#### Bound Attributes
107-
108-
Given a component declared with `defui`:
91+
#### Passing attributes and child nodes
10992

11093
```clj
11194
(defui custom-label [{:keys [input-name children]} attrs]
11295
($ :label {:for input-name} children))
113-
```
11496

115-
It would be called like this:
116-
117-
```clj
97+
;; ✅ Pass the attribute map as a literal
11898
($ custom-label {:input-name "my-input"} "the label title")
119-
```
12099

121-
However, to pass bound variables for the `defui` component,
122100

123-
```clj
124-
;; ❌ Using a bound variable for attributes, `attrs` is
125-
;; indistinguishable from passing multiple child nodes for the `$` macro
126101
(let [attrs {:input-name "my-input"}
127102
children "the label title"]
128-
($ custom-label attrs children))
129-
130-
;; ✅ Wrapping the attribute variable with a vector, `[attrs]`
131-
;; signals the macro that it should be treated as an attribute map
132-
(let [attrs {:input-name "my-input"}
133-
children "the label title"]
134-
($ custom-label [attrs] children))
103+
($ :<> ;; Fragment component
104+
($ custom-label attrs children) ;; ✅ Pass the attribute map as a bound variable
105+
($ custom-label children) ;; ✅ Attribute map is optional, it may be elided entirely
106+
($ custom-label) ;; also valid
107+
($ custom-label attrs))) ;; also valid
135108
```
136109

137110
Do not use bound variables for keyword tags:
138111

139112
```clj
140113
;; ❌ Not supported for keyword tags,
141114
;; Only supported for `defui` tags
142-
(let [attrs {:class "my-div"} ]
143-
($ :div [attrs] children))
115+
(let [attrs {:class "my-div"}
116+
title ($ "the label")
117+
input ($ :input)]
118+
($ :label attrs title input))
144119

145120
;; ✅ Be explicit with a map literal
146-
($ :div {:class "my-div"} children))
121+
(let [attrs
122+
title ($ "the label")
123+
input ($ :input)]
124+
($ :label {:class "my-label"} title input))
125+
;; <label class="my-label"> the label <input> </label>
147126
```
148127

128+
129+
149130
### Rendering
150131

151132
Conditional rendering with `if` and `when`, via [<Show> component](https://docs.solidjs.com/reference/components/show)

src/solid/compiler.cljs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,44 @@
33

44
(def ^:private cc-regexp (js/RegExp. "-(\\w)" "g"))
55

6+
;; Marker type for reactive prop values
7+
;; This distinguishes reactive getters from regular callbacks
8+
(deftype ReactiveProp [getter])
9+
10+
(defn reactive-prop
11+
"Wraps a function as a reactive prop getter.
12+
Used internally by the $ macro for component props."
13+
[f]
14+
(->ReactiveProp f))
15+
16+
(defn reactive-prop?
17+
"Returns true if x is a reactive prop wrapper."
18+
[x]
19+
(instance? ReactiveProp x))
20+
21+
(defn literal?
22+
"Returns true if the expression is a compile-time literal that cannot be reactive.
23+
Literals include: strings, numbers, keywords, nil, booleans."
24+
[expr]
25+
(or (string? expr)
26+
(number? expr)
27+
(keyword? expr)
28+
(nil? expr)
29+
(true? expr)
30+
(false? expr)))
31+
32+
(defn wrap-component-props
33+
"Same intention as `wrap-component-props`, however this
34+
funcion one is meant
35+
to expand into runtime values"
36+
[attrs]
37+
(reduce-kv
38+
(fn [m k v]
39+
(assoc m k
40+
(reactive-prop (fn [] v))))
41+
{}
42+
attrs))
43+
644
(defn- cc-fn [s]
745
(str/upper-case (aget s 1)))
846

src/solid/core.clj

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,6 @@
44
[solid.compiler :as sc]
55
[solid.lint :as lint]))
66

7-
(defn- literal?
8-
"Returns true if the expression is a compile-time literal."
9-
[x]
10-
(core/or
11-
(string? x)
12-
(number? x)
13-
(keyword? x)
14-
(nil? x)
15-
(true? x)
16-
(false? x)))
17-
187
(defmacro defui
198
"Defines a Solid UI component. Supports docstrings and metadata.
209
@@ -44,11 +33,14 @@
4433
(let [~(core/or props (gensym "props")) (solid.core/-props props#)]
4534
~@body))))
4635

36+
(defn- wrap-child-node [node]
37+
(if (sc/literal? node)
38+
node ; Don't wrap literals - they can't be reactive
39+
`(fn [] ~node)))
40+
4741
(defn- wrap-children [children]
4842
(core/for [x children]
49-
(if (literal? x)
50-
x ; Don't wrap literals - they can't be reactive
51-
`(fn [] ~x))))
43+
(wrap-child-node x)))
5244

5345
(defn- fn-form?
5446
"Returns true if the expression is a function literal (fn or fn*)."
@@ -58,14 +50,13 @@
5850
(core/or (= head 'fn)
5951
(= head 'fn*)))))
6052

61-
(defn- wrap-component-props
53+
(defn wrap-component-props
6254
"Wraps component prop values in reactive-prop for fine-grained reactivity.
6355
Does not wrap:
6456
- Literals (strings, numbers, keywords, nil, booleans)
6557
- Function forms (fn, fn*/#())"
6658
[props]
67-
(core/cond
68-
(map? props)
59+
(if (map? props)
6960
(reduce-kv
7061
(fn [m k v]
7162
(assoc m k
@@ -75,18 +66,24 @@
7566
;; Function forms are callbacks, don't wrap
7667
(fn-form? v) v
7768
;; Everything else gets wrapped in reactive-prop
78-
:else `(solid.core/reactive-prop (fn [] ~v)))))
69+
:else `(solid.compiler/reactive-prop (fn [] ~v)))))
7970
{}
8071
props)
81-
(vector? props) ;; attribute map is passed bound to a runtime value
82-
`(let [v# (first ~props)]
83-
(reduce-kv
84-
(fn [m# k# v#]
85-
(assoc m# k#
86-
(solid.core/reactive-prop (fn [] v#))))
87-
{}
88-
v#))
89-
:else props))
72+
props))
73+
74+
(defn wrap-props-or-children
75+
"Similar to `wrap-component-props`, this one considers that
76+
the first value might be a prop map, or might be a child node,
77+
so it must figure out at runtime how to treat the first value"
78+
([] [])
79+
([maybe-attrs & children]
80+
(let [first `(doto (if (map? ~maybe-attrs)
81+
(cljs.core/js-obj
82+
"props"
83+
(solid.compiler/wrap-component-props ~maybe-attrs))
84+
~(wrap-child-node maybe-attrs)) #(prn "hellooo"))
85+
rest (wrap-children children)]
86+
(into [first] rest))))
9087

9188
(defmacro $ [tag & args]
9289
(if (keyword? tag)
@@ -98,9 +95,9 @@
9895
(lint/lint-element-attrs! tag (first args) &env))
9996
`(create-element ~(name tag) ~attrs ~@(wrap-children children))))
10097
(let [[attrs & children] args]
101-
(if ((some-fn map? vector?) attrs)
98+
(if (map? attrs)
10299
`(create-element ~tag (cljs.core/js-obj "props" ~(wrap-component-props attrs)) ~@(wrap-children children))
103-
`(create-element ~tag ~@(wrap-children args))))))
100+
`(create-element ~tag ~@(apply wrap-props-or-children args))))))
104101

105102
(defmacro if
106103
([test then]

src/solid/core.cljs

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,6 @@
66
["solid-js/h/dist/h.js" :default h]
77
[solid.compiler :as sc]))
88

9-
;; Marker type for reactive prop values
10-
;; This distinguishes reactive getters from regular callbacks
11-
(deftype ReactiveProp [getter])
12-
13-
(defn reactive-prop
14-
"Wraps a function as a reactive prop getter.
15-
Used internally by the $ macro for component props."
16-
[f]
17-
(->ReactiveProp f))
18-
19-
(defn reactive-prop?
20-
"Returns true if x is a reactive prop wrapper."
21-
[x]
22-
(instance? ReactiveProp x))
23-
249
(defn create-element [tag & args]
2510
(when (string? tag) (sc/with-numeric-props (first args)))
2611
(apply h tag args))
@@ -118,7 +103,7 @@
118103
(-lookup this k nil))
119104
(-lookup [this k not-found]
120105
(let [v (get props-map k not-found)]
121-
(if (reactive-prop? v)
106+
(if (sc/reactive-prop? v)
122107
((.-getter ^ReactiveProp v)) ; Call the reactive getter
123108
v))) ; Pass through as-is (callbacks, literals, etc.)
124109

test/vitest/basic_component.cljc

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,55 @@
3838
($ :label {:data-testid data-testid} children))
3939

4040
(test
41-
"Defui component receives map attributes as a runtime variable, wrapped in vector literal",
41+
"Component receives map attributes as a runtime variable, wrapped in vector literal",
4242
(fn []
4343
(j/let [title "the label"
4444
input ($ :input)
4545
attrs {:data-testid "label-with-attrs"}
46-
^:js {:keys [baseElement]} (render #($ label-with-attrs [attrs] title input))
46+
^:js {:keys [baseElement]} (render #($ label-with-attrs attrs title input))
4747
screen (.. page (elementLocator baseElement))
4848
label (.. screen (getByTestId "label-with-attrs"))]
4949
(->
5050
(.. expect (element (.. label (getByText "the label"))) toBeInTheDocument)))))
51+
52+
(defui simple-label [{:keys [children]} attrs]
53+
($ :label {:data-testid "simple-label"} children))
54+
55+
(test
56+
"Component without any props or children",
57+
(fn []
58+
(j/let [^:js {:keys [baseElement]} (render #($ simple-label))
59+
screen (.. page (elementLocator baseElement))]
60+
(->
61+
(.. expect (element (.. screen (getByTestId "simple-label"))) toBeInTheDocument)))))
62+
63+
(test
64+
"Component with a single variable-bound child-node",
65+
(fn []
66+
(j/let [title "single child node"
67+
^:js {:keys [baseElement]} (render #($ :<> ($ simple-label title)))
68+
screen (.. page (elementLocator baseElement))]
69+
(->
70+
(.. expect (element (.. screen (getByText "single child node"))) toBeInTheDocument)))))
71+
72+
(defui button-with-reactive-prop [{:keys [label sig unique-id]}]
73+
($ :label label
74+
($ :button {:on-click #(swap! sig + 1)
75+
:data-testid unique-id}
76+
unique-id "Count: " @sig)))
77+
78+
(test
79+
"Component signal updates on user event",
80+
(fn []
81+
(j/let [sig (s/signal 0)
82+
unique-id (s/uid)
83+
^:js {:keys [baseElement]} (render #($ button-with-reactive-prop
84+
{:label "Button"
85+
:unique-id unique-id
86+
:sig sig}))
87+
screen (.. page (elementLocator baseElement))
88+
incrementButton (.. screen (getByTestId unique-id))]
89+
(->
90+
(.. expect (element (.. screen (getByText (str unique-id "Count: 0")))) toBeInTheDocument)
91+
(.then (fn [] (.click incrementButton)))
92+
(.then (fn [] (.. expect (element (.. screen (getByText (str unique-id "Count: 1")))) toBeInTheDocument)))))))

0 commit comments

Comments
 (0)