@@ -1055,6 +1055,307 @@ defmodule ComponentsGuideWeb.ReactEditorController do
1055
1055
render_source ( conn , source )
1056
1056
end
1057
1057
1058
+ def show ( conn , % { "id" => "todo-list-reducer" } ) do
1059
+ source = ~s"""
1060
+ function TodoItem({ item }) {
1061
+ const domID = useId();
1062
+ const descriptionID = `${domID}-description`;
1063
+ const completedID = `${domID}-completed`;
1064
+
1065
+ return <fieldset class="flex items-center gap-3 py-2" data-id={item.id}>
1066
+ <input name="completed[]" value={item.id} type="checkbox" checked={item.completed} id={completedID} class="w-5 h-5 text-purple-700 rounded" />
1067
+ <label for={descriptionID} class="sr-only">Description</label>
1068
+ <input name="description[]" id={descriptionID} type="text" value={item.description} class="flex-1 rounded" />
1069
+ </fieldset>
1070
+ }
1071
+
1072
+ const initialState = {
1073
+ values: {
1074
+ focusID: undefined,
1075
+ items: [
1076
+ {
1077
+ id: crypto.randomUUID(),
1078
+ description: "File taxes",
1079
+ completed: false,
1080
+ },
1081
+ ],
1082
+ },
1083
+ errors: {},
1084
+ }
1085
+
1086
+ function TodoList() {
1087
+ const [{ values, errors }, dispatch] = useReducer(reducer, initialState)
1088
+
1089
+ useLayoutEffect(() => {
1090
+ if (!values.focusID) return;
1091
+
1092
+ const el = document.querySelector(`[data-id="${values.focusID}"] input[type=text]`);
1093
+ el?.focus();
1094
+ }, [values.focusID]);
1095
+
1096
+ return <form className="w-full max-w-[40rem] mx-auto pb-16" onSubmit={(event) => {
1097
+ event.preventDefault()
1098
+ dispatch(event)
1099
+ }}>
1100
+ <div onChange={dispatch}>
1101
+ {values.items.map((item, index) => (
1102
+ <>
1103
+ <TodoItem
1104
+ key={item.id}
1105
+ index={index}
1106
+ item={item}
1107
+ dispatch={dispatch}
1108
+ />
1109
+ </>
1110
+ ))}
1111
+ </div>
1112
+ <div className="my-4" />
1113
+ <button
1114
+ type="button"
1115
+ className="py-1 px-3 text-violet-50 bg-violet-800 rounded"
1116
+ onClick={dispatch}
1117
+ data-action="addItem"
1118
+ >Add item</button>
1119
+ <pre class="mt-8">{JSON.stringify(values, null, 2)}</pre>
1120
+ </form>
1121
+ }
1122
+
1123
+ function changed(state, event) {
1124
+ const { form } = event.target
1125
+ if (!form) {
1126
+ return
1127
+ }
1128
+
1129
+ const formData = new FormData(form)
1130
+ const descriptions = formData.getAll("description[]").map(String)
1131
+ const completeds = new Set(formData.getAll("completed[]").map(String))
1132
+
1133
+ for (const [index, item] of state.items.entries()) {
1134
+ item.description = descriptions[index]
1135
+ item.completed = completeds.has(item.id)
1136
+ }
1137
+ }
1138
+
1139
+ function clicked(state, event) {
1140
+ // event.currentTarget should be the button. The first click it is, but the clicks after are null. Not sure why.
1141
+
1142
+ if (!(event.target instanceof Element)) {
1143
+ return
1144
+ }
1145
+ const button = event.target.closest("button")
1146
+ if (!button) {
1147
+ return
1148
+ }
1149
+
1150
+ const {
1151
+ dataset: { action, payload },
1152
+ } = button
1153
+
1154
+ if (action === "addItem") {
1155
+ const id = crypto.randomUUID()
1156
+ state.items.push({
1157
+ id,
1158
+ description: "",
1159
+ completed: false,
1160
+ })
1161
+ state.focusID = id
1162
+ } else if (action === "removeItem") {
1163
+ const { id } = JSON.parse(payload) // TODO: use Zod?
1164
+ const index = state.items.findIndex((q) => q.id === id)
1165
+ state.items.splice(index, 1)
1166
+ }
1167
+ }
1168
+
1169
+ function reducer(state, event) {
1170
+ state = structuredClone(state)
1171
+
1172
+ if (isInputChangeEvent(event)) {
1173
+ changed(state.values, event)
1174
+ } else if (isClickEvent(event)) {
1175
+ clicked(state.values, event)
1176
+ } else if (isSubmitEvent(event)) {
1177
+ state.errors = validate(state.values)
1178
+ }
1179
+
1180
+ return state
1181
+ }
1182
+
1183
+ export function isInputChangeEvent(event) {
1184
+ return event.type === "change" && event.target instanceof HTMLInputElement
1185
+ }
1186
+
1187
+ export function isClickEvent(event) {
1188
+ return event.type === "click"
1189
+ }
1190
+
1191
+ export function isSubmitEvent(event) {
1192
+ return event.type === "submit"
1193
+ }
1194
+
1195
+ export default function App() {
1196
+ return <TodoList />;
1197
+ }
1198
+ """
1199
+
1200
+ render_source ( conn , source )
1201
+ end
1202
+
1203
+ def show ( conn , % { "id" => "todo-list-reducer-revisions" } ) do
1204
+ source = ~s"""
1205
+ function TodoItem({ item }) {
1206
+ const domID = useId();
1207
+ const descriptionID = `${domID}-description`;
1208
+ const completedID = `${domID}-completed`;
1209
+
1210
+ return <fieldset class="flex items-center gap-3 py-2 after:content-[attr(data-revision)]" data-id={item.id}>
1211
+ <input name="completed[]" value={item.id} type="checkbox" checked={item.completed} id={completedID} class="w-5 h-5 text-purple-700 rounded" />
1212
+ <label for={descriptionID} class="sr-only">Description</label>
1213
+ <input name="description[]" id={descriptionID} type="text" value={item.description} class="flex-1 rounded" />
1214
+ </fieldset>
1215
+ }
1216
+
1217
+ const initialState = {
1218
+ values: {
1219
+ focusID: undefined,
1220
+ items: [
1221
+ {
1222
+ id: crypto.randomUUID(),
1223
+ description: "File taxes",
1224
+ completed: false,
1225
+ },
1226
+ ],
1227
+ },
1228
+ errors: {},
1229
+ }
1230
+
1231
+ function TodoList() {
1232
+ const [{ values, errors }, dispatch] = useReducer(reducer, initialState)
1233
+
1234
+ useLayoutEffect(() => {
1235
+ if (!values.focusID) return;
1236
+
1237
+ const el = document.querySelector(`[data-id="${values.focusID}"] input[type=text]`);
1238
+ el?.focus();
1239
+ }, [values.focusID]);
1240
+
1241
+ return <form className="w-full max-w-[40rem] mx-auto pb-16" onSubmit={(event) => {
1242
+ event.preventDefault()
1243
+ dispatch(event)
1244
+ }}>
1245
+ <div onChange={dispatch}>
1246
+ {values.items.map((item, index) => (
1247
+ <>
1248
+ <TodoItem
1249
+ key={item.id}
1250
+ index={index}
1251
+ item={item}
1252
+ dispatch={dispatch}
1253
+ />
1254
+ </>
1255
+ ))}
1256
+ </div>
1257
+ <div className="my-4" />
1258
+ <button
1259
+ type="button"
1260
+ className="py-1 px-3 text-violet-50 bg-violet-800 rounded"
1261
+ onClick={dispatch}
1262
+ data-action="addItem"
1263
+ >Add item</button>
1264
+ <pre class="mt-8">{JSON.stringify(values, null, 2)}</pre>
1265
+ </form>;
1266
+ }
1267
+
1268
+ function elChanged(el) {
1269
+ const newRevision = parseInt(el.dataset.revision ?? "0") + 1;
1270
+ el.dataset.revision = newRevision;
1271
+ return newRevision;
1272
+ }
1273
+
1274
+ function changed(state, event) {
1275
+ const input = event.target;
1276
+ const { form, dataset } = input;
1277
+ if (!form) {
1278
+ return;
1279
+ }
1280
+
1281
+ const formRevision = elChanged(form);
1282
+ dataset.revision = formRevision;
1283
+ input.closest('fieldset').dataset.revision = formRevision;
1284
+
1285
+ const formData = new FormData(form);
1286
+ const descriptions = formData.getAll("description[]").map(String);
1287
+ const completeds = new Set(formData.getAll("completed[]").map(String));
1288
+
1289
+ for (const [index, item] of state.items.entries()) {
1290
+ item.description = descriptions[index];
1291
+ item.completed = completeds.has(item.id);
1292
+ }
1293
+ }
1294
+
1295
+ function clicked(state, event) {
1296
+ // event.currentTarget should be the button. The first click it is, but the clicks after are null. Not sure why.
1297
+
1298
+ if (!(event.target instanceof Element)) {
1299
+ return
1300
+ }
1301
+ const button = event.target.closest("button")
1302
+ if (!button) {
1303
+ return
1304
+ }
1305
+
1306
+ const {
1307
+ dataset: { action, payload },
1308
+ } = button
1309
+
1310
+ if (action === "addItem") {
1311
+ const id = crypto.randomUUID()
1312
+ state.items.push({
1313
+ id,
1314
+ description: "",
1315
+ completed: false,
1316
+ })
1317
+ state.focusID = id
1318
+ } else if (action === "removeItem") {
1319
+ const { id } = JSON.parse(payload) // TODO: use Zod?
1320
+ const index = state.items.findIndex((q) => q.id === id)
1321
+ state.items.splice(index, 1)
1322
+ }
1323
+ }
1324
+
1325
+ function reducer(state, event) {
1326
+ state = structuredClone(state)
1327
+
1328
+ if (isInputChangeEvent(event)) {
1329
+ changed(state.values, event)
1330
+ } else if (isClickEvent(event)) {
1331
+ clicked(state.values, event)
1332
+ } else if (isSubmitEvent(event)) {
1333
+ state.errors = validate(state.values)
1334
+ }
1335
+
1336
+ return state
1337
+ }
1338
+
1339
+ export function isInputChangeEvent(event) {
1340
+ return event.type === "change" && event.target instanceof HTMLInputElement
1341
+ }
1342
+
1343
+ export function isClickEvent(event) {
1344
+ return event.type === "click"
1345
+ }
1346
+
1347
+ export function isSubmitEvent(event) {
1348
+ return event.type === "submit"
1349
+ }
1350
+
1351
+ export default function App() {
1352
+ return <TodoList />;
1353
+ }
1354
+ """
1355
+
1356
+ render_source ( conn , source )
1357
+ end
1358
+
1058
1359
def show ( conn , params ) do
1059
1360
source = ~s"""
1060
1361
export default function App() {
0 commit comments