Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,43 @@ The `:class` attribute supports multiple formats:
{:class {:active @is-active?
:disabled @is-disabled?}}
```
#### Passing attributes and child nodes

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

;; ✅ Pass the attribute map as a literal
($ custom-label {:input-name "my-input"} "the label title")


(let [attrs {:input-name "my-input"}
children "the label title"]
($ :<> ;; Fragment component
($ custom-label attrs children) ;; ✅ Pass the attribute map as a bound variable
($ custom-label children) ;; ✅ Attribute map is optional, it may be elided entirely
($ custom-label) ;; also valid
($ custom-label attrs))) ;; also valid
```

Do not use bound variables for keyword tags:

```clj
;; ❌ Not supported for keyword tags,
;; Only supported for `defui` tags
(let [attrs {:class "my-div"}
title ($ "the label")
input ($ :input)]
($ :label attrs title input))

;; ✅ Be explicit with a map literal
(let [title ($ "the label")
input ($ :input)]
($ :label {:class "my-label"} title input))
;; <label class="my-label"> the label <input> </label>
```



### Rendering

Expand Down
36 changes: 21 additions & 15 deletions src/solid/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@
[solid.compiler :as sc]
[solid.lint :as lint]))

(defn- literal?
"Returns true if the expression is a compile-time literal."
[x]
(core/or
(string? x)
(number? x)
(keyword? x)
(nil? x)
(true? x)
(false? x)))

(defmacro defui
"Defines a Solid UI component. Supports docstrings and metadata.

Expand Down Expand Up @@ -44,11 +33,14 @@
(let [~(core/or props (gensym "props")) (solid.core/-props props#)]
~@body))))

(defn- wrap-child-node [node]
(if (sc/literal? node)
node ; Don't wrap literals - they can't be reactive
`(fn [] ~node)))

(defn- wrap-children [children]
(core/for [x children]
(if (literal? x)
x ; Don't wrap literals - they can't be reactive
`(fn [] ~x))))
(wrap-child-node x)))

(defn- fn-form?
"Returns true if the expression is a function literal (fn or fn*)."
Expand Down Expand Up @@ -79,6 +71,20 @@
props)
props))

(defn wrap-props-or-children
"Similar to `wrap-component-props`, this one considers that
the first value might be a prop map, or might be a child node,
so it must figure out at runtime how to treat the first value"
([] [])
([maybe-attrs & children]
(let [first `(if (map? ~maybe-attrs)
(cljs.core/js-obj
"props"
~maybe-attrs)
~(wrap-child-node maybe-attrs))
rest (wrap-children children)]
(into [first] rest))))

(defmacro $ [tag & args]
(if (keyword? tag)
(if (= tag :<>)
Expand All @@ -91,7 +97,7 @@
(let [[attrs & children] args]
(if (map? attrs)
`(create-element ~tag (cljs.core/js-obj "props" ~(wrap-component-props attrs)) ~@(wrap-children children))
`(create-element ~tag ~@(wrap-children args))))))
`(create-element ~tag ~@(apply wrap-props-or-children args))))))

(defmacro if
([test then]
Expand Down
59 changes: 58 additions & 1 deletion test/vitest/basic_component.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
(:require
[applied-science.js-interop :as j]
[solid.core :as s :refer [$ defui]]
["vitest/browser" :refer [page userEvent]]
["vitest/browser" :refer [page]]
["@solidjs/testing-library" :as st :refer [render]]
["vitest" :refer [expect test]]))

Expand Down Expand Up @@ -34,3 +34,60 @@
(.then (fn [] (.click incrementButton)))
(.then (fn [] (.. expect (element (.. screen (getByText "Count: 1"))) toBeInTheDocument)))))))

(defui label-with-attrs [{:keys [data-testid children]} attrs]
($ :label {:data-testid data-testid} children))

(test
"Component receives map attributes as a runtime variable, wrapped in vector literal",
(fn []
(j/let [title "the label"
input ($ :input)
attrs {:data-testid "label-with-attrs"}
^:js {:keys [baseElement]} (render #($ label-with-attrs attrs title input))
screen (.. page (elementLocator baseElement))
label (.. screen (getByTestId "label-with-attrs"))]
(->
(.. expect (element (.. label (getByText "the label"))) toBeInTheDocument)))))

(defui simple-label [{:keys [children]} attrs]
($ :label {:data-testid "simple-label"} children))

(test
"Component without any props or children",
(fn []
(j/let [^:js {:keys [baseElement]} (render #($ simple-label))
screen (.. page (elementLocator baseElement))]
(->
(.. expect (element (.. screen (getByTestId "simple-label"))) toBeInTheDocument)))))

(test
"Component with a single variable-bound child-node",
(fn []
(j/let [title "single child node"
^:js {:keys [baseElement]} (render #($ :<> ($ simple-label title)))
screen (.. page (elementLocator baseElement))]
(->
(.. expect (element (.. screen (getByText "single child node"))) toBeInTheDocument)))))

(defui button-with-reactive-prop [{:keys [label sig unique-id]}]
($ :label label
($ :button {:on-click #(swap! sig + 1)
:reactive-prop @sig
:data-testid unique-id}
unique-id "Count: " @sig)))

(test
"Component receives signal as prop and updates accordingly",
(fn []
(j/let [sig (s/signal 0)
unique-id (s/uid)
^:js {:keys [baseElement]} (render #($ button-with-reactive-prop
{:label "Button"
:unique-id unique-id
:sig sig}))
screen (.. page (elementLocator baseElement))
incrementButton (.. screen (getByTestId unique-id))]
(->
(.. expect (element (.. screen (getByText (str unique-id "Count: 0")))) toBeInTheDocument)
(.then (fn [] (.click incrementButton)))
(.then (fn [] (.. expect (element (.. screen (getByText (str unique-id "Count: 1")))) toBeInTheDocument)))))))
2 changes: 2 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export default defineConfig({
// relies on the config at shadow-cljs.edn, which outputs these files
'./out/vitest/vitest.*.js',
],
// testTimeout default is between 5 to 15 seconds, but keeping it low might be productive:
testTimeout: 1_000, // https://vitest.dev/config/testtimeout.html
browser: {
provider: playwright(),
enabled: true,
Expand Down