├── .gitignore ├── deps.edn ├── src └── todo_mvc │ ├── dev.cljs │ ├── components.clj │ ├── storage.cljs │ ├── lib.cljc │ ├── components.cljs │ └── core.cljs ├── package.json ├── shadow-cljs.edn ├── index.html └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | js/ 3 | /package-lock.json 4 | .shadow-cljs/ 5 | /.cpcache 6 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {lilactown/helix {:mvn/version "0.1.9"} 3 | binaryage/devtools {:mvn/version "1.0.6"} 4 | thheller/shadow-cljs {:mvn/version "2.20.20"}}} 5 | -------------------------------------------------------------------------------- /src/todo_mvc/dev.cljs: -------------------------------------------------------------------------------- 1 | (ns todo-mvc.dev 2 | "This namespace injects the React refresh runtime and any other developer 3 | tools we want to preload - and not include in our production build!" 4 | (:require 5 | [helix.experimental.refresh :as r])) 6 | 7 | 8 | (r/inject-hook!) 9 | 10 | (defn ^:dev/after-load refresh [] 11 | (r/refresh!)) 12 | -------------------------------------------------------------------------------- /src/todo_mvc/components.clj: -------------------------------------------------------------------------------- 1 | (ns todo-mvc.components 2 | (:require [helix.core :as helix])) 3 | 4 | (defmacro title [& args] 5 | `(helix/$ Title ~@args)) 6 | 7 | (defmacro app-footer [& args] 8 | `(helix/$ AppFooter ~@args)) 9 | 10 | (defmacro new-todo [& args] 11 | `(helix/$ NewTodo ~@args)) 12 | 13 | (defmacro todo-item [& args] 14 | `(helix/$ TodoItem ~@args)) 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "npx shadow-cljs watch app" 5 | }, 6 | "dependencies": { 7 | "react": "^18.2.0", 8 | "react-dom": "^18.2.0", 9 | "react-refresh": "^0.8.1", 10 | "react-router-dom": "^5.1.2", 11 | "shadow-cljs": "^2.8.94", 12 | "todomvc-app-css": "^2.4.1", 13 | "todomvc-common": "^1.0.5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | ;; shadow-cljs configuration 2 | {:deps true 3 | 4 | :builds 5 | {:app {:target :browser 6 | :output-dir "js" 7 | :asset-path "/js" 8 | :modules {:app {:entries [todo-mvc.core]}} 9 | :devtools {:http-root "." 10 | :http-port 8888 11 | :reload-strategy :full 12 | :preloads [devtools.preload 13 | todo-mvc.dev]}}}} 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CLJS + Helix • TodoMVC 7 | 8 | 9 | 10 | 11 |
Loading...
12 | 13 | 14 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/todo_mvc/storage.cljs: -------------------------------------------------------------------------------- 1 | (ns todo-mvc.storage 2 | (:require [cljs.reader :as reader] 3 | [helix.hooks :as hooks])) 4 | 5 | (defn get-storage [key] 6 | (when-let [v (.getItem js/window.localStorage key)] 7 | (reader/read-string v))) 8 | 9 | (defn set-storage [key val] 10 | (.setItem js/window.localStorage key (pr-str val))) 11 | 12 | (defn use-persisted-reducer 13 | ([storage-key reducer initial-state init] 14 | (let [initial (get-storage storage-key) 15 | reducer-tuple (hooks/use-reducer reducer initial init) 16 | [state] reducer-tuple] 17 | (hooks/use-effect 18 | :auto-deps 19 | (set-storage storage-key state)) 20 | reducer-tuple))) 21 | -------------------------------------------------------------------------------- /src/todo_mvc/lib.cljc: -------------------------------------------------------------------------------- 1 | (ns todo-mvc.lib 2 | #?(:clj (:require [helix.core :as helix])) 3 | #?(:cljs (:require-macros [todo-mvc.lib]))) 4 | 5 | 6 | #?(:clj 7 | (defmacro defnc [type params & body] 8 | (let [opts? (map? (first body)) ;; whether an opts map was passed in 9 | opts (if opts? 10 | (first body) 11 | {}) 12 | body (if opts? 13 | (rest body) 14 | body) 15 | ;; feature flags to enable by default 16 | default-opts {:helix/features {:fast-refresh true}}] 17 | `(helix.core/defnc ~type ~params 18 | ;; we use `merge` here to allow indidivual consumers to override feature 19 | ;; flags in special cases 20 | ~(merge default-opts opts) 21 | ~@body)))) 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Helix • [TodoMVC](http://todomvc.com) 2 | 3 | > ClojureScript optimized for modern React development. 4 | 5 | ## Quick start 6 | 7 | You will need [Node.js](https://nodejs.org/en/) and [Clojure CLI tools](https://clojure.org/guides/getting_started) installed on your machine. 8 | 9 | ``` 10 | npm i 11 | 12 | npm start 13 | ``` 14 | 15 | Navigate to http://localhost:8888 16 | 17 | 18 | ![](https://github.com/tastejs/todomvc-app-css/raw/master/screenshot.png) 19 | 20 | ## Resources + Support 21 | 22 | - [Helix GitHub Repo](https://github.com/Lokeh/helix) 23 | - [Slack](https://clojurians.net) channel: `#helix` 24 | 25 | *Let [Todo MVC](https://github.com/tastejs/todomvc/issues) if you discover anything worth sharing.* 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ## Credit 34 | 35 | Created by [Will Acton](https://lilac.town) 36 | -------------------------------------------------------------------------------- /src/todo_mvc/components.cljs: -------------------------------------------------------------------------------- 1 | (ns todo-mvc.components 2 | (:require 3 | [helix.core :as hx :refer [$ <>]] 4 | [helix.dom :as d] 5 | [helix.hooks :as hooks] 6 | [todo-mvc.lib :refer [defnc]]) 7 | (:require-macros 8 | [todo-mvc.components])) 9 | 10 | (defn enter-key? [ev] 11 | (= (.-which ev) 13)) 12 | 13 | (defn escape-key? [ev] 14 | (= (.-which ev) 27)) 15 | 16 | (defnc Title 17 | [] 18 | (d/h1 "Todos")) 19 | 20 | (defnc AppFooter [] 21 | (d/footer 22 | {:class "info"} 23 | (d/p "Double click to edit a todo") 24 | (d/p "Part of " (d/a {:href "http://todomvc.com"} "TodoMVC")))) 25 | 26 | (defnc NewTodo 27 | [{:keys [on-complete]}] 28 | (let [[new-todo set-new-todo] (hooks/use-state "") 29 | on-change #(set-new-todo (.. % -target -value))] 30 | (d/input 31 | {:class "new-todo" 32 | :placeholder "What needs to be done?" 33 | :autoFocus true 34 | :value new-todo 35 | :on-key-down #(when (enter-key? %) 36 | (on-complete new-todo) 37 | (set-new-todo "")) 38 | :on-change on-change}))) 39 | 40 | (defn init-state [title] 41 | {:editing? false 42 | :title title}) 43 | 44 | (defmulti todo-actions (fn [state action] (first action))) 45 | 46 | (defmethod todo-actions 47 | ::start-editing [state _] 48 | (assoc state :editing? true)) 49 | 50 | (defmethod todo-actions 51 | ::stop-editing [state _] 52 | (assoc state :editing? false)) 53 | 54 | (defmethod todo-actions 55 | ::update-title [state [_ new-title]] 56 | (assoc state :title new-title)) 57 | 58 | (defmethod todo-actions 59 | ::reset [state [_ initial-title]] 60 | (init-state initial-title)) 61 | 62 | (defnc TodoItem 63 | [{:keys [id title completed? on-toggle on-destroy on-update-title]}] 64 | (let [initial-title title 65 | [{:keys [editing? 66 | title]} dispatch] (hooks/use-reducer 67 | todo-actions 68 | initial-title 69 | init-state) 70 | input-ref (hooks/use-ref nil) 71 | focus-input #(when-let [current (.-current input-ref)] 72 | (.focus current))] 73 | (hooks/use-layout-effect 74 | :auto-deps 75 | (when editing? 76 | (focus-input))) 77 | (d/li 78 | {:class (cond 79 | editing? "editing" 80 | completed? "completed")} 81 | (d/input 82 | {:class "edit" 83 | :value title 84 | :on-change #(dispatch [::update-title (.. % -target -value)]) 85 | :ref input-ref 86 | :on-key-down #(cond 87 | (and (enter-key? %) 88 | (= (.. % -target -value) "")) (on-destroy id) 89 | (enter-key? %) (do (on-update-title id title) 90 | (dispatch [::stop-editing])) 91 | (escape-key? %) (do (dispatch [::reset initial-title]))) 92 | :on-blur #(when editing? 93 | (on-update-title id title) 94 | (dispatch [::stop-editing]))}) 95 | (d/div 96 | {:class "view"} 97 | (d/input 98 | {:class "toggle" 99 | :type "checkbox" 100 | :checked completed? 101 | :on-change #(on-toggle id)}) 102 | (d/label {:on-double-click #(dispatch [::start-editing])} title) 103 | (d/button 104 | {:class "destroy" 105 | :on-click #(on-destroy id)}))))) 106 | -------------------------------------------------------------------------------- /src/todo_mvc/core.cljs: -------------------------------------------------------------------------------- 1 | (ns todo-mvc.core 2 | (:require 3 | [clojure.string :as string] 4 | [helix.core :as hx :refer [$ <>]] 5 | [helix.dom :as d] 6 | [helix.hooks :as hooks] 7 | [todo-mvc.components :as c] 8 | [todo-mvc.lib :refer [defnc]] 9 | [todo-mvc.storage :as storage] 10 | ["react-dom/client" :as rdom] 11 | ["react-router-dom" :as rr])) 12 | 13 | (defn todo [id title] 14 | {:id id 15 | :title title 16 | :completed? false}) 17 | 18 | (defn all-complete? [todos] 19 | (every? :completed? todos)) 20 | 21 | (defmulti todo-actions (fn [_ action] (first action))) 22 | 23 | (defmethod todo-actions 24 | ::init [state _] 25 | ;; initialize with empty vector if nothing in local storage 26 | (or state [])) 27 | 28 | (defmethod todo-actions 29 | ::add [todos [_ title]] 30 | (conj todos (todo (random-uuid) title))) 31 | 32 | (defmethod todo-actions 33 | ::remove [todos [_ id]] 34 | (into [] (remove #(= (:id %) id)) todos)) 35 | 36 | (defmethod todo-actions 37 | ::toggle [todos [_ id]] 38 | (into 39 | [] 40 | (map 41 | #(if (= (:id %) id) 42 | (update % :completed? not) 43 | %)) 44 | todos)) 45 | 46 | (defmethod todo-actions 47 | ::update-title [todos [_ id title]] 48 | (into 49 | [] 50 | (map 51 | #(if (= (:id %) id) 52 | (assoc % :title (string/trim title)) 53 | %)) 54 | todos)) 55 | 56 | (defmethod todo-actions 57 | ::toggle-all [todos _] 58 | (let [all-complete? (all-complete? todos)] 59 | (into [] (map #(assoc % :completed? (not all-complete?))) todos))) 60 | 61 | (defmethod todo-actions 62 | ::clear-completed [todos _] 63 | (filterv (comp not :completed?) todos)) 64 | 65 | (defnc App 66 | [] 67 | (let [[todos dispatch] (storage/use-persisted-reducer 68 | "todos-helix" 69 | todo-actions 70 | nil 71 | #(todo-actions % [::init])) 72 | active-todos (filter (comp not :completed?) todos) 73 | completed-todos (filter :completed? todos) 74 | 75 | ;; TodoList handlers 76 | add-todo #(dispatch [::add (string/trim %)]) 77 | remove-todo #(dispatch [::remove %]) 78 | toggle-todo #(dispatch [::toggle %]) 79 | update-todo-title (fn [id title] 80 | (dispatch [::update-title id title])) 81 | toggle-all #(dispatch [::toggle-all]) 82 | clear-completed #(dispatch [::clear-completed]) 83 | 84 | todo-list (fn [visible-todos] 85 | (for [{:keys [id] :as todo} visible-todos] 86 | (c/todo-item {:key id 87 | :on-toggle toggle-todo 88 | :on-destroy remove-todo 89 | :on-update-title update-todo-title 90 | & todo})))] 91 | ($ rr/BrowserRouter 92 | (d/div 93 | (d/section 94 | {:class "todoapp"} 95 | (d/header 96 | {:class "header"} 97 | (c/title) 98 | (c/new-todo {:on-complete add-todo})) 99 | (when (< 0 (count todos)) 100 | (<> 101 | (d/section 102 | {:class "main"} 103 | (d/input {:id "toggle-all" :class "toggle-all" :type "checkbox" 104 | :checked (all-complete? todos) :on-change toggle-all}) 105 | (d/label {:for "toggle-all"} "Mark all as complete") 106 | (d/ul 107 | {:class "todo-list"} 108 | ($ rr/Switch 109 | ($ rr/Route {:path "/active"} 110 | (todo-list active-todos)) 111 | ($ rr/Route {:path "/completed"} 112 | (todo-list completed-todos)) 113 | ($ rr/Route {:path "/"} 114 | (todo-list todos))))) 115 | (d/footer 116 | {:class "footer"} 117 | (d/span 118 | {:class "todo-count"} 119 | (d/strong (count active-todos)) 120 | " items left") 121 | (d/ul 122 | {:class "filters"} 123 | (d/li ($ rr/NavLink {:to "/" :activeClassName "selected" :exact true} "All")) 124 | (d/li ($ rr/NavLink {:to "/active" :activeClassName "selected"} "Active")) 125 | (d/li ($ rr/NavLink {:to "/completed" :activeClassName "selected"} "Completed"))) 126 | (d/button {:class "clear-completed" 127 | :on-click clear-completed} "Clear completed"))))) 128 | (c/app-footer))))) 129 | 130 | (defonce root (rdom/createRoot (js/document.getElementById "app"))) 131 | 132 | (defn ^:export start 133 | [] 134 | (.render root ($ App))) 135 | --------------------------------------------------------------------------------