diff --git a/README.md b/README.md index 45c1f9d..0929a92 100644 --- a/README.md +++ b/README.md @@ -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)) +;; +``` + + ### Rendering diff --git a/src/solid/core.clj b/src/solid/core.clj index 64cdec5..7f85fa7 100644 --- a/src/solid/core.clj +++ b/src/solid/core.clj @@ -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. @@ -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*)." @@ -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 :<>) @@ -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] diff --git a/test/vitest/basic_component.cljc b/test/vitest/basic_component.cljc index 70bbe7a..52e31b1 100644 --- a/test/vitest/basic_component.cljc +++ b/test/vitest/basic_component.cljc @@ -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]])) @@ -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))))))) diff --git a/vitest.config.ts b/vitest.config.ts index 8a28def..4810259 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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,