todo-next

yet another task list webapp, made with Next.js
Log | Files | Refs | README

commit 0392642cf6fc3012aa8e434035cc41974c227851
parent e7508f5388b2c02b13a9ada35b190ae6c0ab0b57
Author: massi <git@massi.world>
Date:   Tue, 15 Apr 2025 14:44:00 -0700

make children components dumber

keyboard management dictated from above.
also clean up a lot of code smells like setTimeouts

Diffstat:
Msrc/app/todo-list/[title]/page.tsx | 424+++++++++++++++++++++++++++++++++++++++++--------------------------------------
1 file changed, 222 insertions(+), 202 deletions(-)

diff --git a/src/app/todo-list/[title]/page.tsx b/src/app/todo-list/[title]/page.tsx @@ -1,12 +1,8 @@ "use client"; import { - ChangeEvent, - HTMLInputTypeAttribute, - InputHTMLAttributes, + KeyboardEvent, KeyboardEventHandler, - ReactEventHandler, Ref, - RefAttributes, RefObject, useEffect, useRef, @@ -15,7 +11,6 @@ import { import { useLocalStorage } from "@/lib/useLocalStorage"; import { useParams, useRouter } from "next/navigation"; -import { accumulateMetadata } from "next/dist/lib/metadata/resolve-metadata"; const MAX_TASK_LENGTH = 128; @@ -33,7 +28,7 @@ type TodoItem = { id: string; }; -function mkTodo(description: string): TodoItem { +function mkTodo(description: string = ""): TodoItem { return { description, isDone: false, @@ -43,45 +38,32 @@ function mkTodo(description: string): TodoItem { // helper hooks -function usePrevious<T>(val: T, initial: T) { - const valRef = useRef(initial); - useEffect(() => { - valRef.current = val; - }, [val]); - return valRef.current; -} +function useListFocuser<T extends HTMLElement>(): [ + RefObject<(T | null)[]>, + (idx: number, evt: KeyboardEvent<T>) => void, +] { + const listRef = useRef<(T | null)[]>([]); -function useListFocusNav<T extends HTMLElement>( - dataAttrName: string, - idx: number, -): [RefObject<T | null>, KeyboardEventHandler<T>] { - const ref = useRef<T | null>(null); - const onKeyDown: KeyboardEventHandler<T> = (evt) => { + const onKeyDown = (idx: number, evt: KeyboardEvent) => { switch (evt.key) { case "ArrowUp": { - evt.preventDefault(); - const prevEl = document.querySelector( - `[data-${dataAttrName}="${idx - 1}"]`, - ) as HTMLDivElement | null; - prevEl?.focus(); - + if (idx > 0) { + evt.preventDefault(); + listRef.current[idx - 1]?.focus(); + } break; } case "ArrowDown": { - evt.preventDefault(); - const prevEl = document.querySelector( - `[data-${dataAttrName}="${idx + 1}"]`, - ) as HTMLDivElement | null; - prevEl?.focus(); + if (idx < listRef.current.length - 1) { + evt.preventDefault(); + listRef.current[idx + 1]?.focus(); + } break; } } }; - useEffect(() => { - ref.current?.setAttribute(`data-${dataAttrName}`, String(idx)); - }, [idx]); - return [ref, onKeyDown]; + return [listRef, onKeyDown]; } // components @@ -90,22 +72,20 @@ type CheckboxProps = { isChecked: boolean; onToggle: () => void; idx: number; + ref: Ref<HTMLButtonElement>; + onKeyDown: KeyboardEventHandler<HTMLButtonElement>; }; function Checkbox(props: CheckboxProps) { - const { isChecked, onToggle, idx } = props; - const [btnRef, onKeyDown] = useListFocusNav<HTMLButtonElement>( - "checkbox-idx", - idx, - ); + const { isChecked, onToggle, idx, ref, onKeyDown } = props; return ( <button - ref={btnRef} - className="cursor-pointer bg-gray-800 hover:bg-gray-700 select-none text-xl" + ref={ref} + className={`cursor-pointer ${!isChecked ? "bg-gray-800 hover:bg-gray-700 " : ""} select-none text-xl aspect-square w-[24px] h-[24px] leading-0`} onClick={onToggle} onKeyDown={onKeyDown} > - <div className={isChecked ? "" : "invisible"}>✅</div> + {isChecked ? "✅" : ""} </button> ); } @@ -115,13 +95,13 @@ type TodoItemProps = { onToggleDone: () => void; updateDescription: (description: string) => void; todo: TodoItem; - goto: (deltaIdx: number, selectionPosition: "start" | "end" | number) => void; - addItem: (deltaIdx: number, goto?: boolean, description?: string) => void; - inputRef?: Ref<HTMLInputElement>; - deleteItem: () => void; - inputPosition: () => number | undefined | null; - setInputPosition: (idx: number) => void; + inputRef?: (ref: HTMLInputElement | null) => void; idx: number; + onInputKeyPress: KeyboardEventHandler<HTMLInputElement>; + xRef: (ref: HTMLButtonElement | null) => void; + xOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; + checkRef: (ref: HTMLButtonElement | null) => void; + checkOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; }; function TodoItem(props: TodoItemProps) { @@ -130,17 +110,15 @@ function TodoItem(props: TodoItemProps) { onToggleDone, todo, updateDescription, - goto, inputRef, - inputPosition, - addItem, - deleteItem, - setInputPosition, idx, + onInputKeyPress, + xRef, + xOnKeyDown, + checkRef, + checkOnKeyDown, } = props; - const [xRef, xOnKeyDown] = useListFocusNav<HTMLButtonElement>("x-idx", idx); - const initialDescription = useRef(todo.description); const commitChange = () => { initialDescription.current = todo.description; @@ -152,58 +130,26 @@ function TodoItem(props: TodoItemProps) { const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { switch (evt.key) { case "Enter": { - if (!todo.description.length) break; - commitChange(); - const inputPos = inputPosition(); - const pos = evt.getModifierState("Shift") || inputPos === 0 ? 0 : 1; - addItem(pos, false); - // focus on next element? - // goto(pos, "start"); + if (todo.description.length) commitChange(); break; } case "Escape": { updateDescription(initialDescription.current); break; } - case "Backspace": { - if (!todo.description.length && !evt.repeat) { - evt.preventDefault(); - deleteItem(); - goto(-1, "end"); - } - break; - } - case "ArrowRight": { - if (inputPosition() === todo.description.length) { - evt.preventDefault(); - goto(1, "start"); - } - break; - } - case "ArrowLeft": { - if (inputPosition() === 0) { - evt.preventDefault(); - goto(-1, "end"); - } - break; - } - case "ArrowDown": { - evt.preventDefault(); - console.debug(inputPosition()); - goto(1, inputPosition() || "start"); - break; - } - case "ArrowUp": { - evt.preventDefault(); - goto(-1, inputPosition() || "start"); - break; - } } + onInputKeyPress(evt); }; return ( <div className="group [&:not(:last-child)>*]:border-b-1 flex flex-col"> - <div className="text-md gap-2 flex place-content-between p-1 mx-4 relative border-gray-600"> - <Checkbox isChecked={todo.isDone} onToggle={onToggleDone} idx={idx} /> + <div className="text-md gap-2 flex place-content-between p-1 mx-4 relative border-gray-300 dark:border-gray-700"> + <Checkbox + ref={checkRef} + onKeyDown={checkOnKeyDown} + isChecked={todo.isDone} + onToggle={onToggleDone} + idx={idx} + /> <input className="grow" placeholder={"new task"} @@ -217,12 +163,11 @@ function TodoItem(props: TodoItemProps) { onKeyDown={handleKeyPress} ref={inputRef} ></input> - {todo.id} <button ref={xRef} onKeyDown={xOnKeyDown} onClick={onDelete} - className="items-center top-0 cursor-pointer bottom-0 text-red-700" + className="items-center top-0 cursor-pointer bottom-0" > ❌ </button> @@ -274,8 +219,8 @@ export default function TodoList() { const router = useRouter(); const { title: _title } = useParams<{ title: string }>(); const title = decodeURIComponent(_title); - const [items, setItems] = useLocalStorage<TodoItem[]>(title, []); - const itemRefs = useRef<(HTMLInputElement | null)[]>([]); + const [items, setItems] = useLocalStorage<TodoItem[]>(title, [mkTodo()]); + const todoDescriptionRefs = useRef<(HTMLInputElement | null)[]>([]); const renameTitle = (newTitle: string) => { const dataAtNewTitle = !!localStorage.getItem(newTitle); if (dataAtNewTitle) { @@ -288,36 +233,77 @@ export default function TodoList() { return true; } }; + + const [xRef, xOnKeyDown] = useListFocuser<HTMLButtonElement>(); + const [checkRef, checkOnKeyDown] = useListFocuser<HTMLButtonElement>(); + const focusInput = (idx: number) => { - // add to event loop so a render can happen first. - setTimeout(() => { - const el = itemRefs.current[idx]; - if (el) el.focus(); - }, 0); + const el = todoDescriptionRefs.current[idx]; + if (el) el.focus(); }; + + const autoFocusInputIdx = useRef<number | null>(null); + useEffect(() => { - if (!items.length) { - setItems([mkTodo("")]); + if (autoFocusInputIdx.current !== null) { + focusInput(autoFocusInputIdx.current); + autoFocusInputIdx.current = null; } }, [items]); - // set initial state to items so the first render doesn't count as a change. - const prevTodos = usePrevious(items, items); - useEffect(() => { - if (items.length > prevTodos.length) { - const seenIds = prevTodos.reduce<{ [id in string]?: true }>( - (seen, todo) => { - seen[todo.id] = true; - return seen; - }, - {}, - ); - for (let i = 0, todo = items[i]; i < items.length; i++, todo = items[i]) { - if (!seenIds[todo.id]) { - focusInput(i); - } + + const addTodo = (idx: number) => { + setItems((items) => { + const newItems = [...items]; + newItems.splice(idx, 0, mkTodo()); + return newItems; + }); + autoFocusInputIdx.current = idx; + }; + + const deleteTodo = (idx: number) => { + setItems((items) => { + const newItems = items.filter((item, iidx) => idx !== iidx); + if (!newItems.length) { + newItems.push(mkTodo("")); } + return newItems; + }); + }; + + const gotoTodo = ( + idx: number, + deltaIdx: number, + selectionPosition: "start" | "end" | number, + ) => { + const gotoIdx = idx + deltaIdx; + if (gotoIdx < 0) { + todoDescriptionRefs.current[idx]?.setSelectionRange(0, 0); + } else if (gotoIdx >= items.length) { + const lastDescLength = items.at(-1)?.description.length || 0; + todoDescriptionRefs.current + .at(-1) + ?.setSelectionRange(lastDescLength, lastDescLength); + } else { + focusInput(idx + deltaIdx); + const selection = + selectionPosition === "end" + ? items[idx + deltaIdx].description.length + : selectionPosition === "start" + ? 0 + : selectionPosition; + todoDescriptionRefs.current[idx + deltaIdx]?.setSelectionRange( + selection, + selection, + ); } - }, [items]); + }; + + useEffect(() => { + if (!items.length) { + setItems([mkTodo()]); + } + }, [items.length]); + return ( <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-16 font-[monospace]"> <header> @@ -325,87 +311,121 @@ export default function TodoList() { </header> <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start w-full"> <div className="w-[100%]"> - {!items.length - ? "all done :)" - : items.map((todo, idx) => { - return ( - <TodoItem - todo={todo} - onToggleDone={() => - setItems((items) => { - const newItems = [...items]; - newItems[idx] = { - ...items[idx], - isDone: !items[idx].isDone, - }; - return newItems; - }) + {items.map((todo, idx) => { + const inputPosition = () => + todoDescriptionRefs.current[idx]?.selectionStart; + return ( + <TodoItem + todo={todo} + onToggleDone={() => + setItems((items) => { + const newItems = [...items]; + newItems[idx] = { + ...items[idx], + isDone: !items[idx].isDone, + }; + return newItems; + }) + } + onDelete={() => { + const xIsFocused = + xRef.current[idx]?.matches(":focus-visible"); + setItems((items) => { + // to keep focus on the delete button, just reset the todo if it's the last one. + if (items.length === 1) { + const newItems = [...items]; + newItems[0].description = ""; + newItems[0].isDone = false; + return newItems; + } else { + return items.filter((item, iidx) => idx !== iidx); + } + }); + if (xIsFocused) { + (xRef.current[idx + 1] || xRef.current[idx - 1])?.focus(); + } + }} + updateDescription={(description: string) => + setItems((items) => + items.map((item, iidx) => + idx === iidx ? { ...item, description } : item, + ), + ) + } + key={todo.id} + idx={idx} + inputRef={(ref) => (todoDescriptionRefs.current[idx] = ref)} + onInputKeyPress={(evt) => { + switch (evt.key) { + case "Enter": { + const inputPos = + todoDescriptionRefs.current[idx]?.selectionStart || 0; + + const multiselect = + todoDescriptionRefs.current[idx]?.selectionEnd || + 0 > inputPos; + const pos = + evt.getModifierState("Shift") || + (inputPos === 0 && + todo.description.length && + !multiselect) + ? 0 + : 1; + addTodo(idx + pos); + break; } - onDelete={() => - setItems((items) => - items.filter((item, iidx) => idx !== iidx), - ) + case "Backspace": { + if (!todo.description.length && !evt.repeat) { + if (items.length === 1) { + break; + } + evt.preventDefault(); + const dir = idx === 0 ? 1 : -1; + // keep the element around while we go to the next todo + // prevents mobile keyboards from closing due to their + // focused element disappearing. + deleteTodo(idx); + gotoTodo(idx, dir, "end"); + } + break; } - updateDescription={(description: string) => - setItems((items) => - items.map((item, iidx) => - idx === iidx ? { ...item, description } : item, - ), - ) + case "ArrowRight": { + if (inputPosition() === todo.description.length) { + evt.preventDefault(); + gotoTodo(idx, 1, "start"); + } + break; } - key={todo.id} - idx={idx} - inputRef={(ref) => (itemRefs.current[idx] = ref)} - inputPosition={() => itemRefs.current[idx]?.selectionStart} - setInputPosition={(iidx: number) => { - itemRefs.current[idx]?.setSelectionRange(iidx, 0); - }} - goto={(deltaIdx, selectionPosition) => { - const gotoIdx = idx + deltaIdx; - if (gotoIdx < 0) { - itemRefs.current[idx]?.setSelectionRange(0, 0); - } else if (gotoIdx >= items.length) { - const lastDescLength = - items.at(-1)?.description.length || 0; - console.debug({ lastDescLength }); - itemRefs.current - .at(-1) - ?.setSelectionRange(lastDescLength, lastDescLength); - } else { - focusInput(idx + deltaIdx); - const selection = - selectionPosition === "end" - ? items[idx + deltaIdx].description.length - : selectionPosition === "start" - ? 0 - : selectionPosition; - itemRefs.current[idx + deltaIdx]?.setSelectionRange( - selection, - selection, - ); + case "ArrowLeft": { + if (inputPosition() === 0) { + evt.preventDefault(); + gotoTodo(idx, -1, "end"); } - }} - addItem={(deltaIdx, goto = false, description = "") => { - setItems((items) => { - const newItems = [...items]; - newItems.splice(idx + deltaIdx, 0, mkTodo(description)); - return newItems; - }); - }} - deleteItem={() => { - setItems((items) => { - const newItems = items.filter( - (item, iidx) => idx !== iidx, - ); - if (!newItems.length) { - newItems.push(mkTodo("")); - } - return newItems; - }); - }} - /> - ); - })} + break; + } + case "ArrowDown": { + evt.preventDefault(); + gotoTodo(idx, 1, inputPosition() || "start"); + break; + } + case "ArrowUp": { + evt.preventDefault(); + gotoTodo(idx, -1, inputPosition() || "start"); + break; + } + } + }} + xRef={(ref) => { + xRef.current[idx] = ref; + }} + xOnKeyDown={(evt) => xOnKeyDown(idx, evt)} + checkRef={(ref) => { + checkRef.current[idx] = ref; + }} + checkOnKeyDown={(evt) => checkOnKeyDown(idx, evt)} + /> + ); + })} </div> </main> </div>