├── .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 | 
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 |
--------------------------------------------------------------------------------