From d77c2adcf451aa9c0b55bd0a835d53f95c7becf4 Mon Sep 17 00:00:00 2001 From: Daniel Leong Date: Sat, 29 May 2021 10:34:18 -0400 Subject: [PATCH] Support dynamic "Style Containers" for alternate style mounting (#14) * Refactor to support a dynamic "style container" to render CSS into See #12 * Fix factory fn name extraction in non-jvm context * Fix incorrect namespace reference * Fix missing spade.runtime ns reference in JVM context * Implement React Context-based IStyleContainer-providing solution * Refactor IStyleContainer and its implementations out of the runtime ns `runtime` is more of an "implementation detail" ns, so things that clients might reasonably want to access shouldn't live there. * Fix old, incorrect ns reference --- dev/spade/demo.cljs | 17 +++++-- package-lock.json | 44 ++++++++-------- project.clj | 7 +-- resources/public/index.html | 1 + src/spade/container.cljc | 8 +++ src/spade/container/alternate.cljc | 12 +++++ src/spade/container/atom.cljc | 9 ++++ src/spade/container/dom.cljs | 61 ++++++++++++++++++++++ src/spade/core.cljc | 15 +++++- src/spade/react.cljs | 49 ++++++++++++++++++ src/spade/runtime.cljc | 51 +++++++++++++++++++ src/spade/runtime.cljs | 81 ------------------------------ src/spade/runtime/defaults.clj | 7 +++ src/spade/runtime/defaults.cljs | 5 ++ src/spade/util.cljc | 5 +- test/spade/jvm_test.clj | 18 +++++++ 16 files changed, 277 insertions(+), 113 deletions(-) create mode 100644 src/spade/container.cljc create mode 100644 src/spade/container/alternate.cljc create mode 100644 src/spade/container/atom.cljc create mode 100644 src/spade/container/dom.cljs create mode 100644 src/spade/react.cljs create mode 100644 src/spade/runtime.cljc delete mode 100644 src/spade/runtime.cljs create mode 100644 src/spade/runtime/defaults.clj create mode 100644 src/spade/runtime/defaults.cljs create mode 100644 test/spade/jvm_test.clj diff --git a/dev/spade/demo.cljs b/dev/spade/demo.cljs index e959268..2586e7e 100644 --- a/dev/spade/demo.cljs +++ b/dev/spade/demo.cljs @@ -1,7 +1,8 @@ (ns spade.demo (:require [clojure.string :as str] - [reagent.core :as r] - [spade.core :refer [defclass defattrs defglobal defkeyframes]])) + [reagent.dom :as rdom] + [spade.core :refer [defclass defattrs defglobal defkeyframes]] + [spade.react :as spade])) (defkeyframes anim-frames [] ["0%" {:opacity 0}] @@ -50,7 +51,7 @@ (defattrs composed-attrs [] {:composes (flex)}) -(defn view [] +(defn demo [] [:<> [:div {:class (serenity)} [:div.title "Test"]] @@ -77,8 +78,16 @@ ]) +(defn view [] + [:div + [:style#styles] + [demo]]) + (defn mount-root [] - (r/render [view] (.getElementById js/document "app"))) + (rdom/render + [spade/with-dom #(.getElementById js/document "styles") + [view]] + (.getElementById js/document "app"))) (defn init! [] (mount-root)) diff --git a/package-lock.json b/package-lock.json index d12020c..4af1d41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,9 +203,9 @@ "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" }, "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "base": { "version": "0.11.2", @@ -865,9 +865,9 @@ "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==" }, "follow-redirects": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz", - "integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", + "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" }, "for-in": { "version": "1.0.2", @@ -926,9 +926,9 @@ "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=" }, "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -1118,9 +1118,9 @@ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" }, "is-core-module": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz", - "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", + "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", "requires": { "has": "^1.0.3" } @@ -1456,16 +1456,16 @@ "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==" }, "mime-db": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz", - "integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==" + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.47.0.tgz", + "integrity": "sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==" }, "mime-types": { - "version": "2.1.29", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz", - "integrity": "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==", + "version": "2.1.30", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.30.tgz", + "integrity": "sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==", "requires": { - "mime-db": "1.46.0" + "mime-db": "1.47.0" } }, "minimatch": { @@ -1854,9 +1854,9 @@ "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=" }, "repeat-element": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", - "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==" }, "repeat-string": { "version": "1.6.1", diff --git a/project.clj b/project.clj index 39c0753..2903b1c 100644 --- a/project.clj +++ b/project.clj @@ -8,7 +8,8 @@ :dependencies [[org.clojure/clojure "1.10.0"] [org.clojure/clojurescript "1.10.520"] - [garden "1.3.10"]] + [garden "1.3.10"] + [net.cgrand/macrovich "0.2.1"]] :plugins [[lein-figwheel "0.5.19"] [lein-cljsbuild "1.1.7" :exclusions [[org.clojure/clojure]]]] @@ -24,7 +25,7 @@ :jar-exclusions [#"(?:^|\/)public\/"] - :aliases {"test" ["do" ; "test" + :aliases {"test" ["do" "test" ["doo" "chrome-headless" "test" "once"]]} :cljsbuild {:builds @@ -85,7 +86,7 @@ :profiles {:dev {:dependencies [[binaryage/devtools "0.9.10"] [figwheel-sidecar "0.5.19"] [cider/piggieback "0.4.1"] - [reagent "0.8.1"]] + [reagent "1.0.0"]] :plugins [[lein-doo "0.1.10"]] diff --git a/resources/public/index.html b/resources/public/index.html index 3f2f08d..c08bc06 100644 --- a/resources/public/index.html +++ b/resources/public/index.html @@ -12,6 +12,7 @@

Figwheel template

Checkout your developer console.

+
diff --git a/src/spade/container.cljc b/src/spade/container.cljc new file mode 100644 index 0000000..202b84a --- /dev/null +++ b/src/spade/container.cljc @@ -0,0 +1,8 @@ +(ns spade.container) + +(defprotocol IStyleContainer + "The IStyleContainer represents anything that can be used by Spade to + 'mount' styles for access by Spade style components." + (mount-style! + [this style-name css] + "Ensure the style with the given name and CSS is available")) diff --git a/src/spade/container/alternate.cljc b/src/spade/container/alternate.cljc new file mode 100644 index 0000000..6784f21 --- /dev/null +++ b/src/spade/container/alternate.cljc @@ -0,0 +1,12 @@ +(ns spade.container.alternate + "The AlternateStyleContainer may be used when a preferred container + is not always available." + (:require [spade.container :as sc :refer [IStyleContainer]])) + +(deftype AlternateStyleContainer [get-preferred fallback] + IStyleContainer + (mount-style! + [_ style-name css] + (or (when-let [preferred (get-preferred)] + (sc/mount-style! preferred style-name css)) + (sc/mount-style! fallback style-name css)))) diff --git a/src/spade/container/atom.cljc b/src/spade/container/atom.cljc new file mode 100644 index 0000000..42a57d3 --- /dev/null +++ b/src/spade/container/atom.cljc @@ -0,0 +1,9 @@ +(ns spade.container.atom + "The AtomStyleContainer renders styles into an atom it is provided with." + (:require [spade.container :refer [IStyleContainer]])) + +(deftype AtomStyleContainer [styles-atom] + IStyleContainer + (mount-style! [_ style-name css] + (swap! styles-atom assoc style-name css))) + diff --git a/src/spade/container/dom.cljs b/src/spade/container/dom.cljs new file mode 100644 index 0000000..2a1bf9a --- /dev/null +++ b/src/spade/container/dom.cljs @@ -0,0 +1,61 @@ +(ns spade.container.dom + "The DomStyleContainer renders styles into DOM elements. References to those + elements are stored in a `styles` atom, or `*injected-styles*` if that is + not provided. Similarly, if no `target-dom` is provided, the `document.head` + element is used." + (:require [spade.container :refer [IStyleContainer]])) + +(defonce ^:dynamic *injected-styles* (atom nil)) + +(defn- perform-update! [obj css] + (set! (.-innerHTML (:element obj)) css)) + +(defn update! [styles-container id css] + (swap! styles-container update id + (fn update-injected-style [obj] + (when-not (= (:source obj) css) + (perform-update! obj css)) + (assoc obj :source css)))) + +(defn inject! [target-dom styles-container id css] + (let [element (doto (js/document.createElement "style") + (.setAttribute "spade-id" (str id))) + obj {:element element + :source css + :id id}] + (assert (some? target-dom) + "An element or target DOM is required to inject the style.") + + (.appendChild target-dom element) + + (swap! styles-container assoc id obj) + (perform-update! obj css))) + +(deftype DomStyleContainer [target-dom styles] + IStyleContainer + (mount-style! [_ style-name css] + (let [resolved-container (or styles + *injected-styles*)] + (if (contains? @resolved-container style-name) + (update! resolved-container style-name css) + + (let [resolved-dom (or (when (ifn? target-dom) + (target-dom)) + target-dom + (.-head js/document))] + (inject! resolved-dom resolved-container style-name css)))))) + +(defn create-container + "Create a DomStyleContainer. With no args, the default is created, which + renders into the `document.head` element. For rendering into a custom + target, such as when using Shadow DOM, you may provide a custom + `target-dom`: this may either be the element itself, or a function which + returns that element. + + If you also wish to provide your own storage for the style references, you + may use the 3-arity version and provide an atom." + ([] (create-container nil)) + ([target-dom] (create-container target-dom (when target-dom + (atom nil)))) + ([target-dom styles-container] + (->DomStyleContainer target-dom styles-container))) diff --git a/src/spade/core.cljc b/src/spade/core.cljc index 03962c4..40054e5 100644 --- a/src/spade/core.cljc +++ b/src/spade/core.cljc @@ -1,6 +1,9 @@ (ns spade.core + #?(:cljs (:require-macros [net.cgrand.macrovich :as macros])) (:require [clojure.string :as str] [clojure.walk :refer [postwalk prewalk]] + #?@(:clj [[net.cgrand.macrovich :as macros] + [spade.runtime]]) [spade.util :refer [factory->name build-style-name]])) (defn- extract-key [style] @@ -255,7 +258,10 @@ (defn ~factory-fn-name ~factory-params ~(transform-style mode style params style-name-var params-var)) - (let [~factory-name-var (factory->name ~factory-fn-name)] + (let [~factory-name-var (factory->name + (macros/case + :cljs ~factory-fn-name + :clj (var ~factory-fn-name)))] ~(declare-style mode class-name params factory-name-var factory-fn-name))))) (defmacro defclass @@ -321,3 +327,10 @@ {:animation [[(anim-frames) \"560ms\" 'ease-in-out]]})" [keyframes-name params & style] (declare-style-fns :keyframes keyframes-name params style)) + +(defmacro with-styles-container [container & body] + (macros/case + :cljs `(binding [spade.runtime/*style-container* ~container] + ~@body) + :clj `(with-bindings {#'spade.runtime/*style-container* ~container} + ~@body))) diff --git a/src/spade/react.cljs b/src/spade/react.cljs new file mode 100644 index 0000000..90ffd1f --- /dev/null +++ b/src/spade/react.cljs @@ -0,0 +1,49 @@ +(ns spade.react + (:require [react :as react] + [spade.container :refer [IStyleContainer]] + [spade.container.alternate :refer [->AlternateStyleContainer]] + [spade.container.dom :as dom] + [spade.runtime :refer [*style-container*]])) + +(defonce context (react/createContext nil)) +(defonce Provider (.-Provider context)) + +(defn- provided-container [] + ;; NOTE: This hack is inspired by ReactN: + ;; https://charles-stover.medium.com/how-reactn-hacks-react-context-9d112397f003 + (or (.-_currentValue2 context) + (.-_currentValue context))) + +;; NOTE: As soon as this namespace is used, we "upgrade" the global style-container +;; to also check the react context +(set! *style-container* + (->AlternateStyleContainer + provided-container + *style-container*)) + +(defn with-style-container + "Uses the provided IStyleContainer to render the styles of the children + elements. This is a reagent-style React component, meant to wrap whatever + part of your app needs to have its styled rendered elsewhere (often the + root of the app), eg: + + [with-style-container container + [your-view]] + + Note that behavior in the presence of a reactively changing `container` is + undefined. You should expect to declare one container per render." + [^IStyleContainer container & children] + (into [:> Provider {:value container}] + children)) + +(defn with-dom + "A convenience for rendering Spade styles into an alternate dom target. + The first argument may either be an actual DOM element, or a function which + returns one. + + See `with-style-container`, which this uses under the hood." + [get-dom-target & _children] + (let [container (dom/create-container get-dom-target)] + (fn with-dom-render [_ & children] + (into [with-style-container container] + children)))) diff --git a/src/spade/runtime.cljc b/src/spade/runtime.cljc new file mode 100644 index 0000000..0feb1f5 --- /dev/null +++ b/src/spade/runtime.cljc @@ -0,0 +1,51 @@ +(ns spade.runtime + (:require [clojure.string :as str] + [garden.core :as garden] + [garden.types :refer [->CSSFunction]] + [spade.container :as sc] + [spade.runtime.defaults :as defaults])) + +(defonce ^:dynamic *css-compile-flags* + {:pretty-print? #? (:cljs goog.DEBUG + :clj false)}) + +(defonce ^:dynamic *style-container* (defaults/create-container)) + +(defn ->css-var [n] + (->CSSFunction "var" n)) + +(defn compile-css [elements] + (garden/css *css-compile-flags* elements)) + +(defn- compose-names [{style-name :name composed :composes}] + (if-not composed + style-name + (str/join " " + (->> + (if (seq? composed) + (into composed style-name) + [composed style-name]) + (map (fn [item] + (cond + (string? item) item + + ; unpack a defattrs + (and (map? item) + (string? (:class item))) + (:class item) + + :else + (throw (ex-info + (str "Invalid argument to :composes key:" + item) + {}))))))))) + +(defn ensure-style! [mode base-style-name factory params] + (let [{css :css style-name :name :as info} (apply factory base-style-name params params)] + + (sc/mount-style! *style-container* style-name css) + + (case mode + :attrs {:class (compose-names info)} + (:class :keyframes) (compose-names info) + :global css))) diff --git a/src/spade/runtime.cljs b/src/spade/runtime.cljs deleted file mode 100644 index 56cce70..0000000 --- a/src/spade/runtime.cljs +++ /dev/null @@ -1,81 +0,0 @@ -(ns spade.runtime - (:require [clojure.string :as str] - [garden.core :as garden] - [garden.types :refer [->CSSFunction]])) - -(defonce - ^{:private true - :dynamic true} - *injected* (atom {})) - -(defonce ^:dynamic *css-compile-flags* - {:pretty-print? goog.DEBUG}) - -(defn ->css-var [n] - (->CSSFunction "var" n)) - -(defn compile-css [elements] - (garden/css *css-compile-flags* elements)) - -(defn- perform-update! [obj css] - (set! (.-innerHTML (:element obj)) css)) - -(defn update! [id css] - (swap! *injected* update id - (fn update-injected-style [obj] - (when-not (= (:source obj) css) - (perform-update! obj css)) - (assoc obj :source css)))) - -(defn inject! [id css] - (let [head (.-head js/document) - element (doto (js/document.createElement "style") - (.setAttribute "spade-id" (str id))) - obj {:element element - :source css - :id id}] - (assert (some? head) - "An head element is required in the dom to inject the style.") - - (.appendChild head element) - - (swap! *injected* assoc id obj) - (perform-update! obj css))) - -(defn- compose-names [{style-name :name composed :composes}] - (if-not composed - style-name - (str/join " " - (->> - (if (seq? composed) - (into composed style-name) - [composed style-name]) - (map (fn [item] - (cond - (string? item) item - - ; unpack a defattrs - (and (map? item) - (string? (:class item))) - (:class item) - - :else - (throw (js/Error. - (str "Invalid argument to :composes key:" - item)))))))))) - -(defn ensure-style! [mode base-style-name factory params] - (let [{css :css style-name :name :as info} (apply factory base-style-name params params) - existing (get @*injected* style-name)] - - (if existing - ; update existing style element - (update! style-name css) - - ; create a new element - (inject! style-name css)) - - (case mode - :attrs {:class (compose-names info)} - (:class :keyframes) (compose-names info) - :global css))) diff --git a/src/spade/runtime/defaults.clj b/src/spade/runtime/defaults.clj new file mode 100644 index 0000000..300e7a2 --- /dev/null +++ b/src/spade/runtime/defaults.clj @@ -0,0 +1,7 @@ +(ns spade.runtime.defaults + (:require [spade.container.atom :refer [->AtomStyleContainer]])) + +(defonce shared-styles-atom (atom nil)) + +(defn create-container [] + (->AtomStyleContainer shared-styles-atom)) diff --git a/src/spade/runtime/defaults.cljs b/src/spade/runtime/defaults.cljs new file mode 100644 index 0000000..7149be5 --- /dev/null +++ b/src/spade/runtime/defaults.cljs @@ -0,0 +1,5 @@ +(ns spade.runtime.defaults + (:require [spade.container.dom :as dom])) + +(defn create-container [] + (dom/create-container)) diff --git a/src/spade/util.cljc b/src/spade/util.cljc index f30b896..b1582ec 100644 --- a/src/spade/util.cljc +++ b/src/spade/util.cljc @@ -7,7 +7,8 @@ factory; subsequent calls for the same factory *may not* return the same value (especially under :simple optimizations)." [factory] - (let [given-name (.-name factory)] + (let [given-name #?(:cljs (.-name factory) + :clj (-> factory meta :name str))] (if (empty? given-name) ; under :simple optimizations, the way the function is declared does ; not leave any value for its name. so... generate one! @@ -17,7 +18,7 @@ ; this lets us have descriptive names in dev, and concise names in ; prod, without having to embed anything extra in the file (-> given-name - (str/replace "_factory$" "") + (str/replace #"[_-]factory\$" "") (str/replace #"[_$]" "-") (str/replace #"^-" "_"))))) diff --git a/test/spade/jvm_test.clj b/test/spade/jvm_test.clj new file mode 100644 index 0000000..6f541ec --- /dev/null +++ b/test/spade/jvm_test.clj @@ -0,0 +1,18 @@ +(ns spade.jvm-test + (:require [clojure.test :refer [deftest is testing]] + [spade.container.atom :refer [->AtomStyleContainer]] + [spade.core :refer [defclass with-styles-container]])) + +(defclass blue-class [] + {:color "blue"}) + +(deftest with-styles-container-test + (testing "Render styles to dynamically-provided Atom container" + (let [styles (atom nil) + container (->AtomStyleContainer styles) + style-name (with-styles-container container + (blue-class))] + (is (= "blue-class" style-name)) + (is (= ".blue-class{color:blue}" + (get @styles style-name)))))) +