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:
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>