commit 7d60567044b92e1ba13f2d27b5f6e786aba36a55
parent 0392642cf6fc3012aa8e434035cc41974c227851
Author: massi <git@massi.world>
Date: Tue, 15 Apr 2025 15:26:12 -0700
title and url ops
Diffstat:
5 files changed, 438 insertions(+), 551 deletions(-)
diff --git a/src/app/[[...title]]/page.tsx b/src/app/[[...title]]/page.tsx
@@ -0,0 +1,436 @@
+"use client";
+import {
+ KeyboardEvent,
+ KeyboardEventHandler,
+ Ref,
+ RefObject,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+import { useLocalStorage } from "@/lib/useLocalStorage";
+import { useParams, useRouter } from "next/navigation";
+
+const MAX_TASK_LENGTH = 128;
+
+const genId = () => {
+ try {
+ return crypto.randomUUID();
+ } catch (e) {
+ return String(Math.random()); // good enough for being unique on a single local list
+ }
+};
+
+type TodoItem = {
+ description: string;
+ isDone: boolean;
+ id: string;
+};
+
+function mkTodo(description: string = ""): TodoItem {
+ return {
+ description,
+ isDone: false,
+ id: genId(),
+ };
+}
+
+// helper hooks
+
+function useListFocuser<T extends HTMLElement>(): [
+ RefObject<(T | null)[]>,
+ (idx: number, evt: KeyboardEvent<T>) => void,
+] {
+ const listRef = useRef<(T | null)[]>([]);
+
+ const onKeyDown = (idx: number, evt: KeyboardEvent) => {
+ switch (evt.key) {
+ case "ArrowUp": {
+ if (idx > 0) {
+ evt.preventDefault();
+ listRef.current[idx - 1]?.focus();
+ }
+ break;
+ }
+ case "ArrowDown": {
+ if (idx < listRef.current.length - 1) {
+ evt.preventDefault();
+ listRef.current[idx + 1]?.focus();
+ }
+ break;
+ }
+ }
+ };
+
+ return [listRef, onKeyDown];
+}
+
+// components
+
+type CheckboxProps = {
+ isChecked: boolean;
+ onToggle: () => void;
+ idx: number;
+ ref: Ref<HTMLButtonElement>;
+ onKeyDown: KeyboardEventHandler<HTMLButtonElement>;
+};
+
+function Checkbox(props: CheckboxProps) {
+ const { isChecked, onToggle, idx, ref, onKeyDown } = props;
+ return (
+ <button
+ ref={ref}
+ 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`}
+ onClick={onToggle}
+ onKeyDown={onKeyDown}
+ >
+ {isChecked ? "✅" : ""}
+ </button>
+ );
+}
+
+type TodoItemProps = {
+ onDelete: () => void;
+ onToggleDone: () => void;
+ updateDescription: (description: string) => void;
+ todo: TodoItem;
+ 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) {
+ const {
+ onDelete,
+ onToggleDone,
+ todo,
+ updateDescription,
+ inputRef,
+ idx,
+ onInputKeyPress,
+ xRef,
+ xOnKeyDown,
+ checkRef,
+ checkOnKeyDown,
+ } = props;
+
+ 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) commitChange();
+ break;
+ }
+ case "Escape": {
+ updateDescription(initialDescription.current);
+ 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-300 dark:border-gray-700">
+ <Checkbox
+ ref={checkRef}
+ onKeyDown={checkOnKeyDown}
+ 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>
+ <button
+ ref={xRef}
+ onKeyDown={xOnKeyDown}
+ onClick={onDelete}
+ className="items-center top-0 cursor-pointer bottom-0 px-1"
+ >
+ ❌
+ </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);
+ const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => {
+ switch (evt.key) {
+ case "Enter": {
+ evt.preventDefault();
+ if (editingTitle) {
+ const success = rename(editingTitle);
+ if (!success) setEditingTitle(null);
+ }
+ break;
+ }
+ case "Escape": {
+ setEditingTitle(null);
+ }
+ }
+ };
+ return (
+ <h1 className="text-3xl p-3">
+ <input
+ className="text-center w-full"
+ type="text"
+ value={editingTitle === null ? title : editingTitle}
+ onChange={(evt) => setEditingTitle(evt.target.value)}
+ onKeyDown={handleKeyPress}
+ onBlur={() => {
+ if (editingTitle) {
+ const success = rename(editingTitle);
+ if (!success) setEditingTitle(null);
+ }
+ }}
+ ></input>
+ </h1>
+ );
+}
+
+export default function TodoList() {
+ const router = useRouter();
+ const { title: _title } = useParams<{ title: string }>();
+ const title = _title ? decodeURIComponent(_title) : "tasks";
+ const [items, setItems] = useLocalStorage<TodoItem[]>(title, [mkTodo()]);
+ const todoDescriptionRefs = useRef<(HTMLInputElement | null)[]>([]);
+ const renameTitle = (newTitle: string) => {
+ if (title === newTitle) return false;
+ const dataAtNewTitle = !!localStorage.getItem(newTitle);
+ if (dataAtNewTitle) {
+ alert(
+ "A task list with that name already exists! Pick another name. If you want to see that list, navigate to it via the URL.",
+ );
+ return false;
+ } else {
+ localStorage.removeItem(title);
+ localStorage.setItem(newTitle, JSON.stringify(items));
+ router.replace(`/${encodeURIComponent(newTitle)}`);
+ return true;
+ }
+ };
+
+ const [xRef, xOnKeyDown] = useListFocuser<HTMLButtonElement>();
+ const [checkRef, checkOnKeyDown] = useListFocuser<HTMLButtonElement>();
+
+ const focusInput = (idx: number) => {
+ const el = todoDescriptionRefs.current[idx];
+ if (el) el.focus();
+ };
+
+ const autoFocusInputIdx = useRef<number | null>(null);
+
+ useEffect(() => {
+ if (autoFocusInputIdx.current !== null) {
+ focusInput(autoFocusInputIdx.current);
+ autoFocusInputIdx.current = null;
+ }
+ }, [items]);
+
+ 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,
+ );
+ }
+ };
+
+ 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-2 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%]">
+ {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;
+ }
+ 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;
+ }
+ case "ArrowRight": {
+ if (inputPosition() === todo.description.length) {
+ evt.preventDefault();
+ gotoTodo(idx, 1, "start");
+ }
+ break;
+ }
+ case "ArrowLeft": {
+ if (inputPosition() === 0) {
+ evt.preventDefault();
+ gotoTodo(idx, -1, "end");
+ }
+ 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>
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
@@ -1,112 +0,0 @@
-import Image from "next/image";
-import Link from "next/link";
-
-export default function Home() {
- return (
- <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
- <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
- <Image
- className="dark:invert"
- src="/next.svg"
- alt="Next.js logo"
- width={180}
- height={38}
- priority
- />
- <ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
- <li className="mb-2 tracking-[-.01em]">
- Get started by editing{" "}
- <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
- src/app/page.tsx
- </code>
- .
- </li>
- <li className="tracking-[-.01em]">
- Save and see your changes instantly.
- </li>
- </ol>
-
- <div className="flex gap-4 items-center flex-col sm:flex-row">
- <a
- className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
- href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
- target="_blank"
- rel="noopener noreferrer"
- >
- <Image
- className="dark:invert"
- src="/vercel.svg"
- alt="Vercel logomark"
- width={20}
- height={20}
- />
- Deploy now
- </a>
- <a
- className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
- href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
- target="_blank"
- rel="noopener noreferrer"
- >
- Read our docs
- </a>
- </div>
- <div className="flex items-center flex-col sm:flex-row">
- <Link
- className="rounded-[50%] border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex basis-[100%] items-center justify-center bg-[salmon] hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-[100%] sm:w-auto"
- href="/todo-list"
- >
- Go to this cool route with a todo app in it
- </Link>
- </div>
- </main>
- <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
- <a
- className="flex items-center gap-2 hover:underline hover:underline-offset-4"
- href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
- target="_blank"
- rel="noopener noreferrer"
- >
- <Image
- aria-hidden
- src="/file.svg"
- alt="File icon"
- width={16}
- height={16}
- />
- Learn
- </a>
- <a
- className="flex items-center gap-2 hover:underline hover:underline-offset-4"
- href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
- target="_blank"
- rel="noopener noreferrer"
- >
- <Image
- aria-hidden
- src="/window.svg"
- alt="Window icon"
- width={16}
- height={16}
- />
- Examples
- </a>
- <a
- className="flex items-center gap-2 hover:underline hover:underline-offset-4"
- href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
- target="_blank"
- rel="noopener noreferrer"
- >
- <Image
- aria-hidden
- src="/globe.svg"
- alt="Globe icon"
- width={16}
- height={16}
- />
- Go to nextjs.org →
- </a>
- </footer>
- </div>
- );
-}
diff --git a/src/app/todo-list/[title]/page.tsx b/src/app/todo-list/[title]/page.tsx
@@ -1,433 +0,0 @@
-"use client";
-import {
- KeyboardEvent,
- KeyboardEventHandler,
- Ref,
- RefObject,
- useEffect,
- useRef,
- useState,
-} from "react";
-
-import { useLocalStorage } from "@/lib/useLocalStorage";
-import { useParams, useRouter } from "next/navigation";
-
-const MAX_TASK_LENGTH = 128;
-
-const genId = () => {
- try {
- return crypto.randomUUID();
- } catch (e) {
- return String(Math.random()); // good enough for being unique on a single local list
- }
-};
-
-type TodoItem = {
- description: string;
- isDone: boolean;
- id: string;
-};
-
-function mkTodo(description: string = ""): TodoItem {
- return {
- description,
- isDone: false,
- id: genId(),
- };
-}
-
-// helper hooks
-
-function useListFocuser<T extends HTMLElement>(): [
- RefObject<(T | null)[]>,
- (idx: number, evt: KeyboardEvent<T>) => void,
-] {
- const listRef = useRef<(T | null)[]>([]);
-
- const onKeyDown = (idx: number, evt: KeyboardEvent) => {
- switch (evt.key) {
- case "ArrowUp": {
- if (idx > 0) {
- evt.preventDefault();
- listRef.current[idx - 1]?.focus();
- }
- break;
- }
- case "ArrowDown": {
- if (idx < listRef.current.length - 1) {
- evt.preventDefault();
- listRef.current[idx + 1]?.focus();
- }
- break;
- }
- }
- };
-
- return [listRef, onKeyDown];
-}
-
-// components
-
-type CheckboxProps = {
- isChecked: boolean;
- onToggle: () => void;
- idx: number;
- ref: Ref<HTMLButtonElement>;
- onKeyDown: KeyboardEventHandler<HTMLButtonElement>;
-};
-
-function Checkbox(props: CheckboxProps) {
- const { isChecked, onToggle, idx, ref, onKeyDown } = props;
- return (
- <button
- 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}
- >
- {isChecked ? "✅" : ""}
- </button>
- );
-}
-
-type TodoItemProps = {
- onDelete: () => void;
- onToggleDone: () => void;
- updateDescription: (description: string) => void;
- todo: TodoItem;
- 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) {
- const {
- onDelete,
- onToggleDone,
- todo,
- updateDescription,
- inputRef,
- idx,
- onInputKeyPress,
- xRef,
- xOnKeyDown,
- checkRef,
- checkOnKeyDown,
- } = props;
-
- 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) commitChange();
- break;
- }
- case "Escape": {
- updateDescription(initialDescription.current);
- 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-300 dark:border-gray-700">
- <Checkbox
- ref={checkRef}
- onKeyDown={checkOnKeyDown}
- 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>
- <button
- ref={xRef}
- onKeyDown={xOnKeyDown}
- onClick={onDelete}
- className="items-center top-0 cursor-pointer bottom-0"
- >
- ❌
- </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);
- const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => {
- switch (evt.key) {
- case "Enter": {
- evt.preventDefault();
- if (editingTitle) {
- const success = rename(editingTitle);
- if (!success) setEditingTitle(null);
- }
- break;
- }
- case "Escape": {
- setEditingTitle(null);
- }
- }
- };
- return (
- <h1 className="text-4xl">
- <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>
- );
-}
-
-export default function TodoList() {
- const router = useRouter();
- const { title: _title } = useParams<{ title: string }>();
- const title = decodeURIComponent(_title);
- const [items, setItems] = useLocalStorage<TodoItem[]>(title, [mkTodo()]);
- const todoDescriptionRefs = useRef<(HTMLInputElement | null)[]>([]);
- const renameTitle = (newTitle: string) => {
- const dataAtNewTitle = !!localStorage.getItem(newTitle);
- if (dataAtNewTitle) {
- 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/${encodeURIComponent(newTitle)}`);
- return true;
- }
- };
-
- const [xRef, xOnKeyDown] = useListFocuser<HTMLButtonElement>();
- const [checkRef, checkOnKeyDown] = useListFocuser<HTMLButtonElement>();
-
- const focusInput = (idx: number) => {
- const el = todoDescriptionRefs.current[idx];
- if (el) el.focus();
- };
-
- const autoFocusInputIdx = useRef<number | null>(null);
-
- useEffect(() => {
- if (autoFocusInputIdx.current !== null) {
- focusInput(autoFocusInputIdx.current);
- autoFocusInputIdx.current = null;
- }
- }, [items]);
-
- 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,
- );
- }
- };
-
- 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>
- <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%]">
- {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;
- }
- 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;
- }
- case "ArrowRight": {
- if (inputPosition() === todo.description.length) {
- evt.preventDefault();
- gotoTodo(idx, 1, "start");
- }
- break;
- }
- case "ArrowLeft": {
- if (inputPosition() === 0) {
- evt.preventDefault();
- gotoTodo(idx, -1, "end");
- }
- 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>
- );
-}
diff --git a/src/app/todo-list/page.tsx b/src/app/todo-list/page.tsx
@@ -1,3 +0,0 @@
-export default function Page() {
- return <h2>howde?</h2>;
-}
diff --git a/src/lib/useLocalStorage.ts b/src/lib/useLocalStorage.ts
@@ -3,7 +3,7 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
function getStorageValue<T>(key: string, defaultValue: T) {
- const saved = localStorage.getItem(key);
+ const saved = window.localStorage.getItem(key);
if (!saved) return defaultValue;
const initial = JSON.parse(saved) as T;
return initial || defaultValue;
@@ -18,8 +18,7 @@ export const useLocalStorage = <T>(
});
useEffect(() => {
- // TODO: may want to delete stored data at the old key if key changes
- localStorage.setItem(key, JSON.stringify(value));
+ window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];