├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.clj ├── cljfmt.edn ├── deps.edn ├── doc ├── BuildingComponents.md ├── ConciseDataFormat.md └── StateManagement.md ├── examples └── todo │ ├── .gitignore │ ├── README.md │ ├── cljfmt.edn │ ├── deps.edn │ ├── pub │ ├── index.html │ ├── package.json │ └── yarn.lock │ ├── shadow-cljs.edn │ ├── src │ └── zero │ │ └── demos │ │ └── todo │ │ ├── app.cljs │ │ └── todo.cljc │ └── test │ ├── snapshots │ ├── accepted │ │ └── snapshots_test │ │ │ ├── todo_view_initial.edn │ │ │ ├── todo_view_w_completed_item.edn │ │ │ ├── todo_view_w_editing_item.edn │ │ │ ├── todo_view_w_input.edn │ │ │ └── todo_view_w_items.edn │ └── current │ │ └── snapshots_test │ │ ├── todo_view_initial.edn │ │ ├── todo_view_w_completed_item.edn │ │ ├── todo_view_w_editing_item.edn │ │ ├── todo_view_w_input.edn │ │ └── todo_view_w_items.edn │ └── snapshots_test.cljc ├── meta.edn ├── package.json ├── shadow-cljs.edn ├── src └── zero │ ├── cdf.cljc │ ├── config.cljc │ ├── core.cljc │ ├── dom.cljs │ ├── extras │ └── db.cljc │ ├── html.cljc │ ├── impl │ ├── actions.cljc │ ├── base.cljc │ ├── bindings.cljc │ ├── default_db.cljc │ ├── injection.cljc │ └── signals.cljc │ ├── tools │ ├── portfolio.cljc │ └── test.cljc │ ├── util.cljc │ └── wcconfig.cljs ├── test ├── browser │ └── zero │ │ └── placeholder_btest.cljs ├── cljc │ └── zero │ │ └── core_test.cljc └── cljs │ └── zero │ └── placeholder_test.cljs └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'src/**' 8 | - 'test/**' 9 | - 'deps.edn' 10 | - 'package.json' 11 | - 'shadow-cljs.edn' 12 | - 'yarn.lock' 13 | - '.github/workflows/ci.yml' 14 | pull_request: 15 | branches: [ main ] 16 | paths: 17 | - 'src/**' 18 | - 'test/**' 19 | - 'deps.edn' 20 | - 'package.json' 21 | - 'shadow-cljs.edn' 22 | - 'yarn.lock' 23 | - '.github/workflows/ci.yml' 24 | 25 | jobs: 26 | test: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | - name: Prepare java 32 | uses: actions/setup-java@v3 33 | with: 34 | distribution: 'zulu' 35 | java-version: '21' 36 | - name: Install clojure tools 37 | uses: DeLaGuardo/setup-clojure@12.5 38 | with: 39 | cli: 1.11.1.1435 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: v22.x 43 | cache: 'yarn' 44 | - name: Cache dependencies 45 | id: cache-deps 46 | uses: actions/cache@v3 47 | with: 48 | path: | 49 | ~/.m2/repository 50 | ~/node_modules 51 | ~/.cpcache 52 | key: deps-${{ hashFiles('deps.edn', 'package.json') }} 53 | restore-keys: deps- 54 | - name: Install Yarn dependencies 55 | run: yarn 56 | - name: Run browser tests 57 | run: yarn test 58 | - name: Run Clojure tests 59 | run: clojure -X:test-clj 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .clj-kondo 2 | .cpcache 3 | .lsp 4 | !.lsp/config.edn 5 | .vscode 6 | .calva 7 | .nrepl-port 8 | .shadow-cljs 9 | .DS_Store 10 | node_modules 11 | *.iml 12 | .idea 13 | pom.xml 14 | *.pom.asc 15 | target/ 16 | cljs-runtime/ 17 | test/snapshots/current/ 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Ray A. Stubbs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Clojars Project](https://img.shields.io/clojars/v/me.raystubbs/zero.svg)](https://clojars.org/me.raystubbs/zero) 3 | ![Test Badge](https://github.com/raystubbs/zero/actions/workflows/ci.yml/badge.svg) 4 | [![cljdoc badge](https://cljdoc.org/badge/me.raystubbs/zero)](https://cljdoc.org/d/me.raystubbs/zero) 5 | 6 | # Zero 7 | Build web UIs in Clojure/Script, the easy way. 8 | 9 | ## Why? 10 | - Zero components are [Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components), which means 11 | they're easy to use from anywhere, including: 12 | - Raw HTML 13 | - JavaScript DOM API 14 | - Any frontend framework 15 | 16 | Besides their reusability, Web Components have many useful features that are lacking from traditional React-style 17 | components, for example: 18 | - [Slots](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot) are great for boosting reconciliation performance 19 | - Stylesheet encapsulation means components can include their own stylesheets, without affecting anything else on the page 20 | - An enclosing DOM node to which custom styling and event handlers can be attached 21 | - Components can be (partially or fully) rendered to raw HTML. 22 | - Component markup is rendered into a [declarative shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM#declaratively_with_html). 23 | - Structured data can be rendered to element attributes in a built-in format designed for that purpose. 24 | - A robust data-oriented state management system makes building complex components a breeze. 25 | - Component view functions can be pure, and generate pure data; easy to test and reason about 26 | - State management constructs are themselves data, they can be serialized/deserialized, compared semantically, etc. 27 | 28 | ## Example 29 | ```clojure 30 | (ns zero.demos.bullet-list " 31 | A simple bullet list. 32 | " 33 | (:require 34 | [zero.core :refer [< 80 | :#css css 81 | :#style {:display "block"} 82 | (map 83 | (fn [option] 84 | [:button.option 85 | :#on {:click (act [::zd/dispatch :value :data (:value option)])} 86 | [:div.bullet 87 | :#class (when (= (:value option) value) :selected) 88 | (when (= (:value option) value) 89 | [:div.iris 90 | :#style {:height "0.75rem" :width "0.75rem"}])] 91 | (:view option)]) 92 | options)]) 93 | 94 | (zc/reg-components 95 | :bullet-list 96 | {:props #{:options :value} 97 | :view view}) 98 | ``` 99 | ![chrome_ZrUnG34Vyj](https://github.com/user-attachments/assets/bf772625-fc8d-4323-9493-bb7518d412c7) 100 | 101 | ## Setup 102 | Add something akin to the following somewhere in your boot up logic: 103 | ```clojure 104 | (zero.config/install! zero.config/!default-db) 105 | 106 | ;; only for browsers, sets up the web component registry 107 | (zero.wcconfig/install! zero.config/!default-db) 108 | 109 | ;; only for browsers, and optional, adds some DOM utilities and convenient components 110 | (zero.dom/install! zero.config/!default-db) 111 | ``` 112 | 113 | Register components with `zero.config/reg-components`. 114 | 115 | On the browser side, registered components will be added to the browser's web 116 | component registry. So any time the browser attaches a matching DOM element to the page, 117 | your registered component will be used. 118 | 119 | When rendering to HTML, component views are rendered into 120 | [declarative shadow DOMs](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM). 121 | Attributes for registered Zero components are serialized as 122 | [CDF (Concise Data Format)](./doc/ConciseDataFormat.md), which allows for seamless 123 | transfer of structured data to browser-side component implementations. 124 | 125 | ## Learning 126 | Here are a few resources to help learn the basics: 127 | - [State Management](./doc/StateManagement.md) 128 | - [SubZero Markup Syntax](https://github.com/raystubbs/subzero?tab=readme-ov-file#markup) 129 | - [Building Components](./doc/BuildingComponents.md) 130 | - [Concise Data Format](./doc/ConciseDataFormat.md) 131 | 132 | And some (only one for now) examples: 133 | - [TodoMVC](./examples/todo) 134 | 135 | You can also browse [c0](https://github.com/raystubbs/c0) (a library of Zero components) 136 | for examples. 137 | 138 | ## Contact 139 | Feel free to reach out in the [#zero-lib](https://clojurians.slack.com/archives/C06UFMY5LUW) 140 | channel on the Clojurians slack for any help, questions, feedback, etc. 141 | -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require 3 | [clojure.tools.build.api :as b] 4 | [clojure.edn :as edn] 5 | [clojure.string :as str] 6 | [clojure.java.io :as io] 7 | [deps-deploy.deps-deploy :as d]) 8 | (:import 9 | java.io.File)) 10 | 11 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 12 | 13 | (defn clean [_] 14 | (b/delete {:path "target"})) 15 | 16 | (defn- bump [n] 17 | (let [meta (-> (slurp "meta.edn") 18 | edn/read-string 19 | (update-in [:version n] inc)) 20 | version-str (str/join "." (:version meta))] 21 | (b/git-process {:git-args "stash"}) 22 | (spit "meta.edn" (pr-str meta)) 23 | (b/git-process {:git-args (str "commit -a -m v" version-str)}) 24 | (b/git-process {:git-args (str "tag -a v" version-str " -m v" version-str)}) 25 | (b/git-process {:git-args (str "push origin v" version-str)}))) 26 | 27 | (defn bump-patch [_] 28 | (bump 2)) 29 | 30 | (defn bump-minor [_] 31 | (bump 1)) 32 | 33 | (defn bump-major [_] 34 | (bump 0)) 35 | 36 | (defn jar [_] 37 | (run! io/delete-file (reverse (file-seq (File. "target")))) 38 | (let [{:keys [version name]} (edn/read-string (slurp "meta.edn")) 39 | version-str (str/join "." version) 40 | class-dir "target/classes"] 41 | (b/write-pom 42 | {:class-dir class-dir 43 | :lib name 44 | :version version-str 45 | :basis @basis 46 | :src-dirs ["src"] 47 | :pom-data [[:licenses 48 | [:license 49 | [:name "The MIT License"] 50 | [:url "https://opensource.org/license/mit/"] 51 | [:distribution "repo"]]] 52 | [:scm 53 | [:url "https://github.com/raystubbs/zero"] 54 | [:connection "scm:git:https://github.com/raystubbs/zero.git"] 55 | [:developerConnection "scm:git:ssh://git@github.com:raystubbs/zero.git"]]]}) 56 | (b/copy-dir 57 | {:src-dirs ["src"] 58 | :target-dir class-dir}) 59 | (b/jar 60 | {:class-dir class-dir 61 | :jar-file (format "target/%s-%s.jar" (clojure.core/name name) version-str)}))) 62 | 63 | (defn deploy [_] 64 | (let [{:keys [version name]} (edn/read-string (slurp "meta.edn")) 65 | version-str (str/join "." version)] 66 | (d/deploy 67 | {:installer :remote 68 | :artifact (format "target/%s-%s.jar" (clojure.core/name name) version-str) 69 | :pom-file (str "target/classes/META-INF/maven/" (namespace name) "/" (clojure.core/name name) "/pom.xml") 70 | :sign-releases? true}))) -------------------------------------------------------------------------------- /cljfmt.edn: -------------------------------------------------------------------------------- 1 | {:remove-surrounding-whitespace? true 2 | :remove-trailing-whitespace? true 3 | :remove-consecutive-blank-lines? false 4 | :insert-missing-whitespace? false 5 | :indents {#re ".+" [[:inner 0]]}} -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {me.raystubbs/subzero {:mvn/version "0.0.5"}} 3 | 4 | :aliases 5 | {:dev 6 | {:extra-deps {org.clojure/clojure {:mvn/version "1.11.3"} 7 | org.clojure/clojurescript {:mvn/version "1.11.60"}} 8 | :extra-paths []} 9 | 10 | :build 11 | {:deps {io.github.clojure/tools.build {:git/tag "v0.9.6" :git/sha "8e78bcc"} 12 | slipset/deps-deploy {:mvn/version "0.2.2"}} 13 | :ns-default build} 14 | 15 | :test-cljs 16 | {:extra-deps {thheller/shadow-cljs {:mvn/version "2.21.0"}} 17 | :extra-paths ["test/cljs" "test/cljc" "test/browser"]} 18 | 19 | :test-clj 20 | {:extra-paths ["test/cljc" "test/clj"] 21 | :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 22 | :main-opts ["-m" "cognitect.test-runner"] 23 | :exec-fn cognitect.test-runner.api/test}}} 24 | -------------------------------------------------------------------------------- /doc/BuildingComponents.md: -------------------------------------------------------------------------------- 1 | # Building Components 2 | The purpose of Zero is to help you build UI components for the web. So, 3 | this is a guide to help towards that end. 4 | 5 | The two most important concepts surrounding Zero components are the `view` 6 | function, and props. 7 | 8 | The `view` function is the thing that renders your component. It should 9 | (generally) be a pure function that accepts a map of prop values, and returns 10 | some [SubZero markup](https://github.com/raystubbs/subzero#markup) (dubbed 11 | the vDOM, or virtual DOM). 12 | 13 | What happens with this vDOM depends on how Zero is being used. 14 | 15 | If it's being used in the browser to build web components, then each Zero 16 | component becomes a [Web Component](https://developer.mozilla.org/en-US/docs/Web/API/Web_components). 17 | And [SubZero](https://github.com/raystubbs/subzero) (which Zero is built upon) 18 | will update the component's shadow DOM to reflect the `view`s vDOM. Whenever 19 | the component's prop values change, this process will happen again, so the 20 | component's DOM will stay up to date. We call this reactivity. 21 | 22 | If the component is being rendered to HTML (for SSR, SSG, etc.) then SubZero 23 | will call the registered `view` function with any props given for the element, 24 | and the resulting vDOM will be rendered into a 25 | [declarative shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM#declaratively_with_html) 26 | for the component. HTML rendering can be used either as the sole rendering 27 | for the component, or as a pre-rendering step, in which case a client-side 28 | component implementation can take over the HTML-rendered DOM. 29 | 30 | Props are the component inputs. They must be explicitly declared when 31 | registering a component. The props can be given either as a set of 32 | names (which will be setup in the default way) or as a map of names 33 | to some value... which allows for more customization, but gets more 34 | complicated for web components. For HTML-rendering, the map values 35 | are ignored (for now) so map vs set makes no difference. 36 | 37 | ```clojure 38 | (ns example 39 | (:require 40 | [zero.config :as zc])) 41 | 42 | (defn- my-component-view 43 | [{:keys [whom]}] 44 | [:div "Hello, " whom "!"]) 45 | 46 | (zc/reg-components 47 | :my-component 48 | {:props #{:whom} 49 | :view my-component-view}) 50 | ``` 51 | 52 | Components can be implemented in `*.cljc` files to make them usable from 53 | both Clojure (for SSR or SSG) and ClojureScript. However it may also be 54 | useful to have separate implementations of the same component, for HTML-rendering 55 | and client-side rendering. In which case I'd recommend separate `*.cljs` and `*.cljc` 56 | files with the same base name. For applications where the back-end and front-end 57 | are both built in ClojureScript... well, you'll probably need to do some dynamic 58 | checking, or use a Closure define to get the compiler to get rid of the unwanted 59 | implementation during tree shaking. 60 | 61 | ## Web Components 62 | When running Zero within the browser, and with the web component plugin installed 63 | (via `zero.wcconfig/install!`), every Zero component becomes a Web Component in 64 | your browser. This means the browser will recognize when a matching DOM element 65 | is attached to the document (no matter how this happens) and will wire it up with your 66 | component logic. It's rather convenient. 67 | 68 | Web components have a lot of extra registration options that don't make much 69 | sense for HTML-rendered components. I'll give a brief overview here, but check 70 | [the SubZero docs](https://github.com/raystubbs/subzero?tab=readme-ov-file#component-registration-options) 71 | for details. 72 | 73 | ### Props 74 | The most important thing is that web components have more powerful props. Whereas 75 | HTML-rendered components need to be passed all prop values explicitly; web components 76 | can get prop values from various sources: JavaScript properties on the host 77 | element, HTML attributes on the host element, or any watchable thing. This allows 78 | web components to be much more dynamic. 79 | 80 | When we register a web component with a set of prop names (rather than a full map) 81 | all props are given the `:default` behavior. Which means a JS property matching the 82 | prop name will be generated for the component class, and the component will watch for 83 | changes to any attribute matching the prop name. The current prop value will reflect 84 | the last thing updated, out of the attribute and JS property. For most components, 85 | this is okay behavior for all props. 86 | 87 | ```clojure 88 | (zc/reg-components 89 | :my-component 90 | {:props #{:foo :bar} 91 | :view my-view}) 92 | ``` 93 | 94 | When we need to tie the component's view to some external state, a watchable thing 95 | can be given for the value in the property map... or a function that returns a 96 | watchable thing... or a map with `:state-factory` and `:state-cleanup` functions... 97 | 98 | ```clojure 99 | (defonce! !my-external-state (atom nil)) 100 | 101 | (zc/reg-components 102 | :my-component 103 | {:props 104 | {:my-external-state !my-external-state 105 | :my-state-factory (fn [^js/HTMLElement _the-component-dom] 106 | (atom nil)) 107 | :my-state-factory-with-cleanup {:state-factory 108 | (fn [^js/HTMLElement the-component-dom] 109 | (create-watchable-thing the-component-dom)) 110 | :state-cleanup 111 | (fn [the-watchable-thing ^js/HTMLElement the-component-dom] 112 | (cleanup-the-thing the-watchable-thing))}} 113 | :view my-view}) 114 | ``` 115 | 116 | Zero provides a convenience function (`zero.dom/internal-state-prop`) to help setup 117 | a property for internal component state, since this is a fairly common need. 118 | 119 | ```clojure 120 | :props {:state (zero.dom/internal-state-prop {:foo "foo"})} 121 | ;==> or <==; 122 | :props {:state (zero.dom/internal-state-prop (fn [^js/HTMLElement the-component-dom] {:foo "foo"}))} 123 | 124 | ``` 125 | 126 | ### Focus 127 | Properly managing the focus of input components is essential to good UI. The default 128 | is that web components just aren't focusable, which is generally what's wanted for 129 | non-interactive or container components; but isn't ideal for controls. Use the `:focus` 130 | option to adjust this. 131 | 132 | Possible values are `:self` and `:delegate`. The `:delegate` option causes the 133 | component's shadow DOM to be created with 134 | [`delegatesFocus`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/delegatesFocus)... 135 | which comes with a few oddities. 1) This can't be undone, so changing this in a hot reload can 136 | cause some weird behavior 2) If the component has been HTML-pre-rendered then it'll already have 137 | a shadow DOM, and this option won't have any effect. 138 | 139 | Basically the effect of `:delegate` is that any time your component is clicked, its first focusable 140 | child will receive the focus instead of the component itself. 141 | 142 | The `:self` option just makes the component itself focusable, by setting `tabIndex = 0` if it's null. 143 | 144 | ```clojure 145 | (zc/reg-components 146 | {:props #{:foo :bar} 147 | :focus :self 148 | :view my-view}) 149 | ``` 150 | 151 | ### Inheriting Document CSS 152 | It may be useful (though these days I avoid it) to allow your components to borrow the styling 153 | from your top level document, since the shadow DOM mitigates this. Set `:inherit-doc-css?` 154 | to enable this behavior. Note that it fetches the CSS and wraps it in a `CSSStyleSheet`, 155 | which ignores imports. 156 | 157 | ```clojure 158 | (zc/reg-components 159 | {:props #{:foo :bar} 160 | :inherit-doc-css? true 161 | :view my-view}) 162 | ``` 163 | 164 | ### Form Controls 165 | When building HTML form controls, set the `:form-associated?` option. This tells SubZero 166 | to setup the component class as a form input, allowing the form value, status, error message, 167 | etc. to be controlled via the `#internals` prop on your component's `:root>`. 168 | 169 | ```clojure 170 | (zc/reg-components 171 | {:props #{:foo :bar} 172 | :form-associated? true 173 | :view my-view}) 174 | ``` 175 | 176 | ## Rendering To HTML 177 | Use `subzero.plugins.html/html` to render SubZero markup to an HTML string, or 178 | `subzero.plugins.html/write-html` to render to a writer. 179 | 180 | In either case, the function takes a SubZero database as the first arg. Generally 181 | you should pass `zero.config/!default-db` here, as this is where your Zero components 182 | will be registered by default. 183 | 184 | For `write-html`, the second arg is the writer. 185 | 186 | An option map can be given as the next argument, which can have a `:doctype` 187 | to be added at the start of the rendered HTML. 188 | 189 | All other args are interpreted as markup, and rendered as HTML. 190 | 191 | Attributes for registered Zero components will be rendered as [CDF](doc/ConciseDataFormat.md), 192 | so the structure of the data can be restored client-side. This will happen automatically 193 | if the component is registered both when rendering the HTML, and as a web component on 194 | the client. 195 | 196 | The HTML renderer will also try to render event handlers (in `:#on` maps) as `:zero.dom/listen` 197 | components, which will try and register the event listener on the client-side. But this will 198 | only work correctly if the event handlers (map values) are things that can be serialized and 199 | deserialized as something that'll work as an event handler... for example... actions. Zero's 200 | client-side DOM utilities (from `zero.dom`) also need to be installed for this to work. 201 | 202 | Likewise, the HTML renderer will try to render bindings (from `:#bind` maps) as `:zero.dom/bind` 203 | components, which will try and setup the bindings client-side. 204 | 205 | ## The `:root>` 206 | A component's view function may return a special `[:root> ...]` as the top-level node 207 | of its vDOM. This serves as a place to attach component-level customizations. 208 | 209 | For example setting a `#style` prop on this node sets up the _default_ styling for 210 | the component's host element. Setting `:#on` event handlers attaches the event 211 | handlers to the component's shadow root. See the 212 | [SubZero docs](https://github.com/raystubbs/subzero?tab=readme-ov-file#the-root) 213 | for details. 214 | 215 | Component lifecycle events are dispatched on the shadow root, so we can handle 216 | them with event handlers on the `:root>` vNode. 217 | 218 | ```clojure 219 | (defn my-view 220 | [] 221 | [:root> 222 | :#on {:connect (fn [^js/Event ev] ,,,) 223 | :render (fn [^js/Event ev] ,,,) 224 | :update (fn [^js/Event ev] ,,,) 225 | :disconnect (fn [^js/Event ev] ,,,)} 226 | ,,,]) 227 | ``` 228 | - `:connect` - when the component is attached to the document, after the first render 229 | - `:render` - after every render 230 | - `:update` - after all but the first render after connecting 231 | - `:disconnect` - when the component is removed from the document 232 | 233 | Use `:#on-host` instead of `:#on` to handle UI events on the component's host element, 234 | for example `focus`/`blur`, `mouseover`/`mouseout`, etc. Most user events won't be 235 | dispatched on the shadow root, unless they're bubbling up from a child. _Be careful_ 236 | though if using actions to handle these events, the context received will be from the 237 | host's element, which might be unexpected. For example `::z/host` will refer to the 238 | parent component's host element, `::z/root` to the parent component's shadow root, etc. 239 | 240 | Use `:#css` to add stylesheets to the component. This can be given as a string, 241 | a `CSSStyleSheet` instance, or a vector of zero or more of the same. Strings are 242 | treated as stylesheet URLs if they begin with `http` or `/`, otherwise as raw CSS 243 | text. 244 | 245 | ## Slots 246 | Slots are a powerful feature of web components, and are especially helpful 247 | for vDOM based rendering, as they allow for some nice performance improvements. 248 | 249 | Essentially, slots allow child elements to be 'projected' into our components 250 | from a parent. These child elements are independent from our component, so 251 | they can be updated efficiently by the common parent, without forcing our component 252 | to also update... I'm not happy with this explanation. Just 253 | [read the docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot) 254 | instead of my rambling. 255 | 256 | Here's an example: 257 | 258 | ```clojure 259 | (defn my-card-view 260 | [] 261 | [:section 262 | [:h1 [:slot :name "heading"]] 263 | [:p [:slot :name "body"]]]) 264 | 265 | (zc/reg-component 266 | :my-card 267 | {:view my-card-view}) 268 | 269 | ;==> and we can use it like <==; 270 | [:my-card 271 | [:span :slot "heading" "My Heading"] 272 | [:span :slot "body" "The main content"]] 273 | ``` 274 | 275 | A fairly common need when it comes to slots, is to be able to adjust our UI 276 | depending on whether the user has plugged anything into our slots. For this 277 | Zero has `zero.dom/slotted-prop`. 278 | 279 | ```clojure 280 | (defn my-card-view 281 | [{:keys [heading-els body-els]}] 282 | [:section 283 | [:h1 [:slot :name "heading"] 284 | (when (empty? heading-els) "")] 285 | [:p [:slot :name "body"] 286 | (when (empty? body-els) 287 | "")]]) 288 | 289 | (zc/reg-component 290 | :my-card 291 | {:props {:heading-els (zd/slotted-prop :slots #{:heading}) 292 | :body-els (zd/slotted-prop :slots #{:body})} 293 | :view my-card-view}) 294 | ``` 295 | 296 | ## Signals 297 | Signals are a Zero feature that allow some behavior of a component to be triggered from 298 | the outside. They're rarely needed, and should be avoided when possible... but when the 299 | need does come up, you'll be happy they're available. 300 | 301 | Signals, like Zero's state management types, have value semantics. So they can be 302 | serialized/deserialized, compared, etc. And they don't break the data-oriented 303 | nature of component `view` functions. 304 | 305 | Here's how they work. 306 | 307 | ```clojure 308 | (ns example 309 | (:require 310 | [zero.core :refer [sig act] :as z])) 311 | 312 | ;; remember, signals are data, so two instances with the same key 313 | ;; are considered to be the same signal 314 | (def my-sig (sig ::my-unique-key)) 315 | 316 | ;; add a listener, any number of these can be added 317 | (z/sig-listen my-sig ::my-listen-key 318 | (fn [] 319 | (println "my-sig has been invoked"))) 320 | 321 | ;; invoke the trigger 322 | (my-sig) 323 | 324 | ;; remove the listener 325 | (z/sig-unlisten my-sig ::my-listen-key) 326 | ``` 327 | 328 | More conveniently, instead of manually listening/unlistening, just put the signal 329 | in the key position of an element's listener map in the vDOM. This also provides 330 | access to an inferred context (much like when handling events), but without the 331 | event-specific keys. 332 | 333 | ```clojure 334 | (defn- my-view 335 | [{:keys [focus-sig]}] 336 | [:input 337 | :#on {focus-sig (act [::zd/invoke (<` — parsed as a string, any number of sequential ticks can be used 27 | 28 | Otherwise, the full top-level string is ‘parsed’ as itself… a string. Note that 29 | empty strings can't be written as ``, since that would just open a 30 | string that must be closed with two ticks. There's no need for any 'syntax' for 31 | empty strings at the top level... just use an empty string. 32 | 33 | At an inner level (nested in a map, vector, or operation) the syntax is more formal. 34 | Numbers, keywords, maps, and vectors look as you’d expect. Strings are nested in 35 | between back-ticks. Operations look like `(operator ...args)` (these are customizable). 36 | 37 | Some special values exist: 38 | 39 | - `E` — empty string 40 | - `Inf+` — positive infinity 41 | - `Inf-` — negative infinity 42 | - `NaN` — not a number 43 | - `T` — true 44 | - `F` — false 45 | - `_` — nil 46 | 47 | That’s it. That's the format. 48 | 49 | Use `zero.extras.cdf/read-str` to parse a CDF string. An `:operators` option can be 50 | passed to specify custom operators. By default the reader supports operators matching 51 | the `act`, `bnd`, and `<<` functions from `zero.core`, as well as a `set` for sets and 52 | `inst` for dates. 53 | 54 | Use `zero.extras.cdf/write-str` to serialize to a CDF string. If a `:mapper` option 55 | is given, the provided function will be called on each value before it’s serialized; 56 | allowing for custom conversions. Lists with a symbol as the first value will be 57 | serialized as operations. The default mapper handles actions, bindings, injections, 58 | sets, and dates. 59 | 60 | > [!TIP] 61 | > CDF was designed as a good format for HTML attributes. But it's a generic format 62 | > for structured data, and has a small footprint. So if smaller bundle size is more 63 | > important than super fast read/write times, then I'd say go ahead and use CDF for 64 | > your APIs, config, etc. 65 | -------------------------------------------------------------------------------- /doc/StateManagement.md: -------------------------------------------------------------------------------- 1 | # State Management 2 | Zero's state management is built on three concepts: injections, actions, and 3 | bindings. The types representing these have value semantics, so they work well 4 | with the virtual DOM model, and make snapshot testing your UIs a breeze. 5 | 6 | ## Injections 7 | An injection is a placeholder for some yet-unknown value, to be resolved at 8 | a later time. The concept is universal, but you'll mostly use these within 9 | actions to reference things that'll be resolved when the action is dispatched. 10 | 11 | ```clojure 12 | (ns example 13 | (:require 14 | [zero.core :refer [<<] :as z] 15 | [zero.config :as zc])) 16 | 17 | (zc/reg-injections 18 | ::user 19 | (fn [context & _args] 20 | (:user context))) 21 | 22 | (z/inject 23 | {:user "Ray"} 24 | ["Hello, " (<< ::user) "!"]) 25 | ;==> ["Hello, " "Ray" "!"] 26 | ``` 27 | 28 | Zero also defines a few convenience injections (and respective constructor 29 | functions) for common use cases: 30 | - `::z/ctx`/`< nil 182 | 183 | ;; watch the binding like you'd do with any watchable thing 184 | (add-watch (bnd ::my-random-data-stream 10) ::my-watch-key 185 | (fn [_ _ _ new-val] 186 | (js/console.log new-val))) 187 | 188 | ;; then when you're done, shut it unwatch, it'll shut down when the 189 | ;; watch count reaches 0 190 | (remove-watch (bnd ::my-random-data-stream 10) ::my-watch-key) 191 | ``` 192 | 193 | The `zero.util/derived` function can help create a data stream that derives 194 | its values from several other watchable things. 195 | 196 | ```clojure 197 | (ns example 198 | (:require 199 | [zero.config :as zc] 200 | [zero.util :as zu] 201 | [zero.core :refer [bnd] :as z])) 202 | 203 | (defonce !src-1 (atom 0)) 204 | 205 | (zc/reg-streams 206 | :my-derived-stream 207 | (zu/derived 208 | (fn [[src-1 src-2] & _args] 209 | (+ src-1 src-2)) 210 | !src-1 211 | (bnd :src-2))) 212 | ``` 213 | 214 | Bindings can be setup as state props on a component: 215 | 216 | ```clojure 217 | (zc/reg-components 218 | :my-random-number 219 | {:props {:rand (bnd ::my-random-data-stream 10)} 220 | :view (fn [{:keys [rand]}] 221 | rand)}) 222 | ``` 223 | 224 | Or bound to regular props within 225 | [SubZero markup](https://github.com/raystubbs/subzero?tab=readme-ov-file#markup). 226 | 227 | ```clojure 228 | (zc/reg-components 229 | :my-random-number 230 | {:view 231 | (fn [{:keys [rand]}] 232 | [:input 233 | :#bind {:value (bnd ::my-random-data-stream 10)}])}) 234 | ``` 235 | 236 | Bindings can also take a prop map. The full usage for `zero.core/bnd` is: 237 | 238 | ```clojure 239 | (bnd {:as props}? data-stream-key & args) 240 | ``` 241 | 242 | Available props are: 243 | 244 | - `:default` - The default value. Dereferencing the binding will yield this 245 | value while its data stream isn't active, or hasn't yet produced a value. 246 | - `:default-nil?` - If `true` then the default value will be substituted for 247 | any `nil` values produced by the data stream. 248 | 249 | ## Conclusion 250 | Zero's state management facilities are designed to make your component's 251 | views more data oriented, and to improve DOM reconciliation performance. 252 | The benefits generally make these things worth using. However, like anything, 253 | they have limitations. So just use a function for your event handler if 254 | an action won't work. Use an atom (or some other watchable thing) for reactivity 255 | if a binding isn't appropriate. These are _extra_ tools, they don't replace 256 | what's otherwise available. 257 | 258 | -------------------------------------------------------------------------------- /examples/todo/.gitignore: -------------------------------------------------------------------------------- 1 | pub/js 2 | pub/node_modules 3 | -------------------------------------------------------------------------------- /examples/todo/README.md: -------------------------------------------------------------------------------- 1 | # [TodoMVC](https://todomvc.com/) in Zero 2 | Run with: 3 | ```shell 4 | npx shadow-cljs watch app 5 | ``` 6 | Open at . 7 | 8 | ## Tests 9 | Build and run tests with: 10 | ```shell 11 | npx shadow-cljs compile test && node target/node-tests.js 12 | ``` 13 | -------------------------------------------------------------------------------- /examples/todo/cljfmt.edn: -------------------------------------------------------------------------------- 1 | {:remove-surrounding-whitespace? true 2 | :remove-trailing-whitespace? true 3 | :remove-consecutive-blank-lines? false 4 | :insert-missing-whitespace? false 5 | :indents {#re ".+" [[:inner 0]]}} -------------------------------------------------------------------------------- /examples/todo/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {binaryage/devtools {:mvn/version "1.0.5"} 3 | me.raystubbs/zero {:local/root "../.."} 4 | thheller/shadow-cljs {:mvn/version "2.21.0"}} 5 | 6 | :aliases 7 | {:test 8 | {:extra-paths ["test"]}}} 9 | -------------------------------------------------------------------------------- /examples/todo/pub/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Zero • TodoMVC 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/todo/pub/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "dependencies": { 5 | "todomvc-app-css": "^2.0.0", 6 | "todomvc-common": "^1.0.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/todo/pub/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | todomvc-app-css@^2.0.0: 6 | version "2.4.3" 7 | resolved "https://registry.yarnpkg.com/todomvc-app-css/-/todomvc-app-css-2.4.3.tgz#f2ed6e02dd9bb92527f0c8d84c5142e8e0d131d1" 8 | integrity sha512-mSnWZaKBWj9aQcFRsGguY/a8O8NR8GmecD48yU1rzwNemgZa/INLpIsxxMiToFGVth+uEKBrQ7IhWkaXZxwq5Q== 9 | 10 | todomvc-common@^1.0.0: 11 | version "1.0.5" 12 | resolved "https://registry.yarnpkg.com/todomvc-common/-/todomvc-common-1.0.5.tgz#8c3e799ac9f1fc1573e0c204f984510826914730" 13 | integrity sha512-D8kEJmxVMQIWwztEdH+WeiAfXRbbSCpgXq4NkYi+gduJ2tr8CNq7sYLfJvjpQ10KD9QxJwig57rvMbV2QAESwQ== 14 | -------------------------------------------------------------------------------- /examples/todo/shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:deps {:aliases [:test]} 2 | :dev-http {8000 "pub"} 3 | :builds {:app 4 | {:target :browser 5 | :output-dir "pub/js" 6 | :asset-path "/js" 7 | :compiler-options {:infer-externs true 8 | :warnings {:redef false}} 9 | :build-hooks [(shadow.cljs.build-report/hook)] 10 | :devtools {:watch-dir "pub/css" :watch-path "/css"} 11 | :modules {:app {:init-fn zero.demos.todo.app/init}}} 12 | 13 | :test 14 | {:target :node-test 15 | :ns-regexp ".+[-]test$" 16 | :output-to "target/node-tests.js"}}} 17 | -------------------------------------------------------------------------------- /examples/todo/src/zero/demos/todo/app.cljs: -------------------------------------------------------------------------------- 1 | (ns zero.demos.todo.app 2 | (:require 3 | [zero.config :as zc] 4 | [zero.wcconfig :as zwc] 5 | [zero.dom :as zd] 6 | [zero.core :refer [< 13 | :#css ["/node_modules/todomvc-common/base.css" 14 | "/node_modules/todomvc-app-css/index.css"] 15 | :#style {:display "block"} 16 | 17 | [:section.todoapp 18 | [:header.header 19 | [:h1 "todos"] 20 | [:input.new-todo 21 | :placeholder "What needs to be done?" 22 | :autofocus true 23 | :value (:input model) 24 | :#on {:input 25 | (act 26 | [::zd/dispatch :patch 27 | :data {:path [:input] 28 | :change [:value (< [] editing? (conj "editing") completed? (conj "completed")) 61 | (if editing? 62 | [:input.edit 63 | :value text 64 | :#on {:change 65 | (act 66 | [::zd/dispatch :patch 67 | :data {:path [:items idx] 68 | :change [:assoc :editing? false :text (< 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | nil 39 | nil] 40 | [:footer.info 41 | [:p "Double-click to edit a todo"] 42 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 43 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 44 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/accepted/snapshots_test/todo_view_w_completed_item.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | [:section.main 39 | [:input#toggle-all.toggle-all 40 | :type 41 | "checkbox" 42 | :checked 43 | true 44 | :#on 45 | {:change 46 | {:props {}, 47 | :effects 48 | [[:zero.dom/dispatch 49 | :patch 50 | :data 51 | ({:path [:items 0 :completed?], :change [:value false]})]]}}] 52 | [:label {:for "toggle-all"} "Mark all as complete"] 53 | [:ul.todo-list 54 | ([:li 55 | :#class 56 | ["completed"] 57 | [:div.view 58 | :#on 59 | {:dblclick 60 | {:props {}, 61 | :effects 62 | [[:zero.dom/dispatch 63 | :patch 64 | :data 65 | {:path [:items 0 :editing?], :change [:value true]}] 66 | [:zero.dom/invoke 67 | {:key :zero.core/ctx, 68 | :args 69 | [:zero.dom/select-one :input.edit :delay :after-render]} 70 | "select"]]}} 71 | [:input.toggle 72 | :type 73 | "checkbox" 74 | :checked 75 | true 76 | :#on 77 | {:change 78 | {:props {}, 79 | :effects 80 | [[:zero.dom/dispatch 81 | :patch 82 | :data 83 | {:path [:items 0 :completed?], :change [:value false]}]]}}] 84 | [:label "Foo"] 85 | [:button.destroy 86 | :#on 87 | {:click 88 | {:props {}, 89 | :effects 90 | [[:zero.dom/dispatch 91 | :patch 92 | :data 93 | {:path [:items], :change [:clear 0]}]]}}]]])]] 94 | [:footer.footer 95 | [:span.todo-count [:strong 1] " item " "left"] 96 | [:button.clear-completed 97 | :#on 98 | {:click 99 | {:props {}, 100 | :effects 101 | [[:zero.dom/dispatch 102 | :patch 103 | :data 104 | {:path [:items], :change [:value []]}]]}} 105 | "Clear completed"]]] 106 | [:footer.info 107 | [:p "Double-click to edit a todo"] 108 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 109 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 110 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/accepted/snapshots_test/todo_view_w_editing_item.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | [:section.main 39 | [:input#toggle-all.toggle-all 40 | :type 41 | "checkbox" 42 | :checked 43 | false 44 | :#on 45 | {:change 46 | {:props {}, 47 | :effects 48 | [[:zero.dom/dispatch 49 | :patch 50 | :data 51 | ({:path [:items 0 :completed?], :change [:value true]})]]}}] 52 | [:label {:for "toggle-all"} "Mark all as complete"] 53 | [:ul.todo-list 54 | ([:li 55 | :#class 56 | ["editing"] 57 | [:input.edit 58 | :value 59 | "Foo" 60 | :#on 61 | {:change 62 | {:props {}, 63 | :effects 64 | [[:zero.dom/dispatch 65 | :patch 66 | :data 67 | {:path [:items 0], 68 | :change 69 | [:assoc 70 | :editing? 71 | false 72 | :text 73 | {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}}]])]] 74 | [:footer.footer [:span.todo-count [:strong 1] " item " "left"] nil]] 75 | [:footer.info 76 | [:p "Double-click to edit a todo"] 77 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 78 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 79 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/accepted/snapshots_test/todo_view_w_input.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "Foo" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text "Foo"}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | nil 39 | nil] 40 | [:footer.info 41 | [:p "Double-click to edit a todo"] 42 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 43 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 44 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/accepted/snapshots_test/todo_view_w_items.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | [:section.main 39 | [:input#toggle-all.toggle-all 40 | :type 41 | "checkbox" 42 | :checked 43 | false 44 | :#on 45 | {:change 46 | {:props {}, 47 | :effects 48 | [[:zero.dom/dispatch 49 | :patch 50 | :data 51 | ({:path [:items 0 :completed?], :change [:value true]} 52 | {:path [:items 1 :completed?], :change [:value true]})]]}}] 53 | [:label {:for "toggle-all"} "Mark all as complete"] 54 | [:ul.todo-list 55 | ([:li 56 | :#class 57 | [] 58 | [:div.view 59 | :#on 60 | {:dblclick 61 | {:props {}, 62 | :effects 63 | [[:zero.dom/dispatch 64 | :patch 65 | :data 66 | {:path [:items 0 :editing?], :change [:value true]}] 67 | [:zero.dom/invoke 68 | {:key :zero.core/ctx, 69 | :args 70 | [:zero.dom/select-one :input.edit :delay :after-render]} 71 | "select"]]}} 72 | [:input.toggle 73 | :type 74 | "checkbox" 75 | :checked 76 | nil 77 | :#on 78 | {:change 79 | {:props {}, 80 | :effects 81 | [[:zero.dom/dispatch 82 | :patch 83 | :data 84 | {:path [:items 0 :completed?], :change [:value true]}]]}}] 85 | [:label "Foo"] 86 | [:button.destroy 87 | :#on 88 | {:click 89 | {:props {}, 90 | :effects 91 | [[:zero.dom/dispatch 92 | :patch 93 | :data 94 | {:path [:items], :change [:clear 0]}]]}}]]] 95 | [:li 96 | :#class 97 | [] 98 | [:div.view 99 | :#on 100 | {:dblclick 101 | {:props {}, 102 | :effects 103 | [[:zero.dom/dispatch 104 | :patch 105 | :data 106 | {:path [:items 1 :editing?], :change [:value true]}] 107 | [:zero.dom/invoke 108 | {:key :zero.core/ctx, 109 | :args 110 | [:zero.dom/select-one :input.edit :delay :after-render]} 111 | "select"]]}} 112 | [:input.toggle 113 | :type 114 | "checkbox" 115 | :checked 116 | nil 117 | :#on 118 | {:change 119 | {:props {}, 120 | :effects 121 | [[:zero.dom/dispatch 122 | :patch 123 | :data 124 | {:path [:items 1 :completed?], :change [:value true]}]]}}] 125 | [:label "Bar"] 126 | [:button.destroy 127 | :#on 128 | {:click 129 | {:props {}, 130 | :effects 131 | [[:zero.dom/dispatch 132 | :patch 133 | :data 134 | {:path [:items], :change [:clear 1]}]]}}]]])]] 135 | [:footer.footer [:span.todo-count [:strong 2] " items " "left"] nil]] 136 | [:footer.info 137 | [:p "Double-click to edit a todo"] 138 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 139 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 140 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/current/snapshots_test/todo_view_initial.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | nil 39 | nil] 40 | [:footer.info 41 | [:p "Double-click to edit a todo"] 42 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 43 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 44 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/current/snapshots_test/todo_view_w_completed_item.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | [:section.main 39 | [:input#toggle-all.toggle-all 40 | :type 41 | "checkbox" 42 | :checked 43 | true 44 | :#on 45 | {:change 46 | {:props {}, 47 | :effects 48 | [[:zero.dom/dispatch 49 | :patch 50 | :data 51 | ({:path [:items 0 :completed?], :change [:value false]})]]}}] 52 | [:label {:for "toggle-all"} "Mark all as complete"] 53 | [:ul.todo-list 54 | ([:li 55 | :#class 56 | ["completed"] 57 | [:div.view 58 | :#on 59 | {:dblclick 60 | {:props {}, 61 | :effects 62 | [[:zero.dom/dispatch 63 | :patch 64 | :data 65 | {:path [:items 0 :editing?], :change [:value true]}] 66 | [:zero.dom/invoke 67 | {:key :zero.core/ctx, 68 | :args 69 | [:zero.dom/select-one :input.edit :delay :after-render]} 70 | "select"]]}} 71 | [:input.toggle 72 | :type 73 | "checkbox" 74 | :checked 75 | true 76 | :#on 77 | {:change 78 | {:props {}, 79 | :effects 80 | [[:zero.dom/dispatch 81 | :patch 82 | :data 83 | {:path [:items 0 :completed?], :change [:value false]}]]}}] 84 | [:label "Foo"] 85 | [:button.destroy 86 | :#on 87 | {:click 88 | {:props {}, 89 | :effects 90 | [[:zero.dom/dispatch 91 | :patch 92 | :data 93 | {:path [:items], :change [:clear 0]}]]}}]]])]] 94 | [:footer.footer 95 | [:span.todo-count [:strong 1] " item " "left"] 96 | [:button.clear-completed 97 | :#on 98 | {:click 99 | {:props {}, 100 | :effects 101 | [[:zero.dom/dispatch 102 | :patch 103 | :data 104 | {:path [:items], :change [:value []]}]]}} 105 | "Clear completed"]]] 106 | [:footer.info 107 | [:p "Double-click to edit a todo"] 108 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 109 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 110 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/current/snapshots_test/todo_view_w_editing_item.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | [:section.main 39 | [:input#toggle-all.toggle-all 40 | :type 41 | "checkbox" 42 | :checked 43 | false 44 | :#on 45 | {:change 46 | {:props {}, 47 | :effects 48 | [[:zero.dom/dispatch 49 | :patch 50 | :data 51 | ({:path [:items 0 :completed?], :change [:value true]})]]}}] 52 | [:label {:for "toggle-all"} "Mark all as complete"] 53 | [:ul.todo-list 54 | ([:li 55 | :#class 56 | ["editing"] 57 | [:input.edit 58 | :value 59 | "Foo" 60 | :#on 61 | {:change 62 | {:props {}, 63 | :effects 64 | [[:zero.dom/dispatch 65 | :patch 66 | :data 67 | {:path [:items 0], 68 | :change 69 | [:assoc 70 | :editing? 71 | false 72 | :text 73 | {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}}]])]] 74 | [:footer.footer [:span.todo-count [:strong 1] " item " "left"] nil]] 75 | [:footer.info 76 | [:p "Double-click to edit a todo"] 77 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 78 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 79 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/current/snapshots_test/todo_view_w_input.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "Foo" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text "Foo"}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | nil 39 | nil] 40 | [:footer.info 41 | [:p "Double-click to edit a todo"] 42 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 43 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 44 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots/current/snapshots_test/todo_view_w_items.edn: -------------------------------------------------------------------------------- 1 | [:root> 2 | :#css 3 | ["/node_modules/todomvc-common/base.css" 4 | "/node_modules/todomvc-app-css/index.css"] 5 | :#style 6 | {:display "block"} 7 | [:section.todoapp 8 | [:header.header 9 | [:h1 "todos"] 10 | [:input.new-todo 11 | :placeholder 12 | "What needs to be done?" 13 | :autofocus 14 | true 15 | :value 16 | "" 17 | :#on 18 | {:input 19 | {:props {}, 20 | :effects 21 | [[:zero.dom/dispatch 22 | :patch 23 | :data 24 | {:path [:input], 25 | :change 26 | [:value {:key :zero.core/ctx, :args [:zero.core/data]}]}]]}, 27 | :keydown 28 | {:props {}, 29 | :effects 30 | [[:zero.core/choose 31 | {"Enter" 32 | [[:zero.dom/dispatch 33 | :patch 34 | :data 35 | [{:path [:items], :change [:conj {:text ""}]} 36 | {:path [:input], :change [:value ""]}]]]} 37 | {:key :zero.core/ctx, :args [:zero.core/data :key]}]]}}]] 38 | [:section.main 39 | [:input#toggle-all.toggle-all 40 | :type 41 | "checkbox" 42 | :checked 43 | false 44 | :#on 45 | {:change 46 | {:props {}, 47 | :effects 48 | [[:zero.dom/dispatch 49 | :patch 50 | :data 51 | ({:path [:items 0 :completed?], :change [:value true]} 52 | {:path [:items 1 :completed?], :change [:value true]})]]}}] 53 | [:label {:for "toggle-all"} "Mark all as complete"] 54 | [:ul.todo-list 55 | ([:li 56 | :#class 57 | [] 58 | [:div.view 59 | :#on 60 | {:dblclick 61 | {:props {}, 62 | :effects 63 | [[:zero.dom/dispatch 64 | :patch 65 | :data 66 | {:path [:items 0 :editing?], :change [:value true]}] 67 | [:zero.dom/invoke 68 | {:key :zero.core/ctx, 69 | :args 70 | [:zero.dom/select-one :input.edit :delay :after-render]} 71 | "select"]]}} 72 | [:input.toggle 73 | :type 74 | "checkbox" 75 | :checked 76 | nil 77 | :#on 78 | {:change 79 | {:props {}, 80 | :effects 81 | [[:zero.dom/dispatch 82 | :patch 83 | :data 84 | {:path [:items 0 :completed?], :change [:value true]}]]}}] 85 | [:label "Foo"] 86 | [:button.destroy 87 | :#on 88 | {:click 89 | {:props {}, 90 | :effects 91 | [[:zero.dom/dispatch 92 | :patch 93 | :data 94 | {:path [:items], :change [:clear 0]}]]}}]]] 95 | [:li 96 | :#class 97 | [] 98 | [:div.view 99 | :#on 100 | {:dblclick 101 | {:props {}, 102 | :effects 103 | [[:zero.dom/dispatch 104 | :patch 105 | :data 106 | {:path [:items 1 :editing?], :change [:value true]}] 107 | [:zero.dom/invoke 108 | {:key :zero.core/ctx, 109 | :args 110 | [:zero.dom/select-one :input.edit :delay :after-render]} 111 | "select"]]}} 112 | [:input.toggle 113 | :type 114 | "checkbox" 115 | :checked 116 | nil 117 | :#on 118 | {:change 119 | {:props {}, 120 | :effects 121 | [[:zero.dom/dispatch 122 | :patch 123 | :data 124 | {:path [:items 1 :completed?], :change [:value true]}]]}}] 125 | [:label "Bar"] 126 | [:button.destroy 127 | :#on 128 | {:click 129 | {:props {}, 130 | :effects 131 | [[:zero.dom/dispatch 132 | :patch 133 | :data 134 | {:path [:items], :change [:clear 1]}]]}}]]])]] 135 | [:footer.footer [:span.todo-count [:strong 2] " items " "left"] nil]] 136 | [:footer.info 137 | [:p "Double-click to edit a todo"] 138 | [:p "Created by " [:a {:href "http://github.com/raystubbs"} "Ray"]] 139 | [:p "Part of " [:a {:href "http://todomvc.com"} "TodoMVC"]]]] 140 | -------------------------------------------------------------------------------- /examples/todo/test/snapshots_test.cljc: -------------------------------------------------------------------------------- 1 | (ns snapshots-test 2 | (:require 3 | [cljs.test :refer [deftest]] 4 | [zero.demos.todo.todo] 5 | [zero.tools.test :refer [snap]])) 6 | 7 | (deftest todo-view-snapshots 8 | (let [todo-view @(resolve 'zero.demos.todo.todo/view)] 9 | (snap ::todo-view-initial 10 | (todo-view {:model {:items [] :input ""}})) 11 | (snap ::todo-view-w-input 12 | (todo-view {:model {:items [] :input "Foo"}})) 13 | (snap ::todo-view-w-completed-item 14 | (todo-view {:model {:items [{:text "Foo" :completed? true}] :input ""}})) 15 | (snap ::todo-view-w-editing-item 16 | (todo-view {:model {:items [{:text "Foo" :editing? true}] :input ""}})) 17 | (snap ::todo-view-w-items 18 | (todo-view {:model {:items [{:text "Foo"} {:text "Bar"}] :input ""}})))) 19 | -------------------------------------------------------------------------------- /meta.edn: -------------------------------------------------------------------------------- 1 | {:name me.raystubbs/zero, :version [0 1 22]} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "shadow-cljs compile browser-tests node-tests && web-test-runner \"target/browser-tests/all.js\" --node-resolve && node target/node-tests.js" 4 | }, 5 | "devDependencies": { 6 | "@web/test-runner": "^0.18.1", 7 | "shadow-cljs": "^2.28.4" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:deps {:aliases [:test-cljs]} 2 | 3 | :builds 4 | {:browser-tests 5 | {:target :esm 6 | :output-dir "target/browser-tests" 7 | :ns-regexp ".+[-]btest$" 8 | :modules {:all {:entries [zero.placeholder-btest]}}} 9 | 10 | :node-tests 11 | {:target :node-test 12 | :ns-regexp ".+[-]test$" 13 | :output-to "target/node-tests.js"}}} 14 | -------------------------------------------------------------------------------- /src/zero/cdf.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.cdf 2 | (:require 3 | [clojure.string :as str] 4 | [zero.core :as z] 5 | [zero.impl.base :refer [str-writer str-writer->str write]]) 6 | #?(:clj 7 | (:import 8 | [java.util ArrayList Date]))) 9 | 10 | #?(:clj (defn- mut-list [] (ArrayList.)) 11 | :cljs (defn- mut-list [] #js[])) 12 | 13 | #?(:clj (defn- append! [^ArrayList l v] (.add l v)) 14 | :cljs (defn- append! [l v] (.push l v))) 15 | 16 | #?(:clj (defn- char-code [c] (long c)) 17 | :cljs (defn- char-code [c] (.charCodeAt c 0))) 18 | 19 | (def ^:private CTRL-CHAR-RANGE-START 0x00) 20 | (def ^:private CTRL-CHAR-RANGE-END 0x0f) 21 | 22 | (defn- graphical-char? 23 | [c] 24 | ;; TODO: improve 25 | (let [x (char-code c)] 26 | (and 27 | (not (<= CTRL-CHAR-RANGE-START x CTRL-CHAR-RANGE-END)) 28 | (not= 0x7F x)))) 29 | 30 | (defn- parse-number 31 | [s start-index _opts] 32 | (let [w (str-writer)] 33 | (loop [i start-index 34 | stage :integer] 35 | (if (= i (count s)) 36 | (case (nth s (dec i)) 37 | (\0 \1 \2 \3 \4 \5 \6 \7 \8 \9) 38 | [(parse-double (str-writer->str w)) i] 39 | 40 | (throw (ex-info "incomplete number" {:idx i}))) 41 | (let [c (nth s i)] 42 | (case c 43 | (\+ \-) 44 | (if (or (= i start-index) (= (nth s (dec i)) "e")) 45 | (do 46 | (write w c) 47 | (recur (inc i) stage)) 48 | (throw (ex-info "unexpected character" {:idx i :char c}))) 49 | 50 | (\0 \1 \2 \3 \4 \5 \6 \7 \8 \9) 51 | (do 52 | (write w c) 53 | (recur (inc i) stage)) 54 | 55 | \. 56 | (case stage 57 | :integer 58 | (case (nth s (dec i)) 59 | (\+ \-) 60 | (throw (ex-info "expected digit" {:idx i :char (nth s (dec i))})) 61 | 62 | (do 63 | (write w c) 64 | (recur (inc i) stage))) 65 | 66 | :decimal 67 | (throw (ex-info "multiple decimal points in number" {:idx i :char c})) 68 | 69 | :exponent 70 | (throw (ex-info "found decimal point in exponent, exponent must be integer" {:idx i :char c}))) 71 | 72 | \e 73 | (case 74 | :exponent 75 | (throw (ex-info "found extra 'e' in number" {:idx i :char c}))) 76 | 77 | (\space \newline \return \tab \[ \] \{ \} \( \) \`) 78 | (case (nth s (dec i)) 79 | (\0 \1 \2 \3 \4 \5 \6 \7 \8 \9) 80 | [(parse-double (str-writer->str w)) i] 81 | 82 | (throw (ex-info "incomplete number" {:idx i}))) 83 | 84 | (throw (ex-info "unexpected character" {:idx i :char c})))))))) 85 | 86 | (defn- parse-keyword 87 | [s start-index _opts] 88 | (let [w (str-writer)] 89 | (loop [i (inc start-index)] 90 | (if (= i (count s)) 91 | (if (= (dec i) start-index) 92 | (throw (ex-info "incomplete keyword" {:idx i})) 93 | [(keyword (str-writer->str w)) i]) 94 | (let [c (nth s i)] 95 | (case c 96 | (\space \newline \return \tab \[ \] \{ \} \( \) \`) 97 | (if (= (dec i) start-index) 98 | (throw (ex-info "incomplete keyword" {:idx i})) 99 | [(keyword (str-writer->str w)) i]) 100 | 101 | (do 102 | (write w c) 103 | (recur (inc i))))))))) 104 | 105 | (defn- count-quotes 106 | [s start-index] 107 | (loop [i start-index] 108 | (if (or (= i (count s)) (not= (nth s i) \`)) 109 | (- i start-index) 110 | (recur (inc i))))) 111 | 112 | (defn- parse-string 113 | [s start-index _opts] 114 | (let [opening-qcount (count-quotes s start-index) 115 | content-start-index (+ start-index opening-qcount) 116 | 117 | w (str-writer)] 118 | (loop [i content-start-index] 119 | (if (= i (count s)) 120 | (throw (ex-info "unterminated string" {:idx i})) 121 | (let [c (nth s i)] 122 | (if (and (= c \`) (<= opening-qcount (count-quotes s i))) 123 | [(str-writer->str w) (+ i opening-qcount)] 124 | (do 125 | (write w c) 126 | (recur (inc i))))))))) 127 | 128 | (defn- parse-ident 129 | [s start-index _opts] 130 | (let [w (str-writer)] 131 | (loop [i start-index] 132 | (if (= i (count s)) 133 | [(str-writer->str w) i] 134 | (let [c (nth s i)] 135 | (case c 136 | (\space \newline \return \tab \[ \] \{ \} \( \) \`) 137 | [(str-writer->str w) i] 138 | 139 | (if (graphical-char? c) 140 | (do 141 | (write w c) 142 | (recur (inc i))) 143 | (throw (ex-info "unexpected character" {:idx i}))))))))) 144 | 145 | (declare 146 | ^:private parse-op 147 | ^:private parse-seq 148 | ^:private parse-map 149 | ^:private write-val) 150 | 151 | (defn- parse-body 152 | [s start-index terminator-char opts] 153 | (let [items (mut-list)] 154 | (loop [i start-index] 155 | (if (= i (count s)) 156 | (throw (ex-info "unterminated form" {:idx i})) 157 | (let [c (nth s i)] 158 | (if (= c terminator-char) 159 | [(vec items) (inc i)] 160 | (case c 161 | (\space \newline \return \tab) 162 | (recur (inc i)) 163 | 164 | (let [[v end-index] 165 | (case c 166 | (\+ \- \0 \1 \2 \3 \4 \5 \6 \7 \8 \9) 167 | (parse-number s i opts) 168 | 169 | \: 170 | (parse-keyword s i opts) 171 | 172 | \` 173 | (parse-string s i opts) 174 | 175 | \( 176 | (parse-op s i opts) 177 | 178 | \[ 179 | (parse-seq s i opts) 180 | 181 | \{ 182 | (parse-map s i opts) 183 | 184 | (if (graphical-char? c) 185 | (let [[ident end-index] (parse-ident s i opts)] 186 | (case ident 187 | "E" ["" end-index] 188 | "T" [true end-index] 189 | "F" [false end-index] 190 | "_" [nil end-index] 191 | "NaN" [##NaN end-index] 192 | "Inf+" [##Inf end-index] 193 | "Inf-" [##-Inf end-index] 194 | (throw (ex-info "unknown identifier" {:idx i :ident (symbol ident)})))) 195 | (throw (ex-info "unexpected character" {:idx i}))))] 196 | (append! items v) 197 | (recur end-index))))))))) 198 | 199 | (defn- parse-op 200 | [s start-index opts] 201 | (let [[op-str body-start-index] (parse-ident s (inc start-index) opts) 202 | _ (when (= "" op-str) (throw (ex-info "missing operator" {:idx start-index}))) 203 | op-sym (symbol op-str) 204 | op-fn (if (= op-sym '$) identity (get-in opts [:operators op-sym])) 205 | _ (when (nil? op-fn) (throw (ex-info "unknown operator" {:idx start-index :op op-sym}))) 206 | [body end-index] (parse-body s body-start-index \) opts)] 207 | [(apply op-fn body) end-index])) 208 | 209 | (defn- parse-seq 210 | [s start-index opts] 211 | (let [[body end-index] (parse-body s (inc start-index) \] opts)] 212 | [body end-index])) 213 | 214 | (defn- parse-map 215 | [s start-index opts] 216 | (let [[body end-index] (parse-body s (inc start-index) \} opts)] 217 | (when-not (even? (count body)) 218 | (throw (ex-info "map has odd number of items" {:idx start-index}))) 219 | [(apply array-map body) end-index])) 220 | 221 | (def default-operators 222 | {'act z/act 223 | 'bnd z/bnd 224 | '<< z/<< 225 | 'set (fn [& xs] (set xs)) 226 | 'err (fn [& args] (ex-info (first args) (second args))) 227 | 'inst #?(:cljs #(js/Date. %) :clj #(Date. ^String %))}) 228 | 229 | (defn read-str 230 | [s & {:as opts}] 231 | (let [opts (merge {:operators default-operators} opts)] 232 | (case s 233 | "" "" 234 | "_" nil 235 | "true" true 236 | "false" false 237 | (let [[v end-index] 238 | (case (nth s 0) 239 | (\+ \- \0 \1 \2 \3 \4 \5 \6 \7 \8 \9) 240 | (parse-number s 0 opts) 241 | 242 | \: 243 | (parse-keyword s 0 opts) 244 | 245 | \` 246 | (parse-string s 0 opts) 247 | 248 | \( 249 | (parse-op s 0 opts) 250 | 251 | \[ 252 | (parse-seq s 0 opts) 253 | 254 | \{ 255 | (parse-map s 0 opts) 256 | 257 | [s (count s)])] 258 | (if (not= end-index (count s)) 259 | (throw (ex-info "extra characters at end of string" {:idx end-index})) 260 | v))))) 261 | 262 | (defn default-mapper 263 | [x] 264 | (cond 265 | (set? x) 266 | (cons 'set (seq x)) 267 | 268 | (z/act? x) 269 | (let [{:keys [props effects]} (z/act->map x)] 270 | (concat ['act] (when (seq props) [props]) effects)) 271 | 272 | (z/bnd? x) 273 | (let [{:keys [key props args]} (z/bnd->map x)] 274 | (concat ['bnd] (when (seq props) [props]) [key] args)) 275 | 276 | (z/inj? x) 277 | (let [{:keys [key args]} (z/inj->map x)] 278 | (concat ['<< key] args)) 279 | 280 | (inst? x) 281 | (list 'inst #?(:cljs (.toISOString x) :clj (.toString x))) 282 | 283 | :else 284 | (if-some [msg (ex-message x)] 285 | (if-some [data (ex-data x)] 286 | (list 'err msg data) 287 | (list 'err msg)) 288 | x))) 289 | 290 | (defn- max-quotes-count 291 | [s] 292 | (loop [i 0 293 | cur-max 0] 294 | (if-let [qi (str/index-of s \` i)] 295 | (let [qcount (count-quotes s qi)] 296 | (recur (+ qi qcount) (max cur-max qcount))) 297 | cur-max))) 298 | 299 | (defn- write-map 300 | [w x opts] 301 | (write w \{) 302 | (when-let [[first-k first-v] (first x)] 303 | (write-val w first-k opts) 304 | (write w \space) 305 | (write-val w first-v opts) 306 | (doseq [[k v] (rest x)] 307 | (write w \space) 308 | (write-val w k opts) 309 | (write w \space) 310 | (write-val w v opts))) 311 | (write w \})) 312 | 313 | (defn- write-seq 314 | [w x opts] 315 | (write w \[) 316 | (when (seq x) 317 | (let [first-v (first x)] 318 | (write-val w first-v opts)) 319 | (doseq [v (rest x)] 320 | (write w \space) 321 | (write-val w v opts))) 322 | (write w \])) 323 | 324 | (defn- write-op 325 | [w x opts] 326 | (write w \() 327 | (write w (pr-str (first x))) 328 | (doseq [v (rest x)] 329 | (write w \space) 330 | (write-val w v opts)) 331 | (write w \))) 332 | 333 | (defn- write-val 334 | [w x {:keys [top?] :as opts}] 335 | (let [x ((:mapper opts) x) 336 | nested-opts (assoc opts :top? false)] 337 | (cond 338 | (true? x) 339 | (write w (if top? "true" "T")) 340 | 341 | (false? x) 342 | (write w (if top? "false" "F")) 343 | 344 | (nil? x) 345 | (write w "_") 346 | 347 | (number? x) 348 | (cond 349 | (= ##Inf x) 350 | (if top? 351 | (write-op w '($ ##Inf) nested-opts) 352 | (write w "Inf+")) 353 | 354 | (= ##-Inf x) 355 | (if top? 356 | (write-op w '($ ##-Inf) nested-opts) 357 | (write w "Inf-")) 358 | 359 | (NaN? x) 360 | (if top? 361 | (write-op w '($ ##NaN) nested-opts) 362 | (write w "NaN")) 363 | 364 | :else 365 | (write w (pr-str x))) 366 | 367 | (keyword? x) 368 | (write w (pr-str x)) 369 | 370 | (string? x) 371 | (cond 372 | (= "" x) 373 | (when-not top? 374 | (write w \E)) 375 | 376 | (or (= (nth x 0) \`) (= (nth x (dec (count x))) \`)) 377 | (throw (ex-info "back ticks (`) aren't allowed at the start or end of strings" {:x x})) 378 | 379 | (and top? (case (nth x 0) (\( \[ \{ \: \0 \1 \2 \3 \4 \5 \6 \7 \8 \9) false true)) 380 | (write w x) 381 | 382 | :else 383 | (let [contains-qcount (max-quotes-count x) 384 | surround-qcount (inc contains-qcount)] 385 | (dotimes [_ surround-qcount] 386 | (write w \`)) 387 | (write w x) 388 | (dotimes [_ surround-qcount] 389 | (write w \`)))) 390 | 391 | (map? x) 392 | (write-map w x nested-opts) 393 | 394 | (and (seq? x) (symbol? (first x))) 395 | (write-op w x nested-opts) 396 | 397 | (sequential? x) 398 | (write-seq w x nested-opts) 399 | 400 | :else 401 | (throw (ex-info "don't know how to write value" {:value x}))) 402 | (str-writer->str w))) 403 | 404 | (defn write-str 405 | [x & {:as opts}] 406 | (let [w (str-writer)] 407 | (write-val w x (merge {:mapper default-mapper :top? true} opts)))) 408 | -------------------------------------------------------------------------------- /src/zero/config.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.config 2 | (:require 3 | [subzero.rstore :as rstore] 4 | [zero.core :as z] 5 | [zero.impl.default-db :as default-db] 6 | [zero.impl.bindings :as bnd] 7 | [zero.impl.actions :as act] 8 | [zero.impl.injection :as-alias inj] 9 | [subzero.plugins.component-registry :as component-registry] 10 | [subzero.plugins.html :as html] 11 | [zero.cdf :as cdf])) 12 | 13 | (def !default-db default-db/!default-db) 14 | 15 | (defn- resolve-db-keyvals-args 16 | [args] 17 | (if (rstore/rstore? (first args)) 18 | [(first args) (apply array-map (rest args))] 19 | [!default-db (apply array-map args)])) 20 | 21 | (defn reg-effects " 22 | Register one or more effects. 23 | 24 | ```clojure 25 | (reg-effect 26 | ::echo 27 | (fn [& args] 28 | (prn args)) 29 | 30 | ::echo2 31 | (fn [& args] 32 | (prn args))) 33 | 34 | (act ::echo \"Hello, World!\") 35 | ``` 36 | " {:arglists '[[!db & keyvals] [& keyvals]]} 37 | [& args] 38 | (let [[!db effect-specs] (resolve-db-keyvals-args args)] 39 | (rstore/patch! !db 40 | {:path [::z/state ::act/effect-handlers] 41 | :fnil {} 42 | :change [:into effect-specs]})) 43 | nil) 44 | 45 | (defn reg-streams " 46 | Register one or more data streams. 47 | 48 | ```clojure 49 | (defonce !db (atom {})) 50 | 51 | (reg-stream 52 | :db 53 | (fn [rx path] 54 | (rx (get-in @!db path))) 55 | 56 | :other 57 | (fn [rx] 58 | (rx \"thing\"))) 59 | ``` 60 | 61 | If a function is returned it will be called to cleanup 62 | the stream once it's spun down. 63 | 64 | Each pair of `[stream-key args]` represents a unique 65 | stream instance, so the method will be called only once 66 | for each set of args used with the stream; until the 67 | stream has been spun down and must be restarted. 68 | " {:arglists '[[!db & keyvals] [& keyvals]]} 69 | [& args] 70 | (let [[!db stream-specs] (resolve-db-keyvals-args args)] 71 | (rstore/patch! !db 72 | {:path [::z/state ::bnd/stream-handlers] 73 | :fnil {} 74 | :change [:into stream-specs]})) 75 | nil) 76 | 77 | (defn reg-injections 78 | {:arglists '[[!db & keyvals] [& keyvals]]} 79 | [& args] 80 | (let [[!db injection-specs] (resolve-db-keyvals-args args)] 81 | (rstore/patch! !db 82 | {:path [::z/state ::inj/injection-handlers] 83 | :fnil {} 84 | :change [:into injection-specs]})) 85 | nil) 86 | 87 | (defn reg-components 88 | {:arglists '[[!db & keyvals] [& keyvals]]} 89 | [& args] 90 | (let [[!db component-specs] (resolve-db-keyvals-args args)] 91 | (when-not (rstore/patch! !db 92 | {:path [::z/state ::pending-components] 93 | :fnil {} 94 | :change [:assoc component-specs]} 95 | :when #(nil? (::component-registry/state %))) 96 | (doseq [[component-name component-spec] component-specs] 97 | (component-registry/reg-component !db component-name component-spec) 98 | (component-registry/reg-attribute-readers !db component-name (get-in @!db [::z/state ::attr-reader])) 99 | (component-registry/reg-attribute-writers !db component-name (get-in @!db [::z/state ::attr-writer]))))) 100 | nil) 101 | 102 | (def ^:private default-opts 103 | {:html? true}) 104 | 105 | (defn- add-registrations! 106 | [!db] 107 | (reg-effects !db 108 | :zero.core/choose 109 | (with-meta 110 | (fn [ctx f & args] 111 | (doseq [effect (apply f args)] 112 | (act/do-effect! (::z/db ctx) ctx effect))) 113 | {::z/contextual true})) 114 | 115 | (reg-injections !db 116 | :zero.core/ctx 117 | (fn [ctx & path] 118 | (get-in ctx path)) 119 | 120 | :zero.core/act 121 | (fn [_ & args] 122 | (apply z/act args)) 123 | 124 | :zero.core/<< 125 | (fn [_ & args] 126 | (apply z/<< args)) 127 | 128 | :zero.core/call 129 | (fn [_ f & args] 130 | (apply f args))) 131 | 132 | (component-registry/reg-attribute-writers !db :zero.dom/* (-> @!db ::z/state ::attr-writer)) 133 | (component-registry/reg-attribute-readers !db :zero.dom/* (-> @!db ::z/state ::attr-reader))) 134 | 135 | (defn install! 136 | [!db & {:as opts}] 137 | (let [merged-opts (merge default-opts opts) 138 | attr-writer (fn zero-attr-writer [v _ _] 139 | (when v 140 | (cdf/write-str v :mapper (or (:cdf-mapper opts) cdf/default-mapper)))) 141 | attr-reader (fn zero-attr-reader [s _ _] 142 | (cdf/read-str s :operators (or (:cdf-operators opts) cdf/default-operators)))] 143 | (component-registry/install! !db :ignore-if-already-installed? true) 144 | 145 | (when-let [[old-db _] (rstore/patch! !db {:path [::z/state] :fnil {} :change [:clear ::pending-components ::pending-attr-readers ::pending-attr-writers]})] 146 | (doseq [[component-name component-spec] (get-in old-db [::z/state ::pending-components])] 147 | (component-registry/reg-component !db component-name component-spec) 148 | (component-registry/reg-attribute-writers !db component-name attr-writer) 149 | (component-registry/reg-attribute-readers !db component-name attr-reader))) 150 | 151 | (when (:html? merged-opts) 152 | (html/install! !db 153 | :render-listener 154 | (fn [node-id k v] 155 | [:zero.dom/listen 156 | :sel (str "#" node-id) 157 | :evt k 158 | :act v]) 159 | 160 | :render-binding 161 | (fn [node-id k v] 162 | [:zero.dom/bind 163 | :sel (str "#" node-id) 164 | :prop k 165 | :ref v]) 166 | 167 | :ignore-if-already-installed? 168 | true)) 169 | 170 | (rstore/patch! !db 171 | [{:path [::z/state ::attr-reader] 172 | :change [:value attr-reader]} 173 | {:path [::z/state ::attr-writer] 174 | :change [:value attr-writer]}]) 175 | (add-registrations! !db)) 176 | nil) 177 | -------------------------------------------------------------------------------- /src/zero/core.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.core 2 | (:require 3 | [zero.impl.default-db :refer [!default-db]] 4 | [zero.impl.actions :as act #?@(:cljs [:refer [Action]])] 5 | [zero.impl.bindings #?@(:cljs [:refer [Binding]])] 6 | [zero.impl.injection #?@(:cljs [:refer [Injection]]) :as inj] 7 | [zero.impl.signals #?@(:cljs [:refer [Signal]]) :as sig] 8 | [zero.impl.base :as base] 9 | [clojure.string :as str] 10 | [subzero.core :as sz]) 11 | #?(:clj 12 | (:import 13 | (zero.impl.actions Action) 14 | (zero.impl.bindings Binding) 15 | (zero.impl.injection Injection) 16 | (zero.impl.signals Signal)))) 17 | 18 | (defn act " 19 | Construct an action. 20 | 21 | ```clojure 22 | [:button 23 | :on-click (act [:do-something (<< :inject-something)] 24 | [:do-something-else \"some data\"]) 25 | \"Click Me!\"] 26 | 27 | ``` 28 | 29 | Any `nil`s in the effect sequence are ignored, and 30 | `seq?`s are concatenated into the effect sequence. 31 | 32 | This function may also take a 'props' map as its first 33 | argument, the following props are supported in ClojureScript: 34 | 35 | - `:log?` - log various bits of useful info when this action is dispatched, useful for debugging 36 | - `:prevent-default?` - call `.preventDefault()` on the event, when dispatched with an event 37 | - `:stop-propagation?` - call `.stopPropagation()` on the event, when dispatched with an event 38 | - `:dispatch` - the dispatch strategy, one of (`:default`, `:immediate`, `:throttle`, `:debounce`) 39 | - `:delta` - if `:dispatch` is `:throttle` or `:debounce`, specifies the delta for each dispatch 40 | " {:arglists 41 | '[[& effects] 42 | [{:keys [log? prevent-default? stop-propagation? dispatch delta]} & effects]]} 43 | [& things] 44 | (let [[props effects] (if (map? (first things)) 45 | [(first things) (rest things)] 46 | [{} things])] 47 | (Action. props (vec (mapcat (fn [fx] (if (or (seq? fx) (nil? fx)) fx [fx])) effects))))) 48 | 49 | (defn bnd " 50 | Construct a binding. 51 | 52 | ```clojure 53 | [:input 54 | :value (bnd {:default \"foo\"} :db/something)] 55 | ``` 56 | " {:arglists 57 | '[[stream-key & args] 58 | [{:keys [default default-nil?]} stream-key & args]]} 59 | [& things] 60 | (let [[props stream-key args] (if (map? (first things)) 61 | [(first things) (second things) (nthrest things 2)] 62 | [{} (first things) (rest things)])] 63 | (Binding. props stream-key (vec args)))) 64 | 65 | (def ^{:arglists '[[injection-key & args]]} 66 | << " 67 | Construct an injection. 68 | ```clojure 69 | (act :do-something (<< :inject-some-data)) 70 | (bnd :something (<< :inject-some-data)) 71 | ``` 72 | 73 | As a convenience, injectors can be chained without nesting: 74 | 75 | ```clojure 76 | (<< :inject-something 1 2 << :inject-something-else) 77 | ;; is equivalent to 78 | (<< :inject-something 1 2 (<< :inject-something-else)) 79 | ``` 80 | " 81 | (with-meta 82 | (fn << [injection-key & args] 83 | (let [[args others] (split-with #(-> % meta ::injector-fn not) args)] 84 | (Injection. injection-key 85 | (cond-> (vec args) 86 | (seq others) (conj (apply (first others) (rest others))))))) 87 | {::injector-fn true})) 88 | 89 | (defn sig " 90 | Construct a signal. 91 | ```clojure 92 | (def my-sig (sig ::my-signal-key)) 93 | 94 | ;; elsewhere 95 | [::some-component :focus-signal my-sig] 96 | 97 | ;; elsewhere 98 | (zc/reg-components 99 | ::some-component 100 | {:props #{:focus-signal} 101 | :view (fn [{:keys [focus-signal]}] 102 | [:input 103 | ::z/on {focus-signal (act [::focus (<map 122 | [^Action act] 123 | {:props (.-props act) :effects (.-effects act)}) 124 | 125 | (defn map->act 126 | [m] 127 | (Action. (into {} (:props m)) (vec (:effects m)))) 128 | 129 | (defn inj? 130 | [x] 131 | (instance? Injection x)) 132 | 133 | (defn inj->map 134 | [^Injection inj] 135 | {:key (.-key inj) :args (.-args inj)}) 136 | 137 | (defn map->inj 138 | [m] 139 | (Injection. (:key m) (vec (:args m)))) 140 | 141 | (defn bnd? 142 | [x] 143 | (instance? Binding x)) 144 | 145 | (defn bnd->map 146 | [^Binding bnd] 147 | {:key (.-key bnd) :props (.-props bnd) :args (.-args bnd)}) 148 | 149 | (defn map->bnd 150 | [m] 151 | (Binding. (into {} (:props m)) (:key m) (vec (:args m)))) 152 | 153 | (defn sig? 154 | [x] 155 | (instance? Signal x)) 156 | 157 | (defn sig->map 158 | [^Signal x] 159 | {:key (.-key x)}) 160 | 161 | (defn map->sig 162 | [m] 163 | (Signal. (:key m))) 164 | 165 | (defn css-selector 166 | [x] 167 | (cond 168 | (string? x) 169 | x 170 | 171 | (keyword? x) 172 | (if-let [ns (namespace x)] 173 | (str (str/replace ns #"[.]" "\\.") "-" (name x)) 174 | (name x)) 175 | 176 | (vector? x) 177 | (str/join " " (map css-selector x)))) 178 | 179 | (def ^{:arglists 180 | '[[& effects] 181 | [{:keys [log? prevent-default? stop-propagation? dispatch delta]} & effects]]} 182 | < selector z/css-selector) 27 | slot-selector (if slots (->> slots (map #(str "slot[name=\"" (name %) "\"]")) (str/join ",")) "slot")] 28 | {:state-factory 29 | (fn slotted-prop-state-factory [^js/HTMLElement dom] 30 | (let [shadow (.-shadowRoot dom) 31 | !slotted (atom nil) 32 | update-slotted! (fn update-slotted! [] 33 | (let [now-slotted 34 | (set 35 | (for [slot (array-seq (.querySelectorAll shadow slot-selector)) 36 | node (array-seq (.assignedNodes slot)) 37 | :when (or (nil? slotted-selector) 38 | (and (instance? js/HTMLElement node) 39 | (.matches node slotted-selector)))] 40 | node))] 41 | (when (not= now-slotted @!slotted) 42 | (reset! !slotted now-slotted)))) 43 | abort-controller (js/AbortController.)] 44 | (update-slotted!) 45 | 46 | (.addEventListener shadow "slotchange" update-slotted! #js{:signal (.-signal abort-controller)}) 47 | #_(.addEventListener shadow "render" update-slotted! #js{:signal (.-signal abort-controller)}) 48 | 49 | (reify 50 | IDeref 51 | (-deref [_] 52 | @!slotted) 53 | 54 | IWatchable 55 | (-add-watch [_ k f] 56 | (-add-watch !slotted k f)) 57 | (-remove-watch [_ k] 58 | (-remove-watch !slotted k)) 59 | 60 | IDisposable 61 | (dispose! [_] 62 | (.abort abort-controller))))) 63 | 64 | :state-cleanup 65 | (fn slotted-prop-state-cleanup [state _] 66 | (dispose! state))})) 67 | 68 | (defn internal-state-prop 69 | [initial] 70 | {:state-factory 71 | (fn [^js/HTMLElement element] 72 | (get-internal-state! element initial))}) 73 | 74 | (defn ^:deprecated set-internal-state 75 | [^js/HTMLElement element new-state] 76 | (reset! (get-internal-state! element {}) new-state)) 77 | 78 | (defn patch-internal-state! 79 | [^js/HTMLElement element patch] 80 | (rstore/patch! (get-internal-state! element {}) (base/convert-patch patch))) 81 | 82 | (defn- dispatch! 83 | [{host ::z/host :as ctx} event-name & {:keys [data target default bubble? cancelable? composed?]}] 84 | (-> (js/Promise.resolve (or target host)) 85 | (.then 86 | (fn [^js target] 87 | (when 88 | (.dispatchEvent target 89 | (js/CustomEvent. (name event-name) 90 | #js{:bubbles bubble? 91 | :cancelable cancelable? 92 | :composed composed? 93 | :detail data})) 94 | (when (ifn? default) 95 | (default ctx))))))) 96 | 97 | (defn- invoke! 98 | [target method-name & args] 99 | (-> (js/Promise.resolve target) 100 | (.then 101 | (fn [^js target] 102 | (apply (gobj/get target (name method-name)) args))))) 103 | 104 | (defn- set-field! 105 | [target field-name value] 106 | (-> (js/Promise.resolve target) 107 | (.then 108 | (fn [^js target] 109 | (gobj/set target (name field-name) value))))) 110 | 111 | (defn listen-view 112 | [!db {:keys [sel evt] action :act :as props {!mut :mut} :state}] 113 | (let [action 114 | (cond 115 | (string? action) (js/Function. "event" action) 116 | (fn? action) action 117 | (satisfies? web-components/IListenValue action) action 118 | :else (throw (ex-info "'act' is not a function" {:act action})))] 119 | [:root> 120 | :#style {:display :none} 121 | :#on {:render 122 | (fn [^js/Event ev] 123 | (let [target (.querySelector (.getRootNode (.-host (.-target ev))) (z/css-selector sel))] 124 | (when-let [{:keys [old-props old-target]} @!mut] 125 | (when-not (and (= old-props props) (= old-target target)) 126 | (web-components/unlisten (:evt old-props) !db old-target))) 127 | (when target 128 | (web-components/listen evt !db target 129 | (if (satisfies? IListenValue action) 130 | (web-components/get-listener-fun action !db) 131 | action)) 132 | (swap! !mut assoc :old-props props :old-target target)))) 133 | 134 | :disconnect 135 | (fn [_] 136 | (when-let [{:keys [old-props old-target]} @!mut] 137 | (web-components/unlisten (:evt old-props) !db old-target)))}])) 138 | 139 | (defn bind-view 140 | [!db {:keys [sel prop ref] :as props {!mut :mut} :state}] 141 | [:root> 142 | :#style {:display :none} 143 | :#on {:render 144 | (fn [^js/Event ev] 145 | (let [target (.querySelector (.getRootNode (.-host (.-target ev))) (z/css-selector sel))] 146 | (when-let [{:keys [old-props old-target]} @!mut] 147 | (when-not (and (= old-props props) (= old-target target)) 148 | (web-components/unbind (:prop old-props) !db old-target))) 149 | (when target 150 | (web-components/bind prop !db target 151 | (if (satisfies? IBindValue ref) 152 | (web-components/get-bind-watchable ref !db) 153 | ref)) 154 | (swap! !mut assoc :old-props props :old-target target)))) 155 | 156 | :disconnect 157 | (fn [_] 158 | (when-let [{:keys [old-props old-target]} @!mut] 159 | (web-components/unbind (:prop old-props) !db old-target)))}]) 160 | 161 | (defn- delayed 162 | [!db delay f & args] 163 | (if (nil? delay) 164 | (apply f args) 165 | (js/Promise. 166 | (fn [resolve reject] 167 | (try 168 | (case delay 169 | :after-render 170 | (let [k (gensym) 171 | after-render-sig (z/sig ::z/after-render)] 172 | (sig/listen !db after-render-sig k 173 | (fn [] 174 | (sig/unlisten !db after-render-sig k) 175 | (resolve (apply f args))))) 176 | 177 | :before-render 178 | (let [k (gensym) 179 | before-render-sig (z/sig ::z/before-render)] 180 | (sig/listen !db before-render-sig k 181 | (fn [] 182 | (sig/unlisten !db before-render-sig k) 183 | (resolve (apply f args)))))) 184 | (catch :default ex 185 | (reject ex))))))) 186 | 187 | (defn inj-select-one 188 | [{^js/Node root ::z/root !db ::z/db} 189 | selector 190 | & {:keys [deep? delay from]}] 191 | (letfn [(select-fn 192 | [^js/Node root] 193 | (or 194 | (.querySelector root (z/css-selector selector)) 195 | (when deep? 196 | (some 197 | (fn [^js/Node node] 198 | (when-let [inner-root (.-shadowRoot node)] 199 | (select-fn inner-root))) 200 | (array-seq (.-childNodes root))))))] 201 | (if (instance? js/Promise from) 202 | (-> ^js/Promise from (.then #(delayed !db delay select-fn %))) 203 | (delayed !db delay select-fn (or from root))))) 204 | 205 | (defn- inj-select-all 206 | [{^js/Node root ::z/root !db ::z/db} 207 | selector 208 | & {:keys [deep? delay from]}] 209 | (letfn [(select-fn 210 | [^js/Node root] 211 | (concat 212 | (array-seq (.querySelectorAll root (z/css-selector selector))) 213 | (when deep? 214 | (mapcat 215 | (fn [^js/Node node] 216 | (when-let [inner-root (.-shadowRoot node)] 217 | (select-fn inner-root))) 218 | (array-seq (.-childNodes root))))))] 219 | (if (instance? js/Promise from) 220 | (-> ^js/Promise from (.then #(delayed !db delay select-fn %))) 221 | (delayed !db delay select-fn (or from root))))) 222 | 223 | (defn- inj-select-closest 224 | [{^js/Node root ::z/root ^js/Node current ::z/current !db ::z/db} 225 | selector 226 | & {:keys [breach? delay from]}] 227 | (letfn [(select-fn 228 | [^js/Node root ^js/Node current] 229 | (or 230 | (.closest current (z/css-selector selector)) 231 | (when breach? 232 | (when (instance? js/ShadowRoot root) 233 | (let [new-current (.-host root) 234 | new-root (.getRootNode new-current)] 235 | (select-fn new-root new-current))))))] 236 | (if (instance? js/Promise from) 237 | (-> ^js/Promise from (.then #(delayed !db delay select-fn root %))) 238 | (delayed !db delay select-fn root (or from current))))) 239 | 240 | (defn- inj-shadow 241 | [_ target] 242 | (if (instance? js/Promise target) 243 | (-> target (.then #(.-shadowRoot ^js target))) 244 | (.-shadowRoot ^js target))) 245 | 246 | (defn- inj-field 247 | [_ ^js target field-name] 248 | (if-not (instance? js/Promise target) 249 | (gobj/get target (name field-name)) 250 | (.then target (fn [^js target] (gobj/get target (name field-name)))))) 251 | 252 | (defn install! 253 | [!db] 254 | (zc/reg-effects !db 255 | ::patch-internal-state patch-internal-state! 256 | ::set-internal-state set-internal-state 257 | ::dispatch (vary-meta dispatch! assoc ::z/contextual true) 258 | ::invoke invoke! 259 | ::set-field set-field!) 260 | 261 | (zc/reg-injections !db 262 | ::select-one inj-select-one 263 | ::select-all inj-select-all 264 | ::select-closest inj-select-closest 265 | ::shadow inj-shadow 266 | ::field inj-field) 267 | 268 | (zc/reg-components !db 269 | ::echo 270 | {:props #{:vdom} 271 | :inherit-doc-css? true 272 | :view (fn [{:keys [vdom]}] 273 | vdom)} 274 | 275 | ::listen 276 | {:props {:sel :default 277 | :evt :default 278 | :act :default 279 | :state (internal-state-prop (fn [] {:mut (atom nil)}))} 280 | :view (partial listen-view !db)} 281 | 282 | ::bind 283 | {:props {:sel :default 284 | :prop :default 285 | :ref :default 286 | :state (internal-state-prop (fn [] {:mut (atom nil)}))} 287 | :view (partial bind-view !db)}) 288 | nil) 289 | -------------------------------------------------------------------------------- /src/zero/extras/db.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:deprecated zero.extras.db " 2 | Deprecated. Use SubZero's rstore instead. 3 | " 4 | (:refer-clojure :exclude [get]) 5 | (:require 6 | [zero.config :as zc] 7 | [subzero.logger :as log] 8 | [subzero.rstore :as rstore] 9 | [zero.impl.base :as base])) 10 | 11 | (defonce ^:private !db (rstore/rstore {})) 12 | 13 | (zc/reg-streams 14 | ::path 15 | (fn [rx path] 16 | (let [watch-key (gensym)] 17 | (rstore/watch !db watch-key path 18 | (fn [_ new-val _] 19 | (rx new-val))) 20 | (rx (get-in @!db path)) 21 | (fn db-path-stream-cleanup [] 22 | (rstore/unwatch !db watch-key))))) 23 | 24 | (defn get 25 | [path] 26 | (get-in @!db path)) 27 | 28 | (zc/reg-injections 29 | ::path 30 | (fn [_ path] 31 | (get-in @!db path))) 32 | 33 | (defn apply-patch [m patch] 34 | (rstore/calc-patch rstore/default-operators m (base/convert-patch patch))) 35 | 36 | (defn patch! 37 | [patch] 38 | (rstore/patch! !db (base/convert-patch patch))) 39 | 40 | (zc/reg-effects 41 | ::patch 42 | (fn [patch] 43 | (patch! patch))) 44 | -------------------------------------------------------------------------------- /src/zero/html.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:deprecated zero.html " 2 | Functions for rendering Zero markup as HTML. 3 | 4 | For vector forms with tags matching registered 5 | elements, and whose registration includes a 6 | `:zero.html/render? true` option; the components 7 | view will be rendered as a declarative shadow DOM. 8 | 9 | This namespace is deprecated, use [[subzero.plugins.html]] 10 | instead. 11 | " 12 | (:require 13 | [zero.dom :as-alias dom] 14 | [zero.config :as zc] 15 | [subzero.plugins.html :as html])) 16 | 17 | (defn write-html " 18 | Write Zero markup to a writer as HTML. 19 | " 20 | {:arglists 21 | '[[w & markup] 22 | [w {:keys [doctype]} & markup]]} 23 | [w & args] 24 | (apply html/write-html zc/!default-db w args)) 25 | 26 | (defn html " 27 | Format Zero markup as an HTML string. 28 | " 29 | {:arglists 30 | '[[& markup] 31 | [{:keys [doctype]} & markup]]} 32 | [& args] 33 | (apply html/html zc/!default-db args)) -------------------------------------------------------------------------------- /src/zero/impl/actions.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc zero.impl.actions 2 | (:require 3 | [zero.impl.injection :refer [apply-injections CustomInject]] 4 | [zero.impl.signals :as sig] 5 | [zero.impl.default-db :refer [!default-db]] 6 | [zero.impl.base :as base] 7 | [subzero.logger :as log] 8 | [zero.core :as-alias z] 9 | [subzero.rstore :as rstore]) 10 | #?(:clj 11 | (:import 12 | [clojure.lang IFn]))) 13 | 14 | (defn ^:no-doc do-effect! 15 | [!db ctx [effect-key & args :as effect]] 16 | (let [effect-fn (or (get-in @!db [::z/state ::effect-handlers effect-key]) 17 | (throw 18 | (ex-info "No effect registered for key" 19 | {:effect effect})))] 20 | (if (::z/contextual (meta effect-fn)) 21 | (apply effect-fn ctx args) 22 | (apply effect-fn args)))) 23 | 24 | #?(:cljs 25 | (defn event->context [!db ^js/Event ev] 26 | (let [^js root (.getRootNode (.-currentTarget ev)) 27 | ^js host (when (instance? js/ShadowRoot root) (.-host root)) 28 | harvest-event (get-in @!db [::z/state ::harvest-event]) 29 | data (harvest-event ev)] 30 | {:zero.core/event.data data ;; deprecated 31 | :zero.core/event.target (.-target ev) 32 | :zero.core/event.current (.-currentTarget ev) ;; deprecated 33 | :zero.core/event ev 34 | :zero.core/current (.-currentTarget ev) 35 | :zero.core/host host 36 | :zero.core/root root 37 | :zero.core/data data 38 | :zero.core/db !db}))) 39 | 40 | (declare ^:private throttle) 41 | 42 | (defrecord Action [props effects] 43 | CustomInject 44 | (custom-inject 45 | [act _inject] 46 | act) 47 | 48 | #?@(:clj 49 | [IFn 50 | (invoke 51 | [action context] 52 | (.invoke action context !default-db)) 53 | (invoke 54 | [action context !db] 55 | (let [{:keys [log? dispatch]} (.-props action) 56 | 57 | actually-perform! 58 | (fn actually-perform! [] 59 | (doseq [effect (apply-injections !db context (.-effects action))] 60 | (try 61 | (do-effect! !db context effect) 62 | (catch Exception ex 63 | (log/error "Error in effect handler" :ex ex)))) 64 | (when log? 65 | (log/info (str action) :data {:context context})))] 66 | (case (or dispatch :default) 67 | (:throttle :debounce) 68 | (throttle !db action actually-perform! dispatch) 69 | 70 | :immediate 71 | (actually-perform!) 72 | 73 | :default 74 | (base/schedule 0 actually-perform!)) 75 | nil))])) 76 | 77 | #?(:cljs 78 | (extend-type Action 79 | IFn 80 | (-invoke 81 | ([action context] 82 | (action context !default-db)) 83 | ([action context !db] 84 | (let [{:keys [log? dispatch] :as props} (.-props action) 85 | 86 | context 87 | (cond 88 | (instance? js/Event context) 89 | (do 90 | (when (:prevent-default? props) 91 | (.preventDefault ^js/Event context)) 92 | (when (:stop-propagation? props) 93 | (.stopPropagation ^js/Event context)) 94 | (event->context !db context)) 95 | 96 | :else 97 | (merge {:zero.core/db !db} context)) 98 | 99 | actually-perform! 100 | (fn actually-perform! [] 101 | (when log? 102 | (js/console.groupCollapsed (pr-str action)) 103 | (js/console.info :context context)) 104 | (let [!errors (atom [])] 105 | (doseq [effect (apply-injections !db context (.-effects action))] 106 | (try 107 | (do-effect! !db context effect) 108 | (when log? 109 | (js/console.info :effect effect)) 110 | (catch :default e 111 | (js/console.error :effect effect e) 112 | (swap! !errors conj e)))) 113 | (when log? 114 | (js/console.groupEnd) 115 | (doseq [error @!errors] 116 | (js/console.error error)))))] 117 | (case (or dispatch :default) 118 | (:throttle :debounce) 119 | (throttle !db action actually-perform! dispatch) 120 | 121 | :after-render 122 | (sig/listen !db sig/after-render-sig [::act action] 123 | (fn [] 124 | (sig/unlisten !db sig/after-render-sig [::act action]) 125 | (actually-perform!))) 126 | 127 | :before-render 128 | (let [!complete? (atom false) 129 | timeout (js/setTimeout 130 | (fn [] 131 | (reset! !complete? true) 132 | (sig/unlisten !db sig/before-render-sig [::act action]) 133 | (actually-perform!)))] 134 | (sig/listen !db sig/before-render-sig [::act action] 135 | (fn [] 136 | (sig/unlisten !db sig/before-render-sig [::act action]) 137 | (js/clearTimeout timeout) 138 | (actually-perform!)))) 139 | 140 | :immediate 141 | (actually-perform!) 142 | 143 | :default 144 | (js/setTimeout actually-perform!)) 145 | nil))))) 146 | 147 | (defn- throttle 148 | [!db ^Action action actually-perform! kind] 149 | (let [{:keys [delta]} (.-props action) 150 | 151 | [old-db _] 152 | (rstore/patch! !db 153 | {:path [::z/state ::throttling action :fn] 154 | :change [:value actually-perform!]})] 155 | (when-not (get-in old-db [::z/state ::throttling action]) 156 | (let [tick-fn 157 | (fn tick-fn [] 158 | (let [[old-db _] (rstore/patch! !db 159 | {:path [::z/state ::throttling action] 160 | :change [:clear :fn]})] 161 | (if-let [perform-fn (get-in old-db [::z/state ::throttling action :fn])] 162 | (perform-fn) 163 | (when-let [[old-db _] 164 | (rstore/patch! !db 165 | {:path [::z/state ::throttling] 166 | :change [:clear action]} 167 | :when #(nil? (get-in % [::z/state ::throttling action :fn])))] 168 | (base/cancel-scheduled (get-in old-db [::z/state ::throttling action :interval])))))) 169 | 170 | interval (base/schedule-every (or delta 300) tick-fn)] 171 | (rstore/patch! !db 172 | {:path [::z/state ::throttling action :interval] 173 | :change [:value interval]}) 174 | (when (= :throttle kind) 175 | (tick-fn)))))) -------------------------------------------------------------------------------- /src/zero/impl/base.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc zero.impl.base 2 | (:require 3 | [subzero.logger :as log]) 4 | #?(:clj 5 | (:import 6 | (clojure.lang Named) 7 | (java.io StringWriter Writer) 8 | (java.util Timer TimerTask)) 9 | :cljs 10 | (:import 11 | (goog.string StringBuffer)))) 12 | 13 | (defn try-catch 14 | [try-fn catch-fn] 15 | (try 16 | (try-fn) 17 | (catch #?(:cljs :default :clj Exception) ex 18 | (catch-fn ex)))) 19 | 20 | (defn can-deref? [x] 21 | #?(:cljs (satisfies? IDeref x) 22 | :clj (instance? clojure.lang.IRef x))) 23 | 24 | (defn can-watch? [x] 25 | #?(:cljs (satisfies? IWatchable x) 26 | :clj (instance? clojure.lang.IRef x))) 27 | 28 | (defn try-deref [x] 29 | (when (can-deref? x) 30 | (deref x))) 31 | 32 | (defprotocol IDisposable 33 | (dispose! [disposable])) 34 | 35 | (defn named? [x] 36 | (or (string? x) 37 | #?(:cljs (satisfies? INamed x) 38 | :clj (instance? Named x)))) 39 | 40 | #?(:clj (defn str-writer [] (StringWriter.)) 41 | :cljs (defn str-writer [] (->StringBufferWriter (StringBuffer.)))) 42 | 43 | #?(:clj (defn write [^Writer w & vs] 44 | (doseq [v vs] 45 | (if (char? v) 46 | (.write w (int v)) 47 | (.write w (str v))))) 48 | :cljs (defn write [w & vs] 49 | (doseq [v vs] 50 | (-write w (str v))))) 51 | 52 | #?(:clj (defn str-writer->str [w] (.toString w)) 53 | :cljs (defn str-writer->str [w] (-> ^js w .-sb .toString))) 54 | 55 | (defn dissoc-in 56 | [m [k & ks]] 57 | (if (seq ks) 58 | (let [new (dissoc-in (get m k) ks)] 59 | (if (seq new) 60 | (assoc m k new) 61 | (dissoc m k))) 62 | (dissoc m k))) 63 | 64 | 65 | #?(:cljs 66 | (do 67 | (defn schedule 68 | [delay f & args] 69 | (js/setTimeout #(apply f args) delay)) 70 | 71 | (defn schedule-every 72 | [delay f & args] 73 | (js/setInterval #(apply f args) delay)) 74 | 75 | (defn cancel-scheduled 76 | [handle] 77 | (js/clearTimeout handle))) 78 | 79 | :clj 80 | (do 81 | (defonce ^:private timer (Timer. true)) 82 | 83 | (defn schedule 84 | [delay f & args] 85 | (let [tt (proxy [TimerTask] [] (run [] (apply f args)))] 86 | (.schedule timer ^long delay) 87 | tt)) 88 | 89 | (defn schedule-every 90 | [delay f & args] 91 | (let [tt (proxy [TimerTask] [] (run [] (apply f args)))] 92 | (.schedule timer ^long delay ^long delay) 93 | tt)) 94 | 95 | (defn cancel-scheduled 96 | [^TimerTask handle] 97 | (.cancel handle)))) 98 | 99 | (defn convert-patch " 100 | Convert from legacy zero.extras.db/patch! format to subzero.rstore/patch! 101 | format for any changes not in the correct format. 102 | " 103 | [patch] 104 | (cond 105 | (and (map? patch) (or (contains? patch :subpatch) (contains? patch :change))) 106 | patch 107 | 108 | (map? patch) 109 | (cond-> 110 | (cond 111 | (ifn? (:fn patch)) 112 | {:path (:path patch) 113 | :change (into [:call (:fn patch)] (:args patch))} 114 | 115 | (map? (:merge patch)) 116 | {:path (:path patch) 117 | :change [:assoc (:merge patch)]} 118 | 119 | (coll? (:clear patch)) 120 | {:path (:path patch) 121 | :change (into [:clear] (:clear patch))} 122 | 123 | (contains? patch :conj) 124 | {:path (:path patch) 125 | :change (into [:conj (:conj patch)])} 126 | 127 | (coll? (:into patch)) 128 | {:path (:path patch) 129 | :change [:into (:into patch)]} 130 | 131 | (coll? (:patch patch)) 132 | {:path (:path patch) 133 | :subpatch (convert-patch (:patch patch))} 134 | 135 | :else 136 | {:path (:path patch) 137 | :change [:value (:value patch)]}) 138 | (contains? patch :fnil) 139 | (assoc :fnil (:fnil patch))) 140 | 141 | (sequential? patch) 142 | (mapv convert-patch patch) 143 | 144 | :else 145 | (throw (ex-info "Invalid patch" {:patch patch})))) 146 | -------------------------------------------------------------------------------- /src/zero/impl/bindings.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc zero.impl.bindings 2 | (:require 3 | [zero.impl.injection :refer [apply-injections]] 4 | [zero.impl.base :refer [try-catch schedule cancel-scheduled]] 5 | [zero.impl.default-db :refer [!default-db]] 6 | [zero.core :as-alias z] 7 | [subzero.core :as-alias sz] 8 | [subzero.logger :as log] 9 | [subzero.rstore :as rstore]) 10 | #?(:clj 11 | (:import 12 | [clojure.lang IDeref IRef IFn]))) 13 | 14 | (declare ^:private get-ref) 15 | 16 | (defrecord Binding [props key args] 17 | #?@(:clj 18 | [IDeref 19 | (deref 20 | [bnd] 21 | (.deref ^IDeref (get-ref !default-db bnd))) 22 | 23 | IRef 24 | (addWatch 25 | [bnd k f] 26 | (.addWatch ^IRef (get-ref !default-db bnd) k f)) 27 | (removeWatch 28 | [bnd k] 29 | (.removeWatch ^IRef (get-ref !default-db bnd) k)) 30 | (getWatches [_] (throw (UnsupportedOperationException.))) 31 | (getValidator [_] (throw (UnsupportedOperationException.))) 32 | (setValidator [_ _] (throw (UnsupportedOperationException.))) 33 | 34 | IFn 35 | (invoke 36 | [bnd !db] 37 | (get-ref !db bnd))])) 38 | 39 | (defn flush! 40 | [!db] 41 | (let [[old-db _] 42 | (rstore/patch! !db 43 | {:path [::z/state ::pending-stream-values] 44 | :change [:value {}]}) 45 | 46 | pending-values (get-in old-db [::z/state ::pending-stream-values])] 47 | (when (seq pending-values) 48 | (doseq [[stream-ident new-value] pending-values 49 | :let [[old-db _ :as patch-r] 50 | (rstore/patch! !db 51 | {:path [::z/state ::stream-states stream-ident :current] 52 | :change [:value new-value]} 53 | :when (fn [db] 54 | (and 55 | (= ::none (get-in db [::z/state ::pending-stream-values stream-ident] ::none)) 56 | (get-in db [::z/state ::stream-states stream-ident]))))] 57 | :when (some? patch-r) 58 | :let [{old-value :current watches :watches} (get-in old-db [::z/state ::stream-states stream-ident])] 59 | [[^Binding bnd k] watch-fn] watches 60 | :when (not= old-value new-value)] 61 | (try-catch 62 | (fn [] 63 | (let [default-value (:default (.-props bnd)) 64 | default-nil? (:default-nil? (.-props bnd))] 65 | (watch-fn k bnd 66 | (if (and (nil? old-value) default-nil?) default-value old-value) 67 | (if (and (nil? new-value) default-nil?) default-value new-value)))) 68 | (fn [ex] 69 | (log/error "Error in stream watcher fn" 70 | :data {:stream stream-ident} 71 | :ex ex)))) 72 | (recur !db)))) 73 | 74 | (defn- schedule-flush! 75 | [!db] 76 | (locking !db 77 | (when (nil? (get-in @!db [::z/state ::flush-streams-timeout])) 78 | (rstore/patch! !db 79 | {:path [::z/state ::flush-streams-timeout] 80 | :change [:value (schedule 5 81 | (fn [] 82 | (let [[old-db _] 83 | (rstore/patch! !db 84 | {:path [::z/state] 85 | :change [:clear ::flush-streams-timeout]})] 86 | (when (get-in old-db [::z/state ::flush-streams-timeout]) 87 | (flush! !db)))))]}))) 88 | nil) 89 | 90 | (defn- rx-fn 91 | [!db stream-ident] 92 | (fn rx 93 | [new-val] 94 | (rstore/patch! !db 95 | {:path [::z/state ::pending-stream-values stream-ident] 96 | :change [:value new-val]}) 97 | (schedule-flush! !db))) 98 | 99 | (defn- kill-stream! 100 | [!db [key args :as stream-ident]] 101 | (let [[old-db _] (rstore/patch! !db 102 | {:path [::z/state ::stream-states] 103 | :change [:clear stream-ident]})] 104 | (when-let [kill-fn (get-in old-db [::z/state ::stream-states stream-ident :kill-fn])] 105 | (try-catch 106 | kill-fn 107 | (fn [ex] 108 | (log/error "Error killing stream" 109 | :data {:key key :args args} 110 | :ex ex)))) 111 | (rstore/unwatch !db [::stream stream-ident])) 112 | nil) 113 | 114 | (defn- boot-stream! 115 | [!db [stream-key args :as stream-ident]] 116 | (try-catch 117 | (fn [] 118 | (let [handler-path [::z/state ::stream-handlers stream-key] 119 | stream-fn (or (get-in @!db handler-path) 120 | (throw 121 | (ex-info "No stream registered for key" 122 | {:stream-key stream-key}))) 123 | kill-fn (apply stream-fn 124 | (rx-fn !db stream-ident) 125 | (apply-injections !db {::z/db !db} args))] 126 | (rstore/patch! !db 127 | {:path [::z/state ::stream-states stream-ident :kill-fn] 128 | :change [:value kill-fn]}) 129 | (rstore/watch !db [::stream stream-ident] handler-path 130 | (fn [old-stream-fn new-stream-fn] 131 | (when (not= old-stream-fn new-stream-fn) 132 | (let [!tmp-pending (atom ::none) 133 | !rx-fn (atom #(reset! !tmp-pending %)) 134 | new-kill-fn (apply new-stream-fn 135 | #(@!rx-fn %) 136 | (apply-injections !db {::z/db !db} args)) 137 | [old-db _] (rstore/patch! !db 138 | [{:path [::z/state ::stream-states stream-ident :kill-fn] 139 | :change [:value new-kill-fn]}])] 140 | (when-let [old-kill-fn (get-in old-db [::z/state ::stream-states stream-ident :kill-fn])] 141 | (old-kill-fn)) 142 | 143 | (locking !rx-fn 144 | (when-not (= @!tmp-pending ::none) 145 | (rstore/patch! !db 146 | {:path [::z/state ::pending-stream-values stream-ident] 147 | :change [:value @!tmp-pending]}) 148 | (schedule-flush! !db)) 149 | (reset! !rx-fn (rx-fn !db stream-ident))))))))) 150 | (fn [ex] 151 | (log/error "Error booting stream" 152 | :data {:key key :args args} 153 | :ex ex) 154 | (rstore/patch! !db 155 | {:path [::z/state ::stream-states] 156 | :change [:clear stream-ident]}))) 157 | nil) 158 | 159 | (defn- get-ref 160 | [!db ^Binding bnd] 161 | (let [stream-ident [(.-key bnd) (.-args bnd)] 162 | props (.-props bnd) 163 | 164 | deref-fn 165 | (fn deref-fn 166 | [] 167 | (let [v (get-in @!db [::z/state ::stream-states stream-ident :current] (:default props))] 168 | (if (and (nil? v) (:default-nil? props)) 169 | (:default props) 170 | v))) 171 | 172 | add-watch-fn 173 | (fn add-watch-fn 174 | [k f] 175 | (let [[old-db _new-db] 176 | (rstore/patch! !db 177 | {:path [::z/state ::stream-states stream-ident :watches [bnd k]] 178 | :change [:value f]})] 179 | (when (empty? (get-in old-db [::z/state ::stream-states stream-ident :watches])) 180 | (boot-stream! !db stream-ident))) 181 | nil) 182 | 183 | remove-watch-fn 184 | (fn remove-watch-fn 185 | [k] 186 | (let [[old-db new-db] 187 | (rstore/patch! !db 188 | {:path [::z/state ::stream-states stream-ident :watches] 189 | :change [:clear [bnd k]]})] 190 | (when 191 | (and 192 | (empty? (get-in new-db [::z/state ::stream-states stream-ident :watches])) 193 | (seq (get-in old-db [::z/state ::stream-states stream-ident :watches]))) 194 | (kill-stream! !db stream-ident))) 195 | nil)] 196 | #?(:cljs 197 | (reify 198 | IDeref 199 | (-deref [_] (deref-fn)) 200 | 201 | IWatchable 202 | (-add-watch [_ k f] (add-watch-fn k f)) 203 | (-remove-watch [_ k] (remove-watch-fn k))) 204 | 205 | :clj 206 | (reify 207 | clojure.lang.IDeref 208 | (deref [_] (deref-fn)) 209 | 210 | clojure.lang.IRef 211 | (addWatch [_ k f] (add-watch-fn k f)) 212 | (removeWatch [_ k] (remove-watch-fn k)) 213 | (getWatches [_] (throw (UnsupportedOperationException.))) 214 | (getValidator [_] (throw (UnsupportedOperationException.))) 215 | (setValidator [_ _] (throw (UnsupportedOperationException.))))))) 216 | 217 | 218 | #?(:cljs 219 | (extend-type Binding 220 | IDeref 221 | (-deref 222 | [bnd] 223 | (-deref (get-ref !default-db bnd))) 224 | 225 | IWatchable 226 | (-add-watch 227 | [bnd k f] 228 | (-add-watch (get-ref !default-db bnd) k f)) 229 | (-remove-watch 230 | [bnd k] 231 | (-remove-watch (get-ref !default-db bnd) k)) 232 | 233 | IFn 234 | (-invoke 235 | ([bnd !db] 236 | (get-ref !db bnd))))) -------------------------------------------------------------------------------- /src/zero/impl/default_db.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.impl.default-db 2 | (:require 3 | [subzero.core :as sz])) 4 | 5 | (defonce !default-db (sz/create-db)) 6 | -------------------------------------------------------------------------------- /src/zero/impl/injection.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc zero.impl.injection 2 | (:require 3 | [clojure.walk :as walk] 4 | [subzero.logger :as log] 5 | [zero.impl.base :refer [try-catch]] 6 | [zero.core :as-alias z])) 7 | 8 | (defprotocol CustomInject 9 | (custom-inject [ci inject])) 10 | 11 | (defrecord Injection [key args]) 12 | 13 | (defn apply-injections [!db context x] 14 | (let [!cache (atom {}) 15 | walker (fn walker [form] 16 | (cond 17 | (instance? Injection form) 18 | (let [inj ^Injection form 19 | cache-key [(.-key inj) (.-args inj)]] 20 | (if (contains? @!cache cache-key) 21 | (get @!cache cache-key) 22 | (try-catch 23 | (fn [] 24 | (let [injector (or (get-in @!db [::z/state ::injection-handlers (.-key inj)]) 25 | (throw (ex-info "No injector registered for key" {:key (.-key inj)}))) 26 | r (apply injector context (walk/walk walker identity (.-args inj)))] 27 | (swap! !cache assoc cache-key r) 28 | r)) 29 | (fn [ex] 30 | (log/error "Error injecting" 31 | :data {:injection inj} 32 | :ex ex))))) 33 | 34 | (satisfies? CustomInject form) 35 | (custom-inject form #(walker %)) 36 | 37 | :else 38 | (walk/walk walker identity form)))] 39 | (walker x))) -------------------------------------------------------------------------------- /src/zero/impl/signals.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc zero.impl.signals 2 | (:require 3 | [subzero.logger :as log] 4 | [zero.impl.base :refer [dissoc-in]] 5 | [zero.impl.default-db :refer [!default-db]] 6 | [zero.core :as-alias z] 7 | [subzero.rstore :as rstore]) 8 | #?(:clj 9 | (:import 10 | (clojure.lang IFn)))) 11 | 12 | (defrecord Signal [key] 13 | #?@(:clj 14 | [IFn 15 | (invoke 16 | [sig !db] 17 | (doseq [f (some-> (get-in @!db [::z/state ::listeners (.-key sig)]) vals)] 18 | (f)) 19 | nil) 20 | (invoke 21 | [sig] 22 | (.invoke sig !default-db) 23 | nil)])) 24 | 25 | (defn listen 26 | ([^Signal sig k f] 27 | (listen !default-db sig k f)) 28 | ([!db ^Signal sig k f] 29 | (rstore/patch! !db 30 | {:path [::z/state ::listeners (.-key sig) k] 31 | :change [:value f]}) 32 | nil)) 33 | 34 | (defn unlisten 35 | ([^Signal sig k] 36 | (unlisten !default-db sig k)) 37 | ([!db ^Signal sig k] 38 | (rstore/patch! !db 39 | {:path [::z/state ::listeners] 40 | :change [:call dissoc-in [(.-key sig) k]]}) 41 | nil)) 42 | 43 | #?(:cljs 44 | (extend-type Signal 45 | IFn 46 | (-invoke 47 | ([sig !db] 48 | (doseq [f (some-> (get-in @!db [::z/state ::listeners (.-key sig)]) vals)] 49 | (f)) 50 | nil) 51 | ([sig] 52 | (sig !default-db))))) 53 | 54 | 55 | (def ^:no-doc after-render-sig (Signal. ::z/after-render)) 56 | (def ^:no-doc before-render-sig (Signal. ::z/before-render)) -------------------------------------------------------------------------------- /src/zero/tools/portfolio.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.tools.portfolio 2 | #?(:cljs (:require-macros zero.tools.portfolio)) 3 | (:require 4 | #?(:clj [portfolio.core :as portfolio]) 5 | [subzero.plugins.html :as html] 6 | [zero.config :as zc] 7 | [subzero.logger :as log])) 8 | 9 | #?(:cljs 10 | (def ^:private component-impl 11 | {'portfolio.adapter/render-component 12 | (fn [{:keys [component]} el] 13 | (try 14 | (set! (.-innerHTML el) (html/html zc/!default-db component)) 15 | (catch :default ex 16 | (log/error "Render error" :ex ex))))})) 17 | 18 | #?(:clj 19 | (defmacro defscene 20 | {:clj-kondo/lint-as 'clj-kondo.lint-as/def-catch-all} 21 | [id & opts] 22 | (when (portfolio/portfolio-active?) 23 | `(portfolio.data/register-scene! 24 | (portfolio.adapter/prepare-scene ~(portfolio/get-options-map id (:line &env) opts) component-impl))))) 25 | -------------------------------------------------------------------------------- /src/zero/tools/test.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.tools.test 2 | (:require 3 | #?@(:cljs 4 | [["node:fs" :as fs] 5 | ["node:path" :as path] 6 | [cljs.pprint :refer [pprint]] 7 | [cljs.test :refer [is]]] 8 | 9 | :clj 10 | [[clojure.java.io :as io] 11 | [clojure.pprint :refer [pprint]] 12 | [clojure.test :refer [is]]]))) 13 | 14 | (defn- slurp' 15 | [x] 16 | #?(:cljs 17 | (when (fs/existsSync x) 18 | (-> (fs/readFileSync x) .toString)) 19 | 20 | :clj 21 | (when (.exists (io/as-file x)) 22 | (slurp x)))) 23 | 24 | (defn- spit' 25 | [x s] 26 | #?(:cljs 27 | (do 28 | (fs/mkdirSync (path/dirname x) #js{:recursive true}) 29 | (fs/writeFileSync x s)) 30 | 31 | :clj 32 | (do 33 | (io/make-parents x) 34 | (spit x s)))) 35 | 36 | (defn snap 37 | [k data] 38 | (let [common-path (str (munge (namespace k)) "/" (munge (name k)) ".edn") 39 | accepted-snapshot-path (str "test/snapshots/accepted/" common-path) 40 | current-snapshot-path (str "test/snapshots/current/" common-path) 41 | current-snapshot (with-out-str (pprint data)) 42 | accepted-snapshot (slurp' accepted-snapshot-path)] 43 | (spit' current-snapshot-path current-snapshot) 44 | (if (some? accepted-snapshot) 45 | (is (= current-snapshot accepted-snapshot)) 46 | (spit' accepted-snapshot-path current-snapshot)) 47 | nil)) 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/zero/util.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.util 2 | (:require 3 | [zero.impl.base :refer [try-catch try-deref]] 4 | [subzero.logger :as log])) 5 | 6 | (defn derived 7 | [f & deps] 8 | (fn [rx & args] 9 | (let [watch-id (random-uuid) 10 | !dep-vals (atom nil) 11 | on-deps (fn [dep-vals] 12 | (try-catch 13 | #(rx (apply f dep-vals args)) 14 | (fn [ex] 15 | (log/error "Error in derived stream function" :ex ex))))] 16 | (doseq [[idx dep] (map-indexed vector deps)] 17 | (add-watch dep watch-id 18 | (fn [_ _ _ new-val] 19 | (swap! !dep-vals assoc idx new-val)))) 20 | 21 | (reset! !dep-vals (mapv try-deref deps)) 22 | (on-deps @!dep-vals) 23 | (add-watch !dep-vals watch-id 24 | (fn [_ _ _ new-val] 25 | (on-deps new-val))) 26 | 27 | (fn cleanup-derived [] 28 | (doseq [dep deps] 29 | (remove-watch dep watch-id)))))) 30 | 31 | (defonce ^:private !watch-deps (atom {})) 32 | 33 | (defn unwatch 34 | [key] 35 | (when-let [deps (get @!watch-deps key)] 36 | (doseq [dep deps] 37 | (remove-watch dep [::watch key]))) 38 | (swap! !watch-deps dissoc key) 39 | nil) 40 | 41 | (defn watch 42 | [key f & deps] 43 | (unwatch key) 44 | (swap! !watch-deps assoc key deps) 45 | (let [!dep-vals (atom nil) 46 | on-deps (fn [dep-vals] 47 | (try-catch 48 | #(apply f dep-vals) 49 | (fn [ex] 50 | (log/error "Error in watch function" :ex ex))))] 51 | (doseq [[idx dep] (map-indexed vector deps)] 52 | (add-watch dep [::watch key] 53 | (fn [_ _ _ new-val] 54 | (swap! !dep-vals assoc idx new-val)))) 55 | 56 | (reset! !dep-vals (mapv try-deref deps)) 57 | (add-watch !dep-vals [::watch key] 58 | (fn [_ _ _ new-val] 59 | (on-deps new-val)))) 60 | nil) 61 | 62 | (defn when-all 63 | [k f & deps] 64 | (let [init-dep-vals (map try-deref deps)] 65 | (if (every? some? init-dep-vals) 66 | (apply f init-dep-vals) 67 | (apply watch k 68 | (fn inner-fn [& dep-vals] 69 | (when (every? some? dep-vals) 70 | (unwatch k) 71 | (apply f dep-vals) 72 | true)) 73 | deps))) 74 | nil) 75 | 76 | (defn when-any 77 | [k f & deps] 78 | (let [init-dep-vals (map try-deref deps)] 79 | (if (some some? init-dep-vals) 80 | (apply f init-dep-vals) 81 | (apply watch k 82 | (fn [& dep-vals] 83 | (when (some some? dep-vals) 84 | (unwatch k) 85 | (apply f dep-vals))) 86 | deps))) 87 | nil) -------------------------------------------------------------------------------- /src/zero/wcconfig.cljs: -------------------------------------------------------------------------------- 1 | (ns zero.wcconfig 2 | (:require 3 | [zero.config] 4 | [zero.core :as z] 5 | [zero.impl.actions :refer [Action] :as act] 6 | [zero.impl.bindings :refer [Binding] :as bnd] 7 | [zero.impl.signals :refer [Signal]] 8 | [subzero.plugins.web-components :refer [IListenKey IListenValue IBindValue]:as web-components] 9 | [subzero.rstore :as rstore])) 10 | 11 | (defn default-event-harvester [^js/Event event] 12 | (case (.-type event) 13 | ("keyup" "keydown" "keypress") 14 | {:key (.-key event) 15 | :code (.-code event) 16 | :mods (cond-> #{} 17 | (.-altKey event) (conj :alt) 18 | (.-shiftKey event) (conj :shift) 19 | (.-ctrlKey event) (conj :ctrl) 20 | (.-metaKey event) (conj :meta))} 21 | 22 | ("input" "change") 23 | (let [target (.-target event)] 24 | (when (or (instance? js/HTMLInputElement target) (instance? js/HTMLTextAreaElement target)) 25 | (case (.-type target) 26 | "checkbox" 27 | (.-checked target) 28 | 29 | "file" 30 | (-> target .-files array-seq vec) 31 | 32 | (.-value target)))) 33 | 34 | "submit" 35 | (let [target (.-target event)] 36 | (when (instance? js/HTMLFormElement target) 37 | (js/FormData. target))) 38 | 39 | ("drop") 40 | (->> event .-dataTransfer .-items array-seq 41 | (mapv #(if (= "file" (.-kind %)) (.getAsFile %) (js/Blob. [(.getAsString %)] #js{:type (.-type %)})))) 42 | 43 | ;; TODO: others 44 | 45 | (or 46 | (.-detail event) 47 | ;; TODO: others 48 | ))) 49 | 50 | (def default-opts 51 | {:harvest-event default-event-harvester 52 | :hot-reload? true}) 53 | 54 | (defn install! 55 | [!db & {:as opts}] 56 | (let [merged-opts (merge default-opts opts) 57 | after-render-sig (resolve 'zero.impl.signals/after-render-sig) 58 | before-render-sig (resolve 'zero.impl.signals/before-render-sig)] 59 | (web-components/install! !db js/document js/customElements 60 | :hot-reload? (:hot-reload? merged-opts) 61 | :disable-tags? false 62 | :after-render after-render-sig 63 | :before-render #(do (before-render-sig) (bnd/flush! !db)) 64 | :ignore-if-already-installed? true) 65 | (rstore/patch! !db 66 | {:path [::z/state ::act/harvest-event] 67 | :change [:value (:harvest-event merged-opts)]})) 68 | nil) 69 | 70 | (extend-type Signal 71 | IListenKey 72 | (listen 73 | [sig !db target listener-fun] 74 | (zero.impl.signals/listen !db sig [sig target] 75 | (fn [] 76 | (let [root (.getRootNode target)] 77 | (listener-fun 78 | {::z/current target 79 | ::z/root root 80 | ::z/host (when (instance? js/ShadowRoot root) (.-host root))}))))) 81 | (unlisten 82 | [sig !db target] 83 | (zero.impl.signals/unlisten !db sig [sig target]))) 84 | 85 | (extend-type Binding 86 | IBindValue 87 | (get-bind-watchable 88 | [bnd !db] 89 | (bnd !db))) 90 | 91 | (extend-type Action 92 | IListenValue 93 | (get-listener-fun 94 | [action !db] 95 | #(action % !db))) 96 | -------------------------------------------------------------------------------- /test/browser/zero/placeholder_btest.cljs: -------------------------------------------------------------------------------- 1 | (ns zero.placeholder-btest) 2 | -------------------------------------------------------------------------------- /test/cljc/zero/core_test.cljc: -------------------------------------------------------------------------------- 1 | (ns zero.core-test 2 | (:require 3 | [zero.core :refer [<< <map inj)] 10 | (is (= ::k1 key)) 11 | (is (= (list ::arg1 ::arg2) (take 2 args))) 12 | (is (z/inj? (last args))) 13 | 14 | (let [{inner-args :args inner-key :key} (z/inj->map (last args))] 15 | (is (= ::k2 inner-key)) 16 | (is (= (list ::arg3) inner-args)))) 17 | (let [inj (<< ::k1 <map inj)] 19 | (is (= ::k1 key)) 20 | (is (z/inj? (last args))) 21 | 22 | (let [{inner-args :args inner-key :key} (z/inj->map (last args))] 23 | (is (= ::z/ctx inner-key)) 24 | (is (z/inj? (last inner-args))) 25 | 26 | (let [{inner-args-2 :args inner-key-2 :key} (z/inj->map (last inner-args))] 27 | (is (= ::z/act inner-key-2)) 28 | (is (z/inj? (last inner-args-2))) 29 | 30 | (let [{inner-args-3 :args inner-key-3 :key} (z/inj->map (last inner-args-2))] 31 | (is (= ::z/<< inner-key-3)) 32 | (is (= ::k4 (last inner-args-3)))))))) 33 | 34 | -------------------------------------------------------------------------------- /test/cljs/zero/placeholder_test.cljs: -------------------------------------------------------------------------------- 1 | (ns zero.placeholder-test) 2 | --------------------------------------------------------------------------------