tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

Lists.jsx (28148B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
      4 
      5 import React, {
      6  useRef,
      7  useState,
      8  useEffect,
      9  useCallback,
     10  useMemo,
     11 } from "react";
     12 import { useSelector, batch } from "react-redux";
     13 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
     14 import { useIntersectionObserver, useConfetti } from "../../../lib/utils";
     15 
     16 const TASK_TYPE = {
     17  IN_PROGRESS: "tasks",
     18  COMPLETED: "completed",
     19 };
     20 
     21 const USER_ACTION_TYPES = {
     22  LIST_COPY: "list_copy",
     23  LIST_CREATE: "list_create",
     24  LIST_EDIT: "list_edit",
     25  LIST_DELETE: "list_delete",
     26  TASK_CREATE: "task_create",
     27  TASK_EDIT: "task_edit",
     28  TASK_DELETE: "task_delete",
     29  TASK_COMPLETE: "task_complete",
     30 };
     31 
     32 const PREF_WIDGETS_LISTS_MAX_LISTS = "widgets.lists.maxLists";
     33 const PREF_WIDGETS_LISTS_MAX_LISTITEMS = "widgets.lists.maxListItems";
     34 const PREF_WIDGETS_LISTS_BADGE_ENABLED = "widgets.lists.badge.enabled";
     35 const PREF_WIDGETS_LISTS_BADGE_LABEL = "widgets.lists.badge.label";
     36 
     37 function Lists({ dispatch, handleUserInteraction, isMaximized }) {
     38  const prefs = useSelector(state => state.Prefs.values);
     39  const { selected, lists } = useSelector(state => state.ListsWidget);
     40  const [newTask, setNewTask] = useState("");
     41  const [isEditing, setIsEditing] = useState(false);
     42  const [pendingNewList, setPendingNewList] = useState(null);
     43  const selectedList = useMemo(() => lists[selected], [lists, selected]);
     44 
     45  const prevCompletedCount = useRef(selectedList?.completed?.length || 0);
     46  const inputRef = useRef(null);
     47  const selectRef = useRef(null);
     48  const reorderListRef = useRef(null);
     49  const [canvasRef, fireConfetti] = useConfetti();
     50 
     51  const handleListInteraction = useCallback(
     52    () => handleUserInteraction("lists"),
     53    [handleUserInteraction]
     54  );
     55 
     56  // store selectedList with useMemo so it isnt re-calculated on every re-render
     57  const isValidUrl = useCallback(str => URL.canParse(str), []);
     58 
     59  const handleIntersection = useCallback(() => {
     60    dispatch(
     61      ac.AlsoToMain({
     62        type: at.WIDGETS_LISTS_USER_IMPRESSION,
     63      })
     64    );
     65  }, [dispatch]);
     66 
     67  const listsRef = useIntersectionObserver(handleIntersection);
     68 
     69  const reorderLists = useCallback(
     70    (draggedElement, targetElement, before = false) => {
     71      const draggedIndex = selectedList.tasks.findIndex(
     72        ({ id }) => id === draggedElement.id
     73      );
     74      const targetIndex = selectedList.tasks.findIndex(
     75        ({ id }) => id === targetElement.id
     76      );
     77 
     78      // return early is index is not found
     79      if (
     80        draggedIndex === -1 ||
     81        targetIndex === -1 ||
     82        draggedIndex === targetIndex
     83      ) {
     84        return;
     85      }
     86 
     87      const reordered = [...selectedList.tasks];
     88      const [removed] = reordered.splice(draggedIndex, 1);
     89      const insertIndex = before ? targetIndex : targetIndex + 1;
     90 
     91      reordered.splice(
     92        insertIndex > draggedIndex ? insertIndex - 1 : insertIndex,
     93        0,
     94        removed
     95      );
     96 
     97      const updatedLists = {
     98        ...lists,
     99        [selected]: {
    100          ...selectedList,
    101          tasks: reordered,
    102        },
    103      };
    104 
    105      dispatch(
    106        ac.AlsoToMain({
    107          type: at.WIDGETS_LISTS_UPDATE,
    108          data: { lists: updatedLists },
    109        })
    110      );
    111      handleListInteraction();
    112    },
    113    [lists, selected, selectedList, dispatch, handleListInteraction]
    114  );
    115 
    116  const moveTask = useCallback(
    117    (task, direction) => {
    118      const index = selectedList.tasks.findIndex(({ id }) => id === task.id);
    119 
    120      // guardrail a falsey index
    121      if (index === -1) {
    122        return;
    123      }
    124 
    125      const targetIndex = direction === "up" ? index - 1 : index + 1;
    126      const before = direction === "up";
    127      const targetTask = selectedList.tasks[targetIndex];
    128 
    129      if (targetTask) {
    130        reorderLists(task, targetTask, before);
    131      }
    132    },
    133    [selectedList, reorderLists]
    134  );
    135 
    136  useEffect(() => {
    137    const selectNode = selectRef.current;
    138    const reorderNode = reorderListRef.current;
    139 
    140    if (!selectNode || !reorderNode) {
    141      return undefined;
    142    }
    143 
    144    function handleSelectChange(e) {
    145      dispatch(
    146        ac.AlsoToMain({
    147          type: at.WIDGETS_LISTS_CHANGE_SELECTED,
    148          data: e.target.value,
    149        })
    150      );
    151      handleListInteraction();
    152    }
    153 
    154    function handleReorder(e) {
    155      const { draggedElement, targetElement, position } = e.detail;
    156      reorderLists(draggedElement, targetElement, position === -1);
    157    }
    158 
    159    reorderNode.addEventListener("reorder", handleReorder);
    160    selectNode.addEventListener("change", handleSelectChange);
    161 
    162    return () => {
    163      selectNode.removeEventListener("change", handleSelectChange);
    164      reorderNode.removeEventListener("reorder", handleReorder);
    165    };
    166  }, [dispatch, isEditing, reorderLists, handleListInteraction]);
    167 
    168  // effect that enables editing new list name only after store has been hydrated
    169  useEffect(() => {
    170    if (selected === pendingNewList) {
    171      setIsEditing(true);
    172      setPendingNewList(null);
    173    }
    174  }, [selected, pendingNewList]);
    175 
    176  function saveTask() {
    177    const trimmedTask = newTask.trimEnd();
    178    // only add new task if it has a length, to avoid creating empty tasks
    179    if (trimmedTask) {
    180      const formattedTask = {
    181        value: trimmedTask,
    182        completed: false,
    183        created: Date.now(),
    184        id: crypto.randomUUID(),
    185        isUrl: isValidUrl(trimmedTask),
    186      };
    187      const updatedLists = {
    188        ...lists,
    189        [selected]: {
    190          ...selectedList,
    191          tasks: [formattedTask, ...lists[selected].tasks],
    192        },
    193      };
    194      batch(() => {
    195        dispatch(
    196          ac.AlsoToMain({
    197            type: at.WIDGETS_LISTS_UPDATE,
    198            data: { lists: updatedLists },
    199          })
    200        );
    201        dispatch(
    202          ac.OnlyToMain({
    203            type: at.WIDGETS_LISTS_USER_EVENT,
    204            data: { userAction: USER_ACTION_TYPES.TASK_CREATE },
    205          })
    206        );
    207      });
    208      setNewTask("");
    209      handleListInteraction();
    210    }
    211  }
    212 
    213  function updateTask(updatedTask, type) {
    214    const isCompletedType = type === TASK_TYPE.COMPLETED;
    215    const isNowCompleted = updatedTask.completed;
    216 
    217    let newTasks = selectedList.tasks;
    218    let newCompleted = selectedList.completed;
    219    let userAction;
    220 
    221    // If the task is in the completed array and is now unchecked
    222    const shouldMoveToTasks = isCompletedType && !isNowCompleted;
    223 
    224    // If we're moving the task from tasks → completed (user checked it)
    225    const shouldMoveToCompleted = !isCompletedType && isNowCompleted;
    226 
    227    //  Move task from completed -> task
    228    if (shouldMoveToTasks) {
    229      newCompleted = selectedList.completed.filter(
    230        task => task.id !== updatedTask.id
    231      );
    232      newTasks = [...selectedList.tasks, updatedTask];
    233      // Move task to completed, but also create local version
    234    } else if (shouldMoveToCompleted) {
    235      newTasks = selectedList.tasks.filter(task => task.id !== updatedTask.id);
    236      newCompleted = [...selectedList.completed, updatedTask];
    237 
    238      userAction = USER_ACTION_TYPES.TASK_COMPLETE;
    239    } else {
    240      const targetKey = isCompletedType ? "completed" : "tasks";
    241      const updatedArray = selectedList[targetKey].map(task =>
    242        task.id === updatedTask.id ? updatedTask : task
    243      );
    244      // In-place update: toggle checkbox (but stay in same array or edit name)
    245      if (targetKey === "tasks") {
    246        newTasks = updatedArray;
    247      } else {
    248        newCompleted = updatedArray;
    249      }
    250      userAction = USER_ACTION_TYPES.TASK_EDIT;
    251    }
    252 
    253    const updatedLists = {
    254      ...lists,
    255      [selected]: {
    256        ...selectedList,
    257        tasks: newTasks,
    258        completed: newCompleted,
    259      },
    260    };
    261 
    262    batch(() => {
    263      dispatch(
    264        ac.AlsoToMain({
    265          type: at.WIDGETS_LISTS_UPDATE,
    266          data: { lists: updatedLists },
    267        })
    268      );
    269      if (userAction) {
    270        dispatch(
    271          ac.AlsoToMain({
    272            type: at.WIDGETS_LISTS_USER_EVENT,
    273            data: { userAction },
    274          })
    275        );
    276      }
    277    });
    278    handleListInteraction();
    279  }
    280 
    281  function deleteTask(task, type) {
    282    const selectedTasks = lists[selected][type];
    283    const updatedTasks = selectedTasks.filter(({ id }) => id !== task.id);
    284 
    285    const updatedLists = {
    286      ...lists,
    287      [selected]: {
    288        ...selectedList,
    289        [type]: updatedTasks,
    290      },
    291    };
    292    batch(() => {
    293      dispatch(
    294        ac.AlsoToMain({
    295          type: at.WIDGETS_LISTS_UPDATE,
    296          data: { lists: updatedLists },
    297        })
    298      );
    299      dispatch(
    300        ac.OnlyToMain({
    301          type: at.WIDGETS_LISTS_USER_EVENT,
    302          data: { userAction: USER_ACTION_TYPES.TASK_DELETE },
    303        })
    304      );
    305    });
    306    handleListInteraction();
    307  }
    308 
    309  function handleKeyDown(e) {
    310    if (e.key === "Enter" && document.activeElement === inputRef.current) {
    311      saveTask();
    312    } else if (
    313      e.key === "Escape" &&
    314      document.activeElement === inputRef.current
    315    ) {
    316      // Clear out the input when esc is pressed
    317      setNewTask("");
    318    }
    319  }
    320 
    321  function handleListNameSave(newLabel) {
    322    const trimmedLabel = newLabel.trimEnd();
    323    if (trimmedLabel && trimmedLabel !== selectedList?.label) {
    324      const updatedLists = {
    325        ...lists,
    326        [selected]: {
    327          ...selectedList,
    328          label: trimmedLabel,
    329        },
    330      };
    331      batch(() => {
    332        dispatch(
    333          ac.AlsoToMain({
    334            type: at.WIDGETS_LISTS_UPDATE,
    335            data: { lists: updatedLists },
    336          })
    337        );
    338        dispatch(
    339          ac.OnlyToMain({
    340            type: at.WIDGETS_LISTS_USER_EVENT,
    341            data: { userAction: USER_ACTION_TYPES.LIST_EDIT },
    342          })
    343        );
    344      });
    345      setIsEditing(false);
    346      handleListInteraction();
    347    }
    348  }
    349 
    350  function handleCreateNewList() {
    351    const id = crypto.randomUUID();
    352    const newLists = {
    353      ...lists,
    354      [id]: {
    355        label: "",
    356        tasks: [],
    357        completed: [],
    358      },
    359    };
    360 
    361    batch(() => {
    362      dispatch(
    363        ac.AlsoToMain({
    364          type: at.WIDGETS_LISTS_UPDATE,
    365          data: { lists: newLists },
    366        })
    367      );
    368      dispatch(
    369        ac.AlsoToMain({
    370          type: at.WIDGETS_LISTS_CHANGE_SELECTED,
    371          data: id,
    372        })
    373      );
    374      dispatch(
    375        ac.OnlyToMain({
    376          type: at.WIDGETS_LISTS_USER_EVENT,
    377          data: { userAction: USER_ACTION_TYPES.LIST_CREATE },
    378        })
    379      );
    380    });
    381    setPendingNewList(id);
    382    handleListInteraction();
    383  }
    384 
    385  function handleCancelNewList() {
    386    // If current list is new and has no label/tasks, remove it
    387    if (!selectedList?.label && selectedList?.tasks?.length === 0) {
    388      const updatedLists = { ...lists };
    389      delete updatedLists[selected];
    390 
    391      const listKeys = Object.keys(updatedLists);
    392      const key = listKeys[listKeys.length - 1];
    393      batch(() => {
    394        dispatch(
    395          ac.AlsoToMain({
    396            type: at.WIDGETS_LISTS_UPDATE,
    397            data: { lists: updatedLists },
    398          })
    399        );
    400        dispatch(
    401          ac.AlsoToMain({
    402            type: at.WIDGETS_LISTS_CHANGE_SELECTED,
    403            data: key,
    404          })
    405        );
    406        dispatch(
    407          ac.OnlyToMain({
    408            type: at.WIDGETS_LISTS_USER_EVENT,
    409            data: { userAction: USER_ACTION_TYPES.LIST_DELETE },
    410          })
    411        );
    412      });
    413    }
    414 
    415    handleListInteraction();
    416  }
    417 
    418  function handleDeleteList() {
    419    let updatedLists = { ...lists };
    420    if (updatedLists[selected]) {
    421      delete updatedLists[selected];
    422 
    423      // if this list was the last one created, add a new list as default
    424      if (Object.keys(updatedLists)?.length === 0) {
    425        updatedLists = {
    426          [crypto.randomUUID()]: {
    427            label: "",
    428            tasks: [],
    429            completed: [],
    430          },
    431        };
    432      }
    433      const listKeys = Object.keys(updatedLists);
    434      const key = listKeys[listKeys.length - 1];
    435      batch(() => {
    436        dispatch(
    437          ac.AlsoToMain({
    438            type: at.WIDGETS_LISTS_UPDATE,
    439            data: { lists: updatedLists },
    440          })
    441        );
    442        dispatch(
    443          ac.AlsoToMain({
    444            type: at.WIDGETS_LISTS_CHANGE_SELECTED,
    445            data: key,
    446          })
    447        );
    448        dispatch(
    449          ac.OnlyToMain({
    450            type: at.WIDGETS_LISTS_USER_EVENT,
    451            data: { userAction: USER_ACTION_TYPES.LIST_DELETE },
    452          })
    453        );
    454      });
    455    }
    456    handleListInteraction();
    457  }
    458 
    459  function handleHideLists() {
    460    dispatch(
    461      ac.OnlyToMain({
    462        type: at.SET_PREF,
    463        data: {
    464          name: "widgets.lists.enabled",
    465          value: false,
    466        },
    467      })
    468    );
    469    handleListInteraction();
    470  }
    471 
    472  function handleCopyListToClipboard() {
    473    const currentList = lists[selected];
    474 
    475    if (!currentList) {
    476      return;
    477    }
    478 
    479    const { label, tasks = [], completed = [] } = currentList;
    480 
    481    const uncompleted = tasks.filter(task => !task.completed);
    482    const currentCompleted = tasks.filter(task => task.completed);
    483 
    484    // In order in include all items, we need to iterate through both current and completed tasks list and mark format all completed tasks accordingly.
    485    const formatted = [
    486      `List: ${label}`,
    487      `---`,
    488      ...uncompleted.map(task => `- [ ] ${task.value}`),
    489      ...currentCompleted.map(task => `- [x] ${task.value}`),
    490      ...completed.map(task => `- [x] ${task.value}`),
    491    ].join("\n");
    492 
    493    try {
    494      navigator.clipboard.writeText(formatted);
    495    } catch (err) {
    496      console.error("Copy failed", err);
    497    }
    498 
    499    dispatch(
    500      ac.OnlyToMain({
    501        type: at.WIDGETS_LISTS_USER_EVENT,
    502        data: { userAction: USER_ACTION_TYPES.LIST_COPY },
    503      })
    504    );
    505    handleListInteraction();
    506  }
    507 
    508  function handleLearnMore() {
    509    dispatch(
    510      ac.OnlyToMain({
    511        type: at.OPEN_LINK,
    512        data: {
    513          url: "https://support.mozilla.org/kb/firefox-new-tab-widgets",
    514        },
    515      })
    516    );
    517    handleListInteraction();
    518  }
    519 
    520  // Reset baseline only when switching lists
    521  useEffect(() => {
    522    prevCompletedCount.current = selectedList?.completed?.length || 0;
    523    // intentionally leaving out selectedList from dependency array
    524    // eslint-disable-next-line react-hooks/exhaustive-deps
    525  }, [selected]);
    526 
    527  useEffect(() => {
    528    if (selectedList) {
    529      const doneCount = selectedList.completed?.length || 0;
    530      const previous = Math.floor(prevCompletedCount.current / 5);
    531      const current = Math.floor(doneCount / 5);
    532 
    533      if (current > previous) {
    534        fireConfetti();
    535      }
    536      prevCompletedCount.current = doneCount;
    537    }
    538  }, [selectedList, fireConfetti, selected]);
    539 
    540  if (!lists) {
    541    return null;
    542  }
    543 
    544  // Enforce maximum count limits to lists
    545  const currentListsCount = Object.keys(lists).length;
    546  // Ensure a minimum of 1, but allow higher values from prefs
    547  const maxListsCount = Math.max(1, prefs[PREF_WIDGETS_LISTS_MAX_LISTS]);
    548  const isAtMaxListsLimit = currentListsCount >= maxListsCount;
    549 
    550  // Enforce maximum count limits to list items
    551  // The maximum applies to the total number of items (both incomplete and completed items)
    552  const currentSelectedListItemsCount =
    553    selectedList?.tasks.length + selectedList?.completed.length;
    554 
    555  // Ensure a minimum of 1, but allow higher values from prefs
    556  const maxListItemsCount = Math.max(
    557    1,
    558    prefs[PREF_WIDGETS_LISTS_MAX_LISTITEMS]
    559  );
    560 
    561  const isAtMaxListItemsLimit =
    562    currentSelectedListItemsCount >= maxListItemsCount;
    563 
    564  // Figure out if the selected list is the first (default) or a new one.
    565  // Index 0 → use "Task list"; any later index → use "New list".
    566  // Fallback to 0 if the selected id isn’t found.
    567  const listKeys = Object.keys(lists);
    568  const selectedIndex = Math.max(0, listKeys.indexOf(selected));
    569 
    570  const listNamePlaceholder =
    571    currentListsCount > 1 && selectedIndex !== 0
    572      ? "newtab-widget-lists-name-placeholder-new"
    573      : "newtab-widget-lists-name-placeholder-default";
    574 
    575  const nimbusBadgeEnabled = prefs.widgetsConfig?.listsBadgeEnabled;
    576  const nimbusBadgeLabel = prefs.widgetsConfig?.listsBadgeLabel;
    577  const nimbusBadgeTrainhopEnabled =
    578    prefs.trainhopConfig?.widgets?.listsBadgeEnabled;
    579  const nimbusBadgeTrainhopLabel =
    580    prefs.trainhopConfig?.widgets?.listsBadgeLabel;
    581 
    582  const badgeEnabled =
    583    (nimbusBadgeEnabled || nimbusBadgeTrainhopEnabled) ??
    584    prefs[PREF_WIDGETS_LISTS_BADGE_ENABLED] ??
    585    false;
    586 
    587  const badgeLabel =
    588    (nimbusBadgeLabel || nimbusBadgeTrainhopLabel) ??
    589    prefs[PREF_WIDGETS_LISTS_BADGE_LABEL] ??
    590    "";
    591 
    592  return (
    593    <article
    594      className={`lists ${isMaximized ? "is-maximized" : ""}`}
    595      ref={el => {
    596        listsRef.current = [el];
    597      }}
    598    >
    599      <div className="select-wrapper">
    600        <EditableText
    601          value={lists[selected]?.label || ""}
    602          onSave={handleListNameSave}
    603          isEditing={isEditing}
    604          setIsEditing={setIsEditing}
    605          onCancel={handleCancelNewList}
    606          type="list"
    607          maxLength={30}
    608          dataL10nId={listNamePlaceholder}
    609        >
    610          <moz-select ref={selectRef} value={selected}>
    611            {Object.entries(lists).map(([key, list]) => (
    612              <moz-option
    613                key={key}
    614                value={key}
    615                // On the first/initial list, use default name
    616                {...(list.label
    617                  ? { label: list.label }
    618                  : {
    619                      "data-l10n-id": "newtab-widget-lists-name-label-default",
    620                    })}
    621              />
    622            ))}
    623          </moz-select>
    624        </EditableText>
    625        {/* Hide the badge when user is editing task list title */}
    626        {!isEditing && badgeEnabled && badgeLabel && (
    627          <moz-badge
    628            data-l10n-id={(() => {
    629              if (badgeLabel === "New") {
    630                return "newtab-widget-lists-label-new";
    631              }
    632              if (badgeLabel === "Beta") {
    633                return "newtab-widget-lists-label-beta";
    634              }
    635              return "";
    636            })()}
    637          ></moz-badge>
    638        )}
    639        <moz-button
    640          className="lists-panel-button"
    641          iconSrc="chrome://global/skin/icons/more.svg"
    642          menuId="lists-panel"
    643          type="ghost"
    644        />
    645        <panel-list id="lists-panel">
    646          <panel-item
    647            data-l10n-id="newtab-widget-lists-menu-edit"
    648            onClick={() => setIsEditing(true)}
    649          ></panel-item>
    650          <panel-item
    651            {...(isAtMaxListsLimit ? { disabled: true } : {})}
    652            data-l10n-id="newtab-widget-lists-menu-create"
    653            onClick={() => handleCreateNewList()}
    654            className="create-list"
    655          ></panel-item>
    656          <panel-item
    657            data-l10n-id="newtab-widget-lists-menu-delete"
    658            onClick={() => handleDeleteList()}
    659          ></panel-item>
    660          <hr />
    661          <panel-item
    662            data-l10n-id="newtab-widget-lists-menu-copy"
    663            onClick={() => handleCopyListToClipboard()}
    664          ></panel-item>
    665          <panel-item
    666            data-l10n-id="newtab-widget-lists-menu-hide"
    667            onClick={() => handleHideLists()}
    668          ></panel-item>
    669          <panel-item
    670            className="learn-more"
    671            data-l10n-id="newtab-widget-lists-menu-learn-more"
    672            onClick={handleLearnMore}
    673          ></panel-item>
    674        </panel-list>
    675      </div>
    676      <div className="add-task-container">
    677        <span
    678          className={`icon icon-add ${isAtMaxListItemsLimit ? "icon-disabled" : ""}`}
    679        />
    680        <input
    681          ref={inputRef}
    682          onBlur={() => saveTask()}
    683          onChange={e => setNewTask(e.target.value)}
    684          value={newTask}
    685          data-l10n-id="newtab-widget-lists-input-add-an-item"
    686          className="add-task-input"
    687          onKeyDown={handleKeyDown}
    688          type="text"
    689          maxLength={100}
    690          disabled={isAtMaxListItemsLimit}
    691        />
    692      </div>
    693      <div className="task-list-wrapper">
    694        <moz-reorderable-list
    695          ref={reorderListRef}
    696          itemSelector="fieldset .task-type-tasks"
    697          dragSelector=".checkbox-wrapper:has(.task-label)"
    698        >
    699          <fieldset>
    700            {/* Incomplete List  */}
    701            {selectedList?.tasks.length >= 1 &&
    702              selectedList.tasks.map((task, index) => (
    703                <ListItem
    704                  type={TASK_TYPE.IN_PROGRESS}
    705                  task={task}
    706                  key={task.id}
    707                  updateTask={updateTask}
    708                  deleteTask={deleteTask}
    709                  moveTask={moveTask}
    710                  isValidUrl={isValidUrl}
    711                  isFirst={index === 0}
    712                  isLast={index === selectedList.tasks.length - 1}
    713                />
    714              ))}
    715            {/* Completed List */}
    716            {selectedList?.completed.length >= 1 && (
    717              <details
    718                className="completed-task-wrapper"
    719                open={selectedList?.tasks.length < 1}
    720              >
    721                <summary>
    722                  <span
    723                    data-l10n-id="newtab-widget-lists-completed-list"
    724                    data-l10n-args={JSON.stringify({
    725                      number: lists[selected]?.completed.length,
    726                    })}
    727                    className="completed-title"
    728                  ></span>
    729                </summary>
    730                {selectedList?.completed.map(completedTask => (
    731                  <ListItem
    732                    key={completedTask.id}
    733                    type={TASK_TYPE.COMPLETED}
    734                    task={completedTask}
    735                    deleteTask={deleteTask}
    736                    updateTask={updateTask}
    737                  />
    738                ))}
    739              </details>
    740            )}
    741          </fieldset>
    742        </moz-reorderable-list>
    743        {/* Empty State */}
    744        {selectedList?.tasks.length < 1 &&
    745          selectedList?.completed.length < 1 && (
    746            <div className="empty-list">
    747              <picture>
    748                <source
    749                  srcSet="chrome://newtab/content/data/content/assets/lists-empty-state-dark.svg"
    750                  media="(prefers-color-scheme: dark)"
    751                />
    752                <source
    753                  srcSet="chrome://newtab/content/data/content/assets/lists-empty-state-light.svg"
    754                  media="(prefers-color-scheme: light)"
    755                />
    756                <img width="100" height="100" alt="" />
    757              </picture>
    758              <p
    759                className="empty-list-text"
    760                data-l10n-id="newtab-widget-lists-empty-cta"
    761              ></p>
    762            </div>
    763          )}
    764      </div>
    765      <canvas className="confetti-canvas" ref={canvasRef} />
    766    </article>
    767  );
    768 }
    769 
    770 function ListItem({
    771  task,
    772  updateTask,
    773  deleteTask,
    774  moveTask,
    775  isValidUrl,
    776  type,
    777  isFirst = false,
    778  isLast = false,
    779 }) {
    780  const [isEditing, setIsEditing] = useState(false);
    781  const [exiting, setExiting] = useState(false);
    782  const isCompleted = type === TASK_TYPE.COMPLETED;
    783 
    784  const prefersReducedMotion =
    785    typeof window !== "undefined" &&
    786    typeof window.matchMedia === "function" &&
    787    window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    788 
    789  function handleCheckboxChange(e) {
    790    const { checked } = e.target;
    791    const updatedTask = { ...task, completed: checked };
    792    if (checked && !prefersReducedMotion) {
    793      setExiting(true);
    794    } else {
    795      updateTask(updatedTask, type);
    796    }
    797  }
    798 
    799  // When the CSS transition finishes, dispatch the real “completed = true”
    800  function handleTransitionEnd(e) {
    801    // only fire once for the exit:
    802    if (e.propertyName === "opacity" && exiting) {
    803      updateTask({ ...task, completed: true }, type);
    804      setExiting(false);
    805    }
    806  }
    807 
    808  function handleSave(newValue) {
    809    const trimmedTask = newValue.trimEnd();
    810    if (trimmedTask && trimmedTask !== task.value) {
    811      updateTask(
    812        { ...task, value: newValue, isUrl: isValidUrl(trimmedTask) },
    813        type
    814      );
    815      setIsEditing(false);
    816    }
    817  }
    818 
    819  function handleDelete() {
    820    deleteTask(task, type);
    821  }
    822 
    823  const taskLabel = task.isUrl ? (
    824    <a
    825      href={task.value}
    826      rel="noopener noreferrer"
    827      target="_blank"
    828      className="task-label"
    829      title={task.value}
    830    >
    831      {task.value}
    832    </a>
    833  ) : (
    834    <label
    835      className="task-label"
    836      title={task.value}
    837      htmlFor={`task-${task.id}`}
    838      onClick={() => setIsEditing(true)}
    839    >
    840      {task.value}
    841    </label>
    842  );
    843 
    844  return (
    845    <div
    846      className={`task-item task-type-${type} ${exiting ? " exiting" : ""}`}
    847      id={task.id}
    848      key={task.id}
    849      onTransitionEnd={handleTransitionEnd}
    850    >
    851      <div className="checkbox-wrapper" key={isEditing}>
    852        <input
    853          type="checkbox"
    854          onChange={handleCheckboxChange}
    855          checked={task.completed || exiting}
    856          id={`task-${task.id}`}
    857        />
    858        {isCompleted ? (
    859          taskLabel
    860        ) : (
    861          <EditableText
    862            isEditing={isEditing}
    863            setIsEditing={setIsEditing}
    864            value={task.value}
    865            onSave={handleSave}
    866            type="task"
    867          >
    868            {taskLabel}
    869          </EditableText>
    870        )}
    871      </div>
    872      <moz-button
    873        iconSrc="chrome://global/skin/icons/more.svg"
    874        menuId={`panel-task-${task.id}`}
    875        type="ghost"
    876      />
    877      <panel-list id={`panel-task-${task.id}`}>
    878        {!isCompleted && (
    879          <>
    880            {task.isUrl && (
    881              <panel-item
    882                data-l10n-id="newtab-widget-lists-input-menu-open-link"
    883                onClick={() => window.open(task.value, "_blank", "noopener")}
    884              ></panel-item>
    885            )}
    886            <panel-item
    887              {...(isFirst ? { disabled: true } : {})}
    888              onClick={() => moveTask(task, "up")}
    889              data-l10n-id="newtab-widget-lists-input-menu-move-up"
    890            ></panel-item>
    891            <panel-item
    892              {...(isLast ? { disabled: true } : {})}
    893              onClick={() => moveTask(task, "down")}
    894              data-l10n-id="newtab-widget-lists-input-menu-move-down"
    895            ></panel-item>
    896            <panel-item
    897              data-l10n-id="newtab-widget-lists-input-menu-edit"
    898              className="edit-item"
    899              onClick={() => setIsEditing(true)}
    900            ></panel-item>
    901          </>
    902        )}
    903        <panel-item
    904          data-l10n-id="newtab-widget-lists-input-menu-delete"
    905          className="delete-item"
    906          onClick={handleDelete}
    907        ></panel-item>
    908      </panel-list>
    909    </div>
    910  );
    911 }
    912 
    913 function EditableText({
    914  value,
    915  isEditing,
    916  setIsEditing,
    917  onSave,
    918  onCancel,
    919  children,
    920  type,
    921  dataL10nId = null,
    922  maxLength = 100,
    923 }) {
    924  const [tempValue, setTempValue] = useState(value);
    925  const inputRef = useRef(null);
    926 
    927  // True if tempValue is empty, null/undefined, or only whitespace
    928  const showPlaceholder = (tempValue ?? "").trim() === "";
    929 
    930  useEffect(() => {
    931    if (isEditing) {
    932      inputRef.current?.focus();
    933    } else {
    934      setTempValue(value);
    935    }
    936  }, [isEditing, value]);
    937 
    938  function handleKeyDown(e) {
    939    if (e.key === "Enter") {
    940      onSave(tempValue.trim());
    941      setIsEditing(false);
    942    } else if (e.key === "Escape") {
    943      setIsEditing(false);
    944      setTempValue(value);
    945      onCancel?.();
    946    }
    947  }
    948 
    949  function handleOnBlur() {
    950    onSave(tempValue.trim());
    951    setIsEditing(false);
    952  }
    953 
    954  return isEditing ? (
    955    <input
    956      className={`edit-${type}`}
    957      ref={inputRef}
    958      type="text"
    959      value={tempValue}
    960      maxLength={maxLength}
    961      onChange={event => setTempValue(event.target.value)}
    962      onBlur={handleOnBlur}
    963      onKeyDown={handleKeyDown}
    964      // Note that if a user has a custom name set, it will override the placeholder
    965      {...(showPlaceholder && dataL10nId ? { "data-l10n-id": dataL10nId } : {})}
    966    />
    967  ) : (
    968    [children]
    969  );
    970 }
    971 
    972 export { Lists };