From 22c673e51d21fe186a9779f35c3223b3aead5e86 Mon Sep 17 00:00:00 2001 From: Daniel Leong Date: Sat, 20 Mar 2021 10:47:15 -0400 Subject: [PATCH] Experiment: add special syntax for CSS variables (#8) * Experiment: add special syntax for CSS variables At compile time, keywords with the namespace "var" get transformed to refer to CSS variables. Map keys like `:var/font-size` become `:--font-size` and map values become `:var(font-size)`. This syntax seems nice since, at least in the "resolve" version, it *looks like* the equivalent CSS. However, this syntax means that all vars are global, unnamespaced, which is perhaps not ideal. So, this is sort of a proof of concept. Perhaps a better syntax would be eg `::*font-size*`. This is somewhat familiar due to clojure's use of earmuffs for dynamic vars, and also allows namespacing with native clojure namespaces. "Global" vars if desired could still be created as eg `:global/*font-size*`. * Switch to the proposed :*var* syntax * Handle css vars within forms, eg `[[:*var* :!important]]` * Fill out tests for var transform across all macros * Document earmuff variables in the README * Add a bit more info to the CSS vars documentation * Explicitly call out CSS Custom Property browser support * Restore deleted test cases Don't remember why I removed these, but the tests do pass --- README.md | 62 ++++++++++++++++++++++++++++++++++ dev/spade/demo.cljs | 5 +-- src/spade/core.cljc | 53 ++++++++++++++++++++++++++--- src/spade/runtime.cljs | 6 +++- test/spade/core_test.cljs | 70 +++++++++++++++++++++++++++++++++++---- 5 files changed, 182 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1e45d0f..0812432 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,67 @@ appropriate CSS into the DOM on-demand, and returns the animation identifier: {:animation [[(anim-frames) "560ms" 'ease-in-out]]}) ``` +### Syntactic Sugar + +Spade also provides some extra syntactic sugar, performed at compile time +for "zero-cost" abstractions. + +#### CSS Custom Properties + +CSS custom properties (AKA variables) can be a convenient way to, for +example, define dynamic theme colors once and reuse them throughout the +codebase. Spade provides some extra sugar to make using them easier and +more idiomatic: + +```clojure +(defglobal light-dark + (at-media {:prefers-color-scheme 'dark} + [":root" {:theme/*background* "#000" + :theme/*text* "#E0EBFF"}]) + [":root" {:theme/*background* "#fff" + :theme/*text* "#000"}]) + +(defclass page [] + {:background :theme/*background* + :color :theme/*text*}) +``` + +Notice how our declaration and usage sites are identical, and that +they're "just" normal keywords. However, by using `*earmuffs*` around +the name of the keyword, Spade knows that it is meant to be a variable, +and applies the correct CSS styling based on the position within the +style. This naming was chosen because it is reminiscent of the naming +of dynamic Clojure vars, and because `*` is not valid in a CSS property +name, so the meaning is unambiguous. + +Normal keyword namespace semantics apply, so you can expect that +`:theme/*text*` and `:crew.quarters/*text*` will result in distinct +variables. In fact, you don't need any namespace at all; `:*text*` will +also result in a perfectly valid CSS variable, disinct from any of the +other two mentioned above. + +The above example will generate the following CSS: + +```css +@media (prefers-color-scheme: dark) { + :root { + --theme--background: #000; + --theme--text: #E0EBFF; + } +} + +:root { + --theme--background: #fff; + --theme--text: #000; +} + +.page { + background: var(--theme--background); + color: var(--theme--text); +} +``` + +Note that [CSS Custom Properties are not supported on all browsers][4], and this syntax compiles to that feature directly without any attempt at backwards compatibility—if CSS Custom Properties are not supported on a browser you are targetting, this syntax will also not be supported. ## Development @@ -172,3 +233,4 @@ Distributed under the Eclipse Public License either version 1.0 [1]: https://github.com/css-modules/css-modules [2]: https://github.com/noprompt/garden/ [3]: https://github.com/roosta/herb +[4]: https://caniuse.com/css-variables diff --git a/dev/spade/demo.cljs b/dev/spade/demo.cljs index eb0fe82..e959268 100644 --- a/dev/spade/demo.cljs +++ b/dev/spade/demo.cljs @@ -12,7 +12,8 @@ ["100%" {:opacity end}]) (defglobal background - [:body {:background "#333"}]) + [:body {:*my-var* "22pt" + :background "#333"}]) (defglobal text [:body {:color "#fff"}]) @@ -22,7 +23,7 @@ {:padding "80px"}) {:padding "8px"} - [:.title {:font-size "22pt" + [:.title {:font-size :*my-var* :animation [[(parameterized-anim-frames 0 0.5) "560ms" 'ease-in-out]]}]) (defclass colorized-with-key [color] diff --git a/src/spade/core.cljc b/src/spade/core.cljc index 0057d85..03962c4 100644 --- a/src/spade/core.cljc +++ b/src/spade/core.cljc @@ -1,5 +1,6 @@ (ns spade.core - (:require [clojure.walk :refer [postwalk]] + (:require [clojure.string :as str] + [clojure.walk :refer [postwalk prewalk]] [spade.util :refer [factory->name build-style-name]])) (defn- extract-key [style] @@ -34,6 +35,49 @@ element)) style)) +(defn- clean-property-name [n] + (when n + (str/replace n #"[^a-zA-Z0-9_-]" "-"))) + +(defn- css-var? [element] + (and (keyword? element) + (let [n (name element)] + (and (str/starts-with? n "*") + (str/ends-with? n "*"))))) + +(defn- varify-key [element] + (let [space (namespace element) + n (name element)] + (keyword (str (when space "--") + (clean-property-name (namespace element)) + "--" + (clean-property-name + (subs n 1 (dec (count n)))))))) + +(defn- varify-val [element] + `(spade.runtime/->css-var ~(varify-key element))) + +(defn- rename-vars [style] + (prewalk + (fn [element] + (cond + (map-entry? element) + (let [var-key? (css-var? (key element)) + var-val? (css-var? (val element))] + (if (or var-key? var-val?) + (-> element + (update 0 (if var-key? varify-key identity)) + (update 1 (if var-val? varify-val identity))) + + element)) + + (css-var? element) + (varify-val element) + + :else + element)) + style)) + (defn- extract-composes [style] (if-let [composes (when (map? (first style)) (:composes (first style)))] @@ -94,7 +138,7 @@ (defn- transform-named-style [style params style-name-var params-var] (let [[composition style] (extract-composes style) style-var (gensym "style") - style (prefix-at-media style) + style (->> style prefix-at-media rename-vars) [base-style-var name-var name-let] (build-style-naming-let style params style-name-var params-var) @@ -106,7 +150,8 @@ ~(with-composition composition name-var style-var)))) (defn- transform-keyframes-style [style params style-name-var params-var] - (let [[style-var name-var style-naming-let] (build-style-naming-let + (let [style (->> style prefix-at-media rename-vars) + [style-var name-var style-naming-let] (build-style-naming-let style params style-name-var params-var) info-map `{:css (spade.runtime/compile-css @@ -127,7 +172,7 @@ (let [style (replace-at-forms style)] (cond (#{:global} mode) - `{:css (spade.runtime/compile-css ~(vec style)) + `{:css (spade.runtime/compile-css ~(vec (rename-vars style))) :name ~style-name-var} ; keyframes are a bit of a special case diff --git a/src/spade/runtime.cljs b/src/spade/runtime.cljs index 0ef24fc..56cce70 100644 --- a/src/spade/runtime.cljs +++ b/src/spade/runtime.cljs @@ -1,6 +1,7 @@ (ns spade.runtime (:require [clojure.string :as str] - [garden.core :as garden])) + [garden.core :as garden] + [garden.types :refer [->CSSFunction]])) (defonce ^{:private true @@ -10,6 +11,9 @@ (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)) diff --git a/test/spade/core_test.cljs b/test/spade/core_test.cljs index 661cf1b..eee3347 100644 --- a/test/spade/core_test.cljs +++ b/test/spade/core_test.cljs @@ -3,6 +3,14 @@ [clojure.string :as str] [spade.core :refer [defattrs defclass defglobal defkeyframes]])) +; for the linter's sake: +(declare with-media-factory$ + class-with-vars-factory$ + fixed-style-attrs-factory$ + composed-factory$ + composed-attrs-factory$ + parameterized-key-frames-factory$) + (defclass computed-key [color] ^{:key (str/upper-case color)} {:color color}) @@ -20,6 +28,13 @@ {:background "blue"} [:.nested {:background "red"}])) +(defclass class-with-vars [] + {:*my-var* "42pt" + ::*namespaced* "blue" + :font-size :*my-var* + :background ::*namespaced* + :color [[:*my-var* :!important]]}) + (deftest defclass-test (testing "computed-key test" (is (= "spade-core-test-computed-key_BLUE" @@ -41,20 +56,47 @@ generated (str "@media (max-width: 50px) { " ".with-media { background: blue; } " - ".with-media .nested { background: red; }")))))) + ".with-media .nested { background: red; }"))))) + + (testing "CSS var declaration and usage" + (let [generated (-> (class-with-vars-factory$ "class-with-vars" []) + :css + (str/replace #"\s+" " "))] + (is (str/includes? + generated + (str ".class-with-vars {" + " --my-var: 42pt;" + " --spade-core-test--namespaced: blue;" + " font-size: var(--my-var);" + " background: var(--spade-core-test--namespaced);" + " color: var(--my-var) !important;" + " }")))))) (defattrs fixed-style-attrs [] - {:color "blue"}) + {:*my-var* "blue" + :color :*my-var*}) (deftest defattrs-test (testing "Return map from defattrs" (is (= {:class "spade-core-test-fixed-style-attrs"} - (fixed-style-attrs))))) + (fixed-style-attrs)))) + + (testing "CSS var declaration and usage" + (let [generated (-> (fixed-style-attrs-factory$ "with-vars" []) + :css + (str/replace #"\s+" " "))] + (is (str/includes? + generated + (str ".with-vars {" + " --my-var: blue;" + " color: var(--my-var);" + " }")))))) (defglobal global-1 - [:body {:background "blue"}]) + [":root" {:*background* "blue"}] + [:body {:background :*background*}]) (defglobal global-2 (at-media {:min-width "42px"} @@ -63,7 +105,8 @@ (deftest defglobal-test (testing "Declare const var with style from global" (is (string? global-1)) - (is (= "body {\n background: blue;\n}" + (is (= (str ":root {\n --background: blue;\n}\n\n" + "body {\n background: var(--background);\n}") global-1))) (testing "Support at-media automatically" @@ -76,7 +119,8 @@ [:from {:opacity 0}]) (defkeyframes parameterized-key-frames [from] - [:from {:opacity from}]) + [:from {::*from* from + :opacity ::*from*}]) (deftest defkeyframes-test (testing "Return keyframes name from defkeyframes" @@ -87,7 +131,19 @@ (testing "Return dynamic keyframes name from parameterized defkeyframes" (is (fn? key-frames)) (is (= (str "spade-core-test-parameterized-key-frames_" (hash [0])) - (parameterized-key-frames 0))))) + (parameterized-key-frames 0)))) + + (testing "CSS var declaration and usage" + (let [generated (-> (parameterized-key-frames-factory$ + "with-vars" [42] 42) + :css + (str/replace #"\s+" " "))] + (is (str/includes? + generated + (str "from {" + " --spade-core-test--from: 42;" + " opacity: var(--spade-core-test--from);" + " }")))))) (defclass composed [color] ^{:key color}