commit f25d5adaa6842e936fdaeed232463cad1e3234aa
parent ff25257f46cb45a8ea496adb2089774c306abb50
Author: massi <git@massi.world>
Date: Sun, 13 Apr 2025 12:54:10 -0700
slew of changes
Diffstat:
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;
+ });
+ }}
/>
);
})}