page.tsx (13159B)
1 "use client"; 2 import { 3 KeyboardEvent, 4 KeyboardEventHandler, 5 Ref, 6 RefObject, 7 useEffect, 8 useRef, 9 useState, 10 } from "react"; 11 12 import { useLocalStorage } from "@/lib/useLocalStorage"; 13 import { useParams, useRouter } from "next/navigation"; 14 15 const MAX_TASK_LENGTH = 128; 16 17 const genId = () => { 18 try { 19 return crypto.randomUUID(); 20 } catch (e) { 21 return String(Math.random()); // good enough for being unique on a single local list 22 } 23 }; 24 25 type TodoItem = { 26 description: string; 27 isDone: boolean; 28 id: string; 29 }; 30 31 function mkTodo(description: string = ""): TodoItem { 32 return { 33 description, 34 isDone: false, 35 id: genId(), 36 }; 37 } 38 39 // helper hooks 40 41 function useListFocuser<T extends HTMLElement>(): [ 42 RefObject<(T | null)[]>, 43 (idx: number, evt: KeyboardEvent<T>) => void, 44 ] { 45 const listRef = useRef<(T | null)[]>([]); 46 47 const onKeyDown = (idx: number, evt: KeyboardEvent) => { 48 switch (evt.key) { 49 case "ArrowUp": { 50 if (idx > 0) { 51 evt.preventDefault(); 52 listRef.current[idx - 1]?.focus(); 53 } 54 break; 55 } 56 case "ArrowDown": { 57 if (idx < listRef.current.length - 1) { 58 evt.preventDefault(); 59 listRef.current[idx + 1]?.focus(); 60 } 61 break; 62 } 63 } 64 }; 65 66 return [listRef, onKeyDown]; 67 } 68 69 // components 70 71 type CheckboxProps = { 72 isChecked: boolean; 73 onToggle: () => void; 74 idx: number; 75 ref: Ref<HTMLButtonElement>; 76 onKeyDown: KeyboardEventHandler<HTMLButtonElement>; 77 }; 78 79 function Checkbox(props: CheckboxProps) { 80 const { isChecked, onToggle, idx, ref, onKeyDown } = props; 81 return ( 82 <button 83 ref={ref} 84 className={` rounded-sm cursor-pointer ${!isChecked ? "bg-gray-300 hover:bg-gray-400 dark:hover:bg-gray-700 dark:bg-gray-800 " : ""} select-none text-xl aspect-square w-[24px] h-[24px] leading-0`} 85 onClick={onToggle} 86 onKeyDown={onKeyDown} 87 > 88 {isChecked ? "✅" : ""} 89 </button> 90 ); 91 } 92 93 type TodoItemProps = { 94 onDelete: () => void; 95 onToggleDone: () => void; 96 updateDescription: (description: string) => void; 97 todo: TodoItem; 98 inputRef?: (ref: HTMLInputElement | null) => void; 99 idx: number; 100 onInputKeyPress: KeyboardEventHandler<HTMLInputElement>; 101 xRef: (ref: HTMLButtonElement | null) => void; 102 xOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; 103 checkRef: (ref: HTMLButtonElement | null) => void; 104 checkOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; 105 }; 106 107 function TodoItem(props: TodoItemProps) { 108 const { 109 onDelete, 110 onToggleDone, 111 todo, 112 updateDescription, 113 inputRef, 114 idx, 115 onInputKeyPress, 116 xRef, 117 xOnKeyDown, 118 checkRef, 119 checkOnKeyDown, 120 } = props; 121 122 const initialDescription = useRef(todo.description); 123 const commitChange = () => { 124 initialDescription.current = todo.description; 125 }; 126 useEffect(() => { 127 initialDescription.current = todo.description; 128 }, []); 129 130 const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { 131 switch (evt.key) { 132 case "Enter": { 133 if (todo.description.length) commitChange(); 134 break; 135 } 136 case "Escape": { 137 updateDescription(initialDescription.current); 138 break; 139 } 140 } 141 onInputKeyPress(evt); 142 }; 143 return ( 144 <div className="group [&:not(:last-child)>*]:border-b-1 flex flex-col"> 145 <div className="text-md gap-2 flex place-content-between p-1 mx-4 relative border-gray-300 dark:border-gray-700"> 146 <Checkbox 147 ref={checkRef} 148 onKeyDown={checkOnKeyDown} 149 isChecked={todo.isDone} 150 onToggle={onToggleDone} 151 idx={idx} 152 /> 153 <input 154 className="grow" 155 placeholder={"new task"} 156 value={todo.description} 157 onChange={(e) => updateDescription(e.target.value)} 158 maxLength={MAX_TASK_LENGTH} 159 onBlur={() => { 160 commitChange(); 161 }} 162 onFocus={commitChange} 163 onKeyDown={handleKeyPress} 164 ref={inputRef} 165 ></input> 166 <button 167 ref={xRef} 168 onKeyDown={xOnKeyDown} 169 onClick={onDelete} 170 className="items-center top-0 cursor-pointer bottom-0 px-1" 171 > 172 ❌ 173 </button> 174 </div> 175 </div> 176 ); 177 } 178 179 type TitleProps = { title: string; rename: (newTitle: string) => boolean }; 180 181 function Title(props: TitleProps) { 182 const { title, rename } = props; 183 const [editingTitle, setEditingTitle] = useState<null | string>(null); 184 const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { 185 switch (evt.key) { 186 case "Enter": { 187 evt.preventDefault(); 188 if (editingTitle) { 189 const success = rename(editingTitle); 190 if (!success) setEditingTitle(null); 191 } 192 break; 193 } 194 case "Escape": { 195 setEditingTitle(null); 196 } 197 } 198 }; 199 return ( 200 <h1 className="text-3xl p-3"> 201 <input 202 className="text-center w-full" 203 type="text" 204 value={editingTitle === null ? title : editingTitle} 205 onChange={(evt) => setEditingTitle(evt.target.value)} 206 onKeyDown={handleKeyPress} 207 onBlur={() => { 208 if (editingTitle) { 209 const success = rename(editingTitle); 210 if (!success) setEditingTitle(null); 211 } 212 }} 213 ></input> 214 </h1> 215 ); 216 } 217 218 export default function TodoList() { 219 const router = useRouter(); 220 const { title: _title } = useParams<{ title: string }>(); 221 const title = _title ? decodeURIComponent(_title) : "Tasks"; 222 const [items, setItems] = useLocalStorage<TodoItem[]>(title, [mkTodo()]); 223 const todoDescriptionRefs = useRef<(HTMLInputElement | null)[]>([]); 224 const renameTitle = (newTitle: string) => { 225 if (title === newTitle) return false; 226 const dataAtNewTitle = !!localStorage.getItem(newTitle); 227 if (dataAtNewTitle) { 228 alert( 229 "A task list with that name already exists! Pick another name. If you want to see that list, navigate to it via the URL.", 230 ); 231 return false; 232 } else { 233 localStorage.removeItem(title); 234 localStorage.setItem(newTitle, JSON.stringify(items)); 235 router.replace(`/${encodeURIComponent(newTitle)}`); 236 return true; 237 } 238 }; 239 240 const [xRef, xOnKeyDown] = useListFocuser<HTMLButtonElement>(); 241 const [checkRef, checkOnKeyDown] = useListFocuser<HTMLButtonElement>(); 242 243 const focusInput = (idx: number) => { 244 const el = todoDescriptionRefs.current[idx]; 245 if (el) el.focus(); 246 }; 247 248 const autoFocusInputIdx = useRef<number | null>(null); 249 250 useEffect(() => { 251 if (autoFocusInputIdx.current !== null) { 252 focusInput(autoFocusInputIdx.current); 253 autoFocusInputIdx.current = null; 254 } 255 }, [items]); 256 257 const addTodo = (idx: number) => { 258 setItems((items) => { 259 const newItems = [...items]; 260 newItems.splice(idx, 0, mkTodo()); 261 return newItems; 262 }); 263 autoFocusInputIdx.current = idx; 264 }; 265 266 const deleteTodo = (idx: number) => { 267 setItems((items) => { 268 const newItems = items.filter((item, iidx) => idx !== iidx); 269 if (!newItems.length) { 270 newItems.push(mkTodo("")); 271 } 272 return newItems; 273 }); 274 }; 275 276 const gotoTodo = ( 277 idx: number, 278 deltaIdx: number, 279 selectionPosition: "start" | "end" | number, 280 ) => { 281 const gotoIdx = idx + deltaIdx; 282 if (gotoIdx < 0) { 283 todoDescriptionRefs.current[idx]?.setSelectionRange(0, 0); 284 } else if (gotoIdx >= items.length) { 285 const lastDescLength = items.at(-1)?.description.length || 0; 286 todoDescriptionRefs.current 287 .at(-1) 288 ?.setSelectionRange(lastDescLength, lastDescLength); 289 } else { 290 focusInput(idx + deltaIdx); 291 const selection = 292 selectionPosition === "end" 293 ? items[idx + deltaIdx].description.length 294 : selectionPosition === "start" 295 ? 0 296 : selectionPosition; 297 todoDescriptionRefs.current[idx + deltaIdx]?.setSelectionRange( 298 selection, 299 selection, 300 ); 301 } 302 }; 303 304 useEffect(() => { 305 if (!items.length) { 306 setItems([mkTodo()]); 307 } 308 }, [items.length]); 309 310 return ( 311 <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-2 font-[monospace]"> 312 <header> 313 <Title title={title} rename={renameTitle} /> 314 </header> 315 <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start w-full"> 316 <div className="w-[100%] pb-[30dvh]"> 317 {items.map((todo, idx) => { 318 const inputPosition = () => 319 todoDescriptionRefs.current[idx]?.selectionStart; 320 return ( 321 <TodoItem 322 todo={todo} 323 onToggleDone={() => 324 setItems((items) => { 325 const newItems = [...items]; 326 newItems[idx] = { 327 ...items[idx], 328 isDone: !items[idx].isDone, 329 }; 330 return newItems; 331 }) 332 } 333 onDelete={() => { 334 const xIsFocused = 335 xRef.current[idx]?.matches(":focus-visible"); 336 setItems((items) => { 337 // to keep focus on the delete button, just reset the todo if it's the last one. 338 if (items.length === 1) { 339 const newItems = [...items]; 340 newItems[0].description = ""; 341 newItems[0].isDone = false; 342 return newItems; 343 } else { 344 return items.filter((item, iidx) => idx !== iidx); 345 } 346 }); 347 if (xIsFocused) { 348 (xRef.current[idx + 1] || xRef.current[idx - 1])?.focus(); 349 } 350 }} 351 updateDescription={(description: string) => 352 setItems((items) => 353 items.map((item, iidx) => 354 idx === iidx ? { ...item, description } : item, 355 ), 356 ) 357 } 358 key={todo.id} 359 idx={idx} 360 inputRef={(ref) => (todoDescriptionRefs.current[idx] = ref)} 361 onInputKeyPress={(evt) => { 362 switch (evt.key) { 363 case "Enter": { 364 const inputPos = 365 todoDescriptionRefs.current[idx]?.selectionStart || 0; 366 367 const multiselect = 368 todoDescriptionRefs.current[idx]?.selectionEnd || 369 0 > inputPos; 370 const pos = 371 evt.getModifierState("Shift") || 372 (inputPos === 0 && 373 todo.description.length && 374 !multiselect) 375 ? 0 376 : 1; 377 addTodo(idx + pos); 378 break; 379 } 380 case "Backspace": { 381 if (!todo.description.length && !evt.repeat) { 382 if (items.length === 1) { 383 break; 384 } 385 evt.preventDefault(); 386 const dir = idx === 0 ? 1 : -1; 387 // keep the element around while we go to the next todo 388 // prevents mobile keyboards from closing due to their 389 // focused element disappearing. 390 deleteTodo(idx); 391 gotoTodo(idx, dir, "end"); 392 } 393 break; 394 } 395 case "ArrowRight": { 396 if (inputPosition() === todo.description.length) { 397 evt.preventDefault(); 398 gotoTodo(idx, 1, "start"); 399 } 400 break; 401 } 402 case "ArrowLeft": { 403 if (inputPosition() === 0) { 404 evt.preventDefault(); 405 gotoTodo(idx, -1, "end"); 406 } 407 break; 408 } 409 case "ArrowDown": { 410 evt.preventDefault(); 411 gotoTodo(idx, 1, inputPosition() || "start"); 412 break; 413 } 414 case "ArrowUp": { 415 evt.preventDefault(); 416 gotoTodo(idx, -1, inputPosition() || "start"); 417 break; 418 } 419 } 420 }} 421 xRef={(ref) => { 422 xRef.current[idx] = ref; 423 }} 424 xOnKeyDown={(evt) => xOnKeyDown(idx, evt)} 425 checkRef={(ref) => { 426 checkRef.current[idx] = ref; 427 }} 428 checkOnKeyDown={(evt) => checkOnKeyDown(idx, evt)} 429 /> 430 ); 431 })} 432 </div> 433 </main> 434 </div> 435 ); 436 }