todo-next

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

page.tsx (13159B)


      1 "use client";
      2 import {
      3   KeyboardEvent,
      4   KeyboardEventHandler,
      5   Ref,
      6   RefObject,
      7   useEffect,
      8   useRef,
      9   useState,
     10 } from "react";
     11 
     12 import { useLocalStorage } from "@/lib/useLocalStorage";
     13 import { useParams, useRouter } from "next/navigation";
     14 
     15 const MAX_TASK_LENGTH = 128;
     16 
     17 const genId = () => {
     18   try {
     19     return crypto.randomUUID();
     20   } catch (e) {
     21     return String(Math.random()); // good enough for being unique on a single local list
     22   }
     23 };
     24 
     25 type TodoItem = {
     26   description: string;
     27   isDone: boolean;
     28   id: string;
     29 };
     30 
     31 function mkTodo(description: string = ""): TodoItem {
     32   return {
     33     description,
     34     isDone: false,
     35     id: genId(),
     36   };
     37 }
     38 
     39 // helper hooks
     40 
     41 function useListFocuser<T extends HTMLElement>(): [
     42   RefObject<(T | null)[]>,
     43   (idx: number, evt: KeyboardEvent<T>) => void,
     44 ] {
     45   const listRef = useRef<(T | null)[]>([]);
     46 
     47   const onKeyDown = (idx: number, evt: KeyboardEvent) => {
     48     switch (evt.key) {
     49       case "ArrowUp": {
     50         if (idx > 0) {
     51           evt.preventDefault();
     52           listRef.current[idx - 1]?.focus();
     53         }
     54         break;
     55       }
     56       case "ArrowDown": {
     57         if (idx < listRef.current.length - 1) {
     58           evt.preventDefault();
     59           listRef.current[idx + 1]?.focus();
     60         }
     61         break;
     62       }
     63     }
     64   };
     65 
     66   return [listRef, onKeyDown];
     67 }
     68 
     69 // components
     70 
     71 type CheckboxProps = {
     72   isChecked: boolean;
     73   onToggle: () => void;
     74   idx: number;
     75   ref: Ref<HTMLButtonElement>;
     76   onKeyDown: KeyboardEventHandler<HTMLButtonElement>;
     77 };
     78 
     79 function Checkbox(props: CheckboxProps) {
     80   const { isChecked, onToggle, idx, ref, onKeyDown } = props;
     81   return (
     82     <button
     83       ref={ref}
     84       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`}
     85       onClick={onToggle}
     86       onKeyDown={onKeyDown}
     87     >
     88       {isChecked ? "✅" : ""}
     89     </button>
     90   );
     91 }
     92 
     93 type TodoItemProps = {
     94   onDelete: () => void;
     95   onToggleDone: () => void;
     96   updateDescription: (description: string) => void;
     97   todo: TodoItem;
     98   inputRef?: (ref: HTMLInputElement | null) => void;
     99   idx: number;
    100   onInputKeyPress: KeyboardEventHandler<HTMLInputElement>;
    101   xRef: (ref: HTMLButtonElement | null) => void;
    102   xOnKeyDown: KeyboardEventHandler<HTMLButtonElement>;
    103   checkRef: (ref: HTMLButtonElement | null) => void;
    104   checkOnKeyDown: KeyboardEventHandler<HTMLButtonElement>;
    105 };
    106 
    107 function TodoItem(props: TodoItemProps) {
    108   const {
    109     onDelete,
    110     onToggleDone,
    111     todo,
    112     updateDescription,
    113     inputRef,
    114     idx,
    115     onInputKeyPress,
    116     xRef,
    117     xOnKeyDown,
    118     checkRef,
    119     checkOnKeyDown,
    120   } = props;
    121 
    122   const initialDescription = useRef(todo.description);
    123   const commitChange = () => {
    124     initialDescription.current = todo.description;
    125   };
    126   useEffect(() => {
    127     initialDescription.current = todo.description;
    128   }, []);
    129 
    130   const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => {
    131     switch (evt.key) {
    132       case "Enter": {
    133         if (todo.description.length) commitChange();
    134         break;
    135       }
    136       case "Escape": {
    137         updateDescription(initialDescription.current);
    138         break;
    139       }
    140     }
    141     onInputKeyPress(evt);
    142   };
    143   return (
    144     <div className="group [&:not(:last-child)>*]:border-b-1 flex flex-col">
    145       <div className="text-md gap-2 flex place-content-between p-1 mx-4 relative border-gray-300 dark:border-gray-700">
    146         <Checkbox
    147           ref={checkRef}
    148           onKeyDown={checkOnKeyDown}
    149           isChecked={todo.isDone}
    150           onToggle={onToggleDone}
    151           idx={idx}
    152         />
    153         <input
    154           className="grow"
    155           placeholder={"new task"}
    156           value={todo.description}
    157           onChange={(e) => updateDescription(e.target.value)}
    158           maxLength={MAX_TASK_LENGTH}
    159           onBlur={() => {
    160             commitChange();
    161           }}
    162           onFocus={commitChange}
    163           onKeyDown={handleKeyPress}
    164           ref={inputRef}
    165         ></input>
    166         <button
    167           ref={xRef}
    168           onKeyDown={xOnKeyDown}
    169           onClick={onDelete}
    170           className="items-center top-0 cursor-pointer bottom-0 px-1"
    171         >
    172    173         </button>
    174       </div>
    175     </div>
    176   );
    177 }
    178 
    179 type TitleProps = { title: string; rename: (newTitle: string) => boolean };
    180 
    181 function Title(props: TitleProps) {
    182   const { title, rename } = props;
    183   const [editingTitle, setEditingTitle] = useState<null | string>(null);
    184   const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => {
    185     switch (evt.key) {
    186       case "Enter": {
    187         evt.preventDefault();
    188         if (editingTitle) {
    189           const success = rename(editingTitle);
    190           if (!success) setEditingTitle(null);
    191         }
    192         break;
    193       }
    194       case "Escape": {
    195         setEditingTitle(null);
    196       }
    197     }
    198   };
    199   return (
    200     <h1 className="text-3xl p-3">
    201       <input
    202         className="text-center w-full"
    203         type="text"
    204         value={editingTitle === null ? title : editingTitle}
    205         onChange={(evt) => setEditingTitle(evt.target.value)}
    206         onKeyDown={handleKeyPress}
    207         onBlur={() => {
    208           if (editingTitle) {
    209             const success = rename(editingTitle);
    210             if (!success) setEditingTitle(null);
    211           }
    212         }}
    213       ></input>
    214     </h1>
    215   );
    216 }
    217 
    218 export default function TodoList() {
    219   const router = useRouter();
    220   const { title: _title } = useParams<{ title: string }>();
    221   const title = _title ? decodeURIComponent(_title) : "Tasks";
    222   const [items, setItems] = useLocalStorage<TodoItem[]>(title, [mkTodo()]);
    223   const todoDescriptionRefs = useRef<(HTMLInputElement | null)[]>([]);
    224   const renameTitle = (newTitle: string) => {
    225     if (title === newTitle) return false;
    226     const dataAtNewTitle = !!localStorage.getItem(newTitle);
    227     if (dataAtNewTitle) {
    228       alert(
    229         "A task list with that name already exists! Pick another name. If you want to see that list, navigate to it via the URL.",
    230       );
    231       return false;
    232     } else {
    233       localStorage.removeItem(title);
    234       localStorage.setItem(newTitle, JSON.stringify(items));
    235       router.replace(`/${encodeURIComponent(newTitle)}`);
    236       return true;
    237     }
    238   };
    239 
    240   const [xRef, xOnKeyDown] = useListFocuser<HTMLButtonElement>();
    241   const [checkRef, checkOnKeyDown] = useListFocuser<HTMLButtonElement>();
    242 
    243   const focusInput = (idx: number) => {
    244     const el = todoDescriptionRefs.current[idx];
    245     if (el) el.focus();
    246   };
    247 
    248   const autoFocusInputIdx = useRef<number | null>(null);
    249 
    250   useEffect(() => {
    251     if (autoFocusInputIdx.current !== null) {
    252       focusInput(autoFocusInputIdx.current);
    253       autoFocusInputIdx.current = null;
    254     }
    255   }, [items]);
    256 
    257   const addTodo = (idx: number) => {
    258     setItems((items) => {
    259       const newItems = [...items];
    260       newItems.splice(idx, 0, mkTodo());
    261       return newItems;
    262     });
    263     autoFocusInputIdx.current = idx;
    264   };
    265 
    266   const deleteTodo = (idx: number) => {
    267     setItems((items) => {
    268       const newItems = items.filter((item, iidx) => idx !== iidx);
    269       if (!newItems.length) {
    270         newItems.push(mkTodo(""));
    271       }
    272       return newItems;
    273     });
    274   };
    275 
    276   const gotoTodo = (
    277     idx: number,
    278     deltaIdx: number,
    279     selectionPosition: "start" | "end" | number,
    280   ) => {
    281     const gotoIdx = idx + deltaIdx;
    282     if (gotoIdx < 0) {
    283       todoDescriptionRefs.current[idx]?.setSelectionRange(0, 0);
    284     } else if (gotoIdx >= items.length) {
    285       const lastDescLength = items.at(-1)?.description.length || 0;
    286       todoDescriptionRefs.current
    287         .at(-1)
    288         ?.setSelectionRange(lastDescLength, lastDescLength);
    289     } else {
    290       focusInput(idx + deltaIdx);
    291       const selection =
    292         selectionPosition === "end"
    293           ? items[idx + deltaIdx].description.length
    294           : selectionPosition === "start"
    295             ? 0
    296             : selectionPosition;
    297       todoDescriptionRefs.current[idx + deltaIdx]?.setSelectionRange(
    298         selection,
    299         selection,
    300       );
    301     }
    302   };
    303 
    304   useEffect(() => {
    305     if (!items.length) {
    306       setItems([mkTodo()]);
    307     }
    308   }, [items.length]);
    309 
    310   return (
    311     <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-2 font-[monospace]">
    312       <header>
    313         <Title title={title} rename={renameTitle} />
    314       </header>
    315       <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start w-full">
    316         <div className="w-[100%] pb-[30dvh]">
    317           {items.map((todo, idx) => {
    318             const inputPosition = () =>
    319               todoDescriptionRefs.current[idx]?.selectionStart;
    320             return (
    321               <TodoItem
    322                 todo={todo}
    323                 onToggleDone={() =>
    324                   setItems((items) => {
    325                     const newItems = [...items];
    326                     newItems[idx] = {
    327                       ...items[idx],
    328                       isDone: !items[idx].isDone,
    329                     };
    330                     return newItems;
    331                   })
    332                 }
    333                 onDelete={() => {
    334                   const xIsFocused =
    335                     xRef.current[idx]?.matches(":focus-visible");
    336                   setItems((items) => {
    337                     // to keep focus on the delete button, just reset the todo if it's the last one.
    338                     if (items.length === 1) {
    339                       const newItems = [...items];
    340                       newItems[0].description = "";
    341                       newItems[0].isDone = false;
    342                       return newItems;
    343                     } else {
    344                       return items.filter((item, iidx) => idx !== iidx);
    345                     }
    346                   });
    347                   if (xIsFocused) {
    348                     (xRef.current[idx + 1] || xRef.current[idx - 1])?.focus();
    349                   }
    350                 }}
    351                 updateDescription={(description: string) =>
    352                   setItems((items) =>
    353                     items.map((item, iidx) =>
    354                       idx === iidx ? { ...item, description } : item,
    355                     ),
    356                   )
    357                 }
    358                 key={todo.id}
    359                 idx={idx}
    360                 inputRef={(ref) => (todoDescriptionRefs.current[idx] = ref)}
    361                 onInputKeyPress={(evt) => {
    362                   switch (evt.key) {
    363                     case "Enter": {
    364                       const inputPos =
    365                         todoDescriptionRefs.current[idx]?.selectionStart || 0;
    366 
    367                       const multiselect =
    368                         todoDescriptionRefs.current[idx]?.selectionEnd ||
    369                         0 > inputPos;
    370                       const pos =
    371                         evt.getModifierState("Shift") ||
    372                         (inputPos === 0 &&
    373                           todo.description.length &&
    374                           !multiselect)
    375                           ? 0
    376                           : 1;
    377                       addTodo(idx + pos);
    378                       break;
    379                     }
    380                     case "Backspace": {
    381                       if (!todo.description.length && !evt.repeat) {
    382                         if (items.length === 1) {
    383                           break;
    384                         }
    385                         evt.preventDefault();
    386                         const dir = idx === 0 ? 1 : -1;
    387                         // keep the element around while we go to the next todo
    388                         // prevents mobile keyboards from closing due to their
    389                         // focused element disappearing.
    390                         deleteTodo(idx);
    391                         gotoTodo(idx, dir, "end");
    392                       }
    393                       break;
    394                     }
    395                     case "ArrowRight": {
    396                       if (inputPosition() === todo.description.length) {
    397                         evt.preventDefault();
    398                         gotoTodo(idx, 1, "start");
    399                       }
    400                       break;
    401                     }
    402                     case "ArrowLeft": {
    403                       if (inputPosition() === 0) {
    404                         evt.preventDefault();
    405                         gotoTodo(idx, -1, "end");
    406                       }
    407                       break;
    408                     }
    409                     case "ArrowDown": {
    410                       evt.preventDefault();
    411                       gotoTodo(idx, 1, inputPosition() || "start");
    412                       break;
    413                     }
    414                     case "ArrowUp": {
    415                       evt.preventDefault();
    416                       gotoTodo(idx, -1, inputPosition() || "start");
    417                       break;
    418                     }
    419                   }
    420                 }}
    421                 xRef={(ref) => {
    422                   xRef.current[idx] = ref;
    423                 }}
    424                 xOnKeyDown={(evt) => xOnKeyDown(idx, evt)}
    425                 checkRef={(ref) => {
    426                   checkRef.current[idx] = ref;
    427                 }}
    428                 checkOnKeyDown={(evt) => checkOnKeyDown(idx, evt)}
    429               />
    430             );
    431           })}
    432         </div>
    433       </main>
    434     </div>
    435   );
    436 }