todo-next

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

commit f25d5adaa6842e936fdaeed232463cad1e3234aa
parent ff25257f46cb45a8ea496adb2089774c306abb50
Author: massi <git@massi.world>
Date:   Sun, 13 Apr 2025 12:54:10 -0700

slew of changes

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

diff --git a/src/app/todo-list/[title]/page.tsx b/src/app/todo-list/[title]/page.tsx @@ -5,77 +5,104 @@ import { InputHTMLAttributes, KeyboardEventHandler, ReactEventHandler, + Ref, + RefAttributes, + RefObject, useEffect, + useRef, useState, } from "react"; import { useLocalStorage } from "@/lib/useLocalStorage"; import { useParams, useRouter } from "next/navigation"; +import { accumulateMetadata } from "next/dist/lib/metadata/resolve-metadata"; -const MAX_TASK_LENGTH = 32; +const MAX_TASK_LENGTH = 128; + +function usePrevious<T>(val: T, initial: T) { + const valRef = useRef(initial); + useEffect(() => { + valRef.current = val; + }, [val]); + return valRef.current; +} type TodoItem = { description: string; isDone: boolean; + id: string; +}; + +const genId = () => { + try { + return crypto.randomUUID(); + } catch (e) { + return String(Math.random()); + } }; function mkTodo(description: string): TodoItem { return { description, isDone: false, + id: genId(), }; } type CheckboxProps = { isChecked: boolean; onToggle: () => void; + idx: number; }; -function Checkbox(props: CheckboxProps) { - const { isChecked, onToggle } = props; - return ( - <div - className="cursor-pointer w-[28px] h-[28px] bg-gray-800 relative leading-none hover:bg-gray-700 select-none" - onClick={onToggle} - > - {isChecked ? "✅" : ""} - </div> - ); -} - -type TodoInputProps = { - onEnter: (desc: string) => void; -}; -function TodoInput(props: TodoInputProps) { - const [desc, setDesc] = useState<string>(""); - const { onEnter } = props; - const addItem = () => { - if (desc.length) { - onEnter(desc); - setDesc(() => ""); - } - }; - const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { +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) => { switch (evt.key) { - case "Enter": { + case "ArrowUp": { + evt.preventDefault(); + const prevEl = document.querySelector( + `[data-${dataAttrName}="${idx - 1}"]`, + ) as HTMLDivElement | null; + prevEl?.focus(); + + break; + } + case "ArrowDown": { evt.preventDefault(); - addItem(); + const prevEl = document.querySelector( + `[data-${dataAttrName}="${idx + 1}"]`, + ) as HTMLDivElement | null; + prevEl?.focus(); break; } } }; + useEffect(() => { + ref.current?.setAttribute(`data-${dataAttrName}`, String(idx)); + }, [idx]); + + return [ref, onKeyDown]; +} + +function Checkbox(props: CheckboxProps) { + const { isChecked, onToggle, idx } = props; + const [btnRef, onKeyDown] = useListFocusNav<HTMLButtonElement>( + "checkbox-idx", + idx, + ); return ( - <div className="text-2xl p-2 mx-4"> - <input - value={desc} - onChange={(e) => setDesc(e.target.value)} - maxLength={MAX_TASK_LENGTH} - type="text" - onKeyDown={handleKeyPress} - placeholder="new todo goes here" - ></input> - <button onClick={addItem}>add</button> - </div> + <button + ref={btnRef} + className="cursor-pointer bg-gray-800 hover:bg-gray-700 select-none text-xl" + onClick={onToggle} + onKeyDown={onKeyDown} + > + <div className={isChecked ? "" : "invisible"}>✅</div> + </button> ); } @@ -84,31 +111,123 @@ 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; + idx: number; }; function TodoItem(props: TodoItemProps) { - const { onDelete, onToggleDone, todo, updateDescription } = props; + const { + onDelete, + onToggleDone, + todo, + updateDescription, + goto, + inputRef, + inputPosition, + addItem, + deleteItem, + setInputPosition, + idx, + } = props; + + const [xRef, xOnKeyDown] = useListFocusNav<HTMLButtonElement>("x-idx", idx); + + const initialDescription = useRef(todo.description); + const commitChange = () => { + initialDescription.current = todo.description; + }; + useEffect(() => { + initialDescription.current = todo.description; + }, []); + + 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"); + 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) { + goto(1, "start"); + } + break; + } + case "ArrowLeft": { + console.debug("RIGHT"); + if (inputPosition() === 0) { + 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; + } + } + }; return ( - <div className="group"> - <div className="text-2xl flex place-content-between p-2 mx-4 relative border-t-1 border-gray-600"> - <button - onClick={onDelete} - className="absolute left-[-10px] items-center top-0 cursor-pointer bottom-0 text-red-700 hidden group-hover:flex" - > - x - </button> + <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} /> <input + className="grow" + placeholder={"new task"} value={todo.description} onChange={(e) => updateDescription(e.target.value)} maxLength={MAX_TASK_LENGTH} + onBlur={() => { + commitChange(); + }} + onFocus={commitChange} + onKeyDown={handleKeyPress} + ref={inputRef} ></input> - <Checkbox isChecked={todo.isDone} onToggle={onToggleDone} /> + {todo.id} + <button + ref={xRef} + onKeyDown={xOnKeyDown} + onClick={onDelete} + className="items-center top-0 cursor-pointer bottom-0 text-red-700" + > + ❌ + </button> </div> </div> ); } type TitleProps = { title: string; rename: (newTitle: string) => boolean }; + function Title(props: TitleProps) { const { title, rename } = props; const [editingTitle, setEditingTitle] = useState<null | string>(null); @@ -117,20 +236,30 @@ function Title(props: TitleProps) { case "Enter": { evt.preventDefault(); if (editingTitle) { - rename(editingTitle); + const success = rename(editingTitle); + if (!success) setEditingTitle(null); } break; } + case "Escape": { + setEditingTitle(null); + } } }; return ( <h1 className="text-4xl"> - TODO:{" "} <input + className="text-center" type="text" value={editingTitle || title} onChange={(evt) => setEditingTitle(evt.target.value)} onKeyDown={handleKeyPress} + onBlur={() => { + if (editingTitle) { + const success = rename(editingTitle); + if (!success) setEditingTitle(null); + } + }} ></input> </h1> ); @@ -138,32 +267,59 @@ function Title(props: TitleProps) { export default function TodoList() { const router = useRouter(); - const { title } = useParams<{ title: string }>(); + const { title: _title } = useParams<{ title: string }>(); + const title = decodeURIComponent(_title); const [items, setItems] = useLocalStorage<TodoItem[]>(title, []); + const itemRefs = useRef<(HTMLInputElement | null)[]>([]); const renameTitle = (newTitle: string) => { const dataAtNewTitle = !!localStorage.getItem(newTitle); if (dataAtNewTitle) { - alert( - "AAAAAAAAAAAAAA THERES ALREADY A TODO LIST WITH THIS NAME AAAAAAAAAA CHANGE IT AAAAAAAAAAAAAAAAAAA", - ); + alert("A todo list with that name already exists!"); + return false; } else { localStorage.removeItem(title); localStorage.setItem(newTitle, JSON.stringify(items)); - router.replace(`/todo-list/${newTitle}`); + router.replace(`/todo-list/${encodeURIComponent(newTitle)}`); + return true; } }; + 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); + }; + useEffect(() => { + if (!items.length) { + setItems([mkTodo("")]); + } + }, [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); + } + } + } + }, [items]); return ( - <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-16 font-[family-name:var(--font-geist-sans)]"> + <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-16 font-[monospace]"> <header> <Title title={title} rename={renameTitle} /> </header> <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start w-full"> <div className="w-[100%]"> - <TodoInput - onEnter={(description: string) => - setItems((items) => [mkTodo(description), ...items]) - } - /> {!items.length ? "all done :)" : items.map((todo, idx) => { @@ -192,7 +348,56 @@ export default function TodoList() { ), ) } - key={idx} + 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, + ); + } + }} + 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; + }); + }} /> ); })}