/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, {
useRef,
useState,
useEffect,
useCallback,
useMemo,
} from "react";
import { useSelector, batch } from "react-redux";
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import { WIDGET_REGISTRY, resolveWidgetSize } from "common/WidgetsRegistry.mjs";
import { WidgetCelebration } from "../WidgetCelebration";
import { useWidgetCelebration } from "../useWidgetCelebration";
import { SizeSubmenu } from "../SizeSubmenu";
import { WidgetMenuFooter } from "../WidgetMenuFooter";
import { useWidgetTelemetry } from "../useWidgetTelemetry";
const TASK_TYPE = {
IN_PROGRESS: "tasks",
COMPLETED: "completed",
};
const USER_ACTION_TYPES = {
CHANGE_SIZE: "change_size",
LIST_COPY: "list_copy",
LIST_CREATE: "list_create",
LIST_EDIT: "list_edit",
LIST_DELETE: "list_delete",
TASK_CREATE: "task_create",
TASK_EDIT: "task_edit",
TASK_DELETE: "task_delete",
TASK_COMPLETE: "task_complete",
};
const PREF_WIDGETS_LISTS_MAX_LISTS = "widgets.lists.maxLists";
const PREF_WIDGETS_LISTS_MAX_LISTITEMS = "widgets.lists.maxListItems";
const PREF_WIDGETS_LISTS_BADGE_ENABLED = "widgets.lists.badge.enabled";
const PREF_WIDGETS_LISTS_BADGE_LABEL = "widgets.lists.badge.label";
const PREF_WIDGETS_LISTS_SIZE = "widgets.lists.size";
const PREF_NOVA_ENABLED = "nova.enabled";
const LISTS_EMPTY_STATE_ILLUSTRATION =
"chrome://newtab/content/data/content/assets/lists-empty-state-comet.svg";
const LISTS_CELEBRATION = {
headlineL10nId: "newtab-widget-lists-celebration-headline",
illustrationSrc:
"chrome://newtab/content/data/content/assets/firefox-motion-head-pop-up-no-bg.svg",
subheadL10nId: "newtab-widget-lists-celebration-subhead",
};
const ENABLE_COMPACT_COMPLETED_PREVIEW = false;
const getCompactPreviewState = ({
enableCompactCompletedPreview,
isCompactMediumSize,
selectedList,
showCompactCompleted,
}) => {
const hasIncompleteTasks = selectedList?.tasks.length >= 1;
const hasCompletedTasks = selectedList?.completed.length >= 1;
const hasAnyTasks = hasIncompleteTasks || hasCompletedTasks;
const isShowingCompactCompleted =
enableCompactCompletedPreview &&
isCompactMediumSize &&
hasCompletedTasks &&
(showCompactCompleted || !hasIncompleteTasks);
let hasVisibleTasks = hasAnyTasks;
if (isCompactMediumSize) {
hasVisibleTasks = isShowingCompactCompleted
? hasCompletedTasks
: hasIncompleteTasks;
}
return {
hasIncompleteTasks,
hasCompletedTasks,
hasAnyTasks,
hasVisibleTasks,
isShowingCompactCompleted,
compactPreviewTasks: isShowingCompactCompleted
? selectedList?.completed
: selectedList?.tasks,
compactPreviewTaskType: isShowingCompactCompleted
? TASK_TYPE.COMPLETED
: TASK_TYPE.IN_PROGRESS,
};
};
const renderListSwitcherOrTitle = ({
currentListsCount,
lists,
onSelect,
selected,
defaultListLabelL10nId,
}) => {
const selectedLabel = lists[selected]?.label;
if (currentListsCount > 1) {
return (
{selectedLabel || null}
{Object.entries(lists).map(([key, list]) => (
onSelect(key)}
type="checkbox"
{...(list.label
? {}
: {
"data-l10n-id": defaultListLabelL10nId,
})}
>
{list.label || null}
))}
);
}
return (
{selectedLabel || null}
);
};
// eslint-disable-next-line complexity, max-statements
function Lists({
dispatch,
handleUserInteraction,
isMaximized,
widgetsMayBeMaximized,
widgetEnabledMap,
}) {
const prefs = useSelector(state => state.Prefs.values);
const { selected, lists } = useSelector(state => state.ListsWidget);
const [newTask, setNewTask] = useState("");
const [isAddingTask, setIsAddingTask] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isCreatingNewList, setIsCreatingNewList] = useState(false);
const [showCompactCompleted, setShowCompactCompleted] = useState(false);
const selectedList = useMemo(() => lists[selected], [lists, selected]);
const novaEnabled = prefs[PREF_NOVA_ENABLED];
const listsWidget = WIDGET_REGISTRY.find(w => w.id === "lists");
const getListsWidgetSize = () => {
if (novaEnabled) {
const resolvedSize = resolveWidgetSize(listsWidget, prefs);
return resolvedSize === "small" ? "medium" : resolvedSize;
}
const requestedSize = prefs[PREF_WIDGETS_LISTS_SIZE];
if (requestedSize === "large" || requestedSize === "medium") {
return requestedSize;
}
if (requestedSize === "small") {
return "medium";
}
if (!widgetsMayBeMaximized) {
return "large";
}
return isMaximized ? "large" : "medium";
};
const widgetSize = getListsWidgetSize();
const isMediumSize = widgetSize === "medium";
const inputRef = useRef(null);
const reorderListRef = useRef(null);
const widgetRef = useRef(null);
const {
celebrationFrame,
celebrationId,
completeCelebration,
isCelebrating,
triggerCelebration,
} = useWidgetCelebration(widgetRef);
// Pre-hook code reported widget_size as "medium" when the widgets row is
// not maximizable, regardless of the resolved widgetSize. Preserve that.
const telemetrySize = widgetsMayBeMaximized ? widgetSize : "medium";
const { impressionRef, recordUserAction } = useWidgetTelemetry({
dispatch,
widget: listsWidget,
widgetSize: telemetrySize,
legacyImpressionTypes: [at.WIDGETS_LISTS_USER_IMPRESSION],
legacyUserEventType: at.WIDGETS_LISTS_USER_EVENT,
});
const handleListInteraction = useCallback(
() => handleUserInteraction("lists"),
[handleUserInteraction]
);
const handleSelectList = useCallback(
listId => {
setIsEditing(false);
setIsCreatingNewList(false);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_CHANGE_SELECTED,
data: listId,
})
);
handleListInteraction();
},
[dispatch, handleListInteraction]
);
// store selectedList with useMemo so it isn't re-calculated on every re-render
const isValidUrl = useCallback(str => URL.canParse(str), []);
const reorderLists = useCallback(
(draggedElement, targetElement, before = false) => {
const draggedIndex = selectedList.tasks.findIndex(
({ id }) => id === draggedElement.id
);
const targetIndex = selectedList.tasks.findIndex(
({ id }) => id === targetElement.id
);
// return early is index is not found
if (
draggedIndex === -1 ||
targetIndex === -1 ||
draggedIndex === targetIndex
) {
return;
}
const reordered = [...selectedList.tasks];
const [removed] = reordered.splice(draggedIndex, 1);
const insertIndex = before ? targetIndex : targetIndex + 1;
reordered.splice(
insertIndex > draggedIndex ? insertIndex - 1 : insertIndex,
0,
removed
);
const updatedLists = {
...lists,
[selected]: {
...selectedList,
tasks: reordered,
},
};
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
handleListInteraction();
},
[lists, selected, selectedList, dispatch, handleListInteraction]
);
const moveTask = useCallback(
(task, direction) => {
const index = selectedList.tasks.findIndex(({ id }) => id === task.id);
// guardrail a falsey index
if (index === -1) {
return;
}
const targetIndex = direction === "up" ? index - 1 : index + 1;
const before = direction === "up";
const targetTask = selectedList.tasks[targetIndex];
if (targetTask) {
reorderLists(task, targetTask, before);
}
},
[selectedList, reorderLists]
);
useEffect(() => {
const reorderNode = reorderListRef.current;
function handleReorder(e) {
const { draggedElement, targetElement, position } = e.detail;
reorderLists(draggedElement, targetElement, position === -1);
}
reorderNode?.addEventListener("reorder", handleReorder);
return () => {
reorderNode?.removeEventListener("reorder", handleReorder);
};
}, [reorderLists]);
useEffect(() => {
if (isAddingTask) {
inputRef.current?.focus();
}
}, [isAddingTask]);
useEffect(() => {
setShowCompactCompleted(false);
}, [selected]);
useEffect(() => {
if (!selectedList?.completed?.length) {
setShowCompactCompleted(false);
}
}, [selectedList]);
function saveTask() {
const trimmedTask = newTask.trimEnd();
// only add new task if it has a length, to avoid creating empty tasks
if (trimmedTask) {
const formattedTask = {
value: trimmedTask,
completed: false,
created: Date.now(),
id: crypto.randomUUID(),
isUrl: isValidUrl(trimmedTask),
};
const updatedLists = {
...lists,
[selected]: {
...selectedList,
tasks: [formattedTask, ...lists[selected].tasks],
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
recordUserAction(USER_ACTION_TYPES.TASK_CREATE, {
source: "widget",
legacy: true,
});
});
setNewTask("");
}
setIsAddingTask(false);
handleListInteraction();
}
function updateTask(updatedTask, type) {
const isCompletedType = type === TASK_TYPE.COMPLETED;
const isNowCompleted = updatedTask.completed;
let newTasks = selectedList.tasks;
let newCompleted = selectedList.completed;
let userAction;
// If the task is in the completed array and is now unchecked
const shouldMoveToTasks = isCompletedType && !isNowCompleted;
// If we're moving the task from tasks → completed (user checked it)
const shouldMoveToCompleted = !isCompletedType && isNowCompleted;
// Move task from completed -> task
if (shouldMoveToTasks) {
newCompleted = selectedList.completed.filter(
task => task.id !== updatedTask.id
);
newTasks = [...selectedList.tasks, updatedTask];
// Move task to completed, but also create local version
} else if (shouldMoveToCompleted) {
newTasks = selectedList.tasks.filter(task => task.id !== updatedTask.id);
newCompleted = [...selectedList.completed, updatedTask];
userAction = USER_ACTION_TYPES.TASK_COMPLETE;
if (!newTasks.length && newCompleted.length) {
triggerCelebration();
}
} else {
const targetKey = isCompletedType ? "completed" : "tasks";
const updatedArray = selectedList[targetKey].map(task =>
task.id === updatedTask.id ? updatedTask : task
);
// In-place update: toggle checkbox (but stay in same array or edit name)
if (targetKey === "tasks") {
newTasks = updatedArray;
} else {
newCompleted = updatedArray;
}
userAction = USER_ACTION_TYPES.TASK_EDIT;
}
const updatedLists = {
...lists,
[selected]: {
...selectedList,
tasks: newTasks,
completed: newCompleted,
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
if (userAction) {
recordUserAction(userAction, {
source: "widget",
legacy: true,
alsoToMain: true,
});
}
});
handleListInteraction();
}
function deleteTask(task, type) {
const selectedTasks = lists[selected][type];
const updatedTasks = selectedTasks.filter(({ id }) => id !== task.id);
const updatedLists = {
...lists,
[selected]: {
...selectedList,
[type]: updatedTasks,
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
recordUserAction(USER_ACTION_TYPES.TASK_DELETE, {
source: "widget",
legacy: true,
});
});
handleListInteraction();
}
function handleKeyDown(e) {
if (e.key === "Enter" && document.activeElement === inputRef.current) {
saveTask();
} else if (
e.key === "Escape" &&
document.activeElement === inputRef.current
) {
// Clear out the input when esc is pressed
setNewTask("");
setIsAddingTask(false);
}
}
function handleShowTaskInput() {
setIsAddingTask(true);
handleListInteraction();
}
function handleListNameSave(newLabel) {
const trimmedLabel = newLabel.trimEnd();
if (isCreatingNewList) {
setIsCreatingNewList(false);
if (!trimmedLabel) {
handleListInteraction();
return;
}
const id = crypto.randomUUID();
const newLists = {
...lists,
[id]: {
label: trimmedLabel,
tasks: [],
completed: [],
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: newLists },
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_CHANGE_SELECTED,
data: id,
})
);
recordUserAction(USER_ACTION_TYPES.LIST_CREATE, {
source: "widget",
legacy: true,
});
});
handleListInteraction();
return;
}
if (trimmedLabel && trimmedLabel !== selectedList?.label) {
const updatedLists = {
...lists,
[selected]: {
...selectedList,
label: trimmedLabel,
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
recordUserAction(USER_ACTION_TYPES.LIST_EDIT, {
source: "widget",
legacy: true,
});
});
setIsEditing(false);
handleListInteraction();
}
}
function handleCreateNewList() {
setIsCreatingNewList(true);
setIsEditing(true);
handleListInteraction();
}
function handleCancelNewList() {
if (isCreatingNewList) {
setIsCreatingNewList(false);
}
handleListInteraction();
}
function handleDeleteList() {
let updatedLists = { ...lists };
if (updatedLists[selected]) {
delete updatedLists[selected];
// if this list was the last one created, add a new list as default
if (Object.keys(updatedLists)?.length === 0) {
updatedLists = {
[crypto.randomUUID()]: {
label: "",
tasks: [],
completed: [],
},
};
}
const listKeys = Object.keys(updatedLists);
const key = listKeys[listKeys.length - 1];
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_CHANGE_SELECTED,
data: key,
})
);
recordUserAction(USER_ACTION_TYPES.LIST_DELETE, {
source: "widget",
legacy: true,
});
});
}
handleListInteraction();
}
function handleCopyListToClipboard() {
const currentList = lists[selected];
if (!currentList) {
return;
}
const { label, tasks = [], completed = [] } = currentList;
const uncompleted = tasks.filter(task => !task.completed);
const currentCompleted = tasks.filter(task => task.completed);
// In order in include all items, we need to iterate through both current and completed tasks list and mark format all completed tasks accordingly.
const formatted = [
`List: ${label}`,
`---`,
...uncompleted.map(task => `- [ ] ${task.value}`),
...currentCompleted.map(task => `- [x] ${task.value}`),
...completed.map(task => `- [x] ${task.value}`),
].join("\n");
try {
navigator.clipboard.writeText(formatted);
} catch (err) {
console.error("Copy failed", err);
}
recordUserAction(USER_ACTION_TYPES.LIST_COPY, {
source: "widget",
legacy: true,
});
handleListInteraction();
}
const handleChangeSize = useCallback(
size => {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: { name: PREF_WIDGETS_LISTS_SIZE, value: size },
})
);
recordUserAction(USER_ACTION_TYPES.CHANGE_SIZE, {
source: "context_menu",
value: size,
size,
});
});
},
[dispatch, recordUserAction]
);
useEffect(() => {
setIsAddingTask(false);
}, [selected]);
if (!lists) {
return null;
}
// Enforce maximum count limits to lists
const currentListsCount = Object.keys(lists).length;
// Ensure a minimum of 1, but allow higher values from prefs
const maxListsCount = Math.max(1, prefs[PREF_WIDGETS_LISTS_MAX_LISTS]);
const isAtMaxListsLimit = currentListsCount >= maxListsCount;
// Enforce maximum count limits to list items
// The maximum applies to the total number of items (both incomplete and completed items)
const currentSelectedListItemsCount =
selectedList?.tasks.length + selectedList?.completed.length;
// Ensure a minimum of 1, but allow higher values from prefs
const maxListItemsCount = Math.max(
1,
prefs[PREF_WIDGETS_LISTS_MAX_LISTITEMS]
);
const isAtMaxListItemsLimit =
currentSelectedListItemsCount >= maxListItemsCount;
// Figure out if the selected list is the first (default) or a new one.
// Index 0 → use "Task list"; any later index → use "New list".
// Fallback to 0 if the selected id isn’t found.
const listKeys = Object.keys(lists);
const selectedIndex = Math.max(0, listKeys.indexOf(selected));
const listNamePlaceholder =
currentListsCount > 1 && selectedIndex !== 0
? "newtab-widget-lists-name-placeholder-new2"
: "newtab-widget-lists-name-placeholder-checklist2";
const nimbusBadgeEnabled = prefs.widgetsConfig?.listsBadgeEnabled;
const nimbusBadgeLabel = prefs.widgetsConfig?.listsBadgeLabel;
const nimbusBadgeTrainhopEnabled =
prefs.trainhopConfig?.widgets?.listsBadgeEnabled;
const nimbusBadgeTrainhopLabel =
prefs.trainhopConfig?.widgets?.listsBadgeLabel;
const badgeEnabled =
(nimbusBadgeEnabled || nimbusBadgeTrainhopEnabled) ??
prefs[PREF_WIDGETS_LISTS_BADGE_ENABLED] ??
false;
const badgeLabel =
(nimbusBadgeLabel || nimbusBadgeTrainhopLabel) ??
prefs[PREF_WIDGETS_LISTS_BADGE_LABEL] ??
"";
const {
hasIncompleteTasks,
hasCompletedTasks,
hasAnyTasks,
hasVisibleTasks,
isShowingCompactCompleted,
} = getCompactPreviewState({
enableCompactCompletedPreview: ENABLE_COMPACT_COMPLETED_PREVIEW,
isCompactMediumSize: isMediumSize,
selectedList,
showCompactCompleted,
});
const showCompactPopulatedState = isMediumSize && hasAnyTasks;
const showCompletedTasks = !isMediumSize && hasCompletedTasks;
const showInlineAddButton = !showCompactPopulatedState;
const showHeaderAddButton = showCompactPopulatedState;
const showEmptyState = !hasAnyTasks && !isAddingTask;
const defaultListLabelL10nId = "newtab-widget-lists-name-default";
const listsSizeClass =
widgetSize === "large" ? "large-widget" : "medium-widget compact-widget";
function renderAddTaskButton(iconOnly = false) {
return (
);
}
return (
{
widgetRef.current = el;
impressionRef(el);
}}
>
{isCelebrating && celebrationFrame ? (
) : null}
{renderListSwitcherOrTitle({
currentListsCount,
lists,
onSelect: handleSelectList,
selected,
defaultListLabelL10nId,
})}
{/* Hide the badge when user is editing task list title */}
{!isEditing && badgeEnabled && badgeLabel && !isMediumSize && (
{
if (badgeLabel === "New") {
return "newtab-widget-lists-label-new";
}
if (badgeLabel === "Beta") {
return "newtab-widget-lists-label-beta";
}
return "";
})()}
>
)}
{showHeaderAddButton && renderAddTaskButton(true)}
{ENABLE_COMPACT_COMPLETED_PREVIEW &&
isMediumSize &&
hasCompletedTasks && (
)}
setIsEditing(true)}
>
handleCreateNewList()}
className="create-list"
>
handleDeleteList()}
>
handleCopyListToClipboard()}
>
)
}
/>
{(showInlineAddButton || isAddingTask) && (
)}
{showEmptyState ? (
) : (
)}
);
}
function ListItem({
task,
updateTask,
deleteTask,
moveTask,
isValidUrl,
type,
isFirst = false,
isLast = false,
}) {
const [isEditing, setIsEditing] = useState(false);
const [exiting, setExiting] = useState(false);
const isCompleted = type === TASK_TYPE.COMPLETED;
const prefersReducedMotion =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function handleCheckboxChange(e) {
const { checked } = e.target;
const updatedTask = { ...task, completed: checked };
if (checked && !prefersReducedMotion) {
setExiting(true);
} else {
updateTask(updatedTask, type);
}
}
// When the CSS transition finishes, dispatch the real “completed = true”
function handleTransitionEnd(e) {
// only fire once for the exit:
if (e.propertyName === "opacity" && exiting) {
updateTask({ ...task, completed: true }, type);
setExiting(false);
}
}
function handleSave(newValue) {
const trimmedTask = newValue.trimEnd();
if (trimmedTask && trimmedTask !== task.value) {
updateTask(
{ ...task, value: newValue, isUrl: isValidUrl(trimmedTask) },
type
);
setIsEditing(false);
}
}
function handleDelete() {
deleteTask(task, type);
}
const taskLabel = task.isUrl ? (
{task.value}
) : (
);
return (
);
}
function EditableText({
value,
isEditing,
setIsEditing,
onSave,
onCancel,
children,
type,
dataL10nId = null,
ariaLabelL10nId = null,
maxLength = 100,
saveOnBlur = true,
}) {
const [tempValue, setTempValue] = useState(value);
const inputRef = useRef(null);
const wrapperRef = useRef(null);
const previousFocusRef = useRef(null);
const cancellingRef = useRef(false);
// True if tempValue is empty, null/undefined, or only whitespace
const showPlaceholder = (tempValue ?? "").trim() === "";
const inputL10nId =
showPlaceholder && dataL10nId ? dataL10nId : ariaLabelL10nId;
const inputL10nAttrs =
showPlaceholder && dataL10nId ? "placeholder,aria-label" : "aria-label";
useEffect(() => {
if (isEditing) {
cancellingRef.current = false;
previousFocusRef.current = document.activeElement;
inputRef.current?.focus();
}
}, [isEditing]);
useEffect(() => {
if (!isEditing) {
setTempValue(value);
}
}, [isEditing, value]);
const handleRestoreFocus = () => {
const target = previousFocusRef.current;
if (target && document.contains(target)) {
target.focus();
}
};
function handleKeyDown(e) {
if (e.key === "Enter") {
onSave(tempValue.trim());
setIsEditing(false);
} else if (e.key === "Escape") {
cancellingRef.current = true;
setIsEditing(false);
setTempValue(value);
onCancel?.();
handleRestoreFocus();
}
}
function handleOnBlur(e) {
// Skip save when cancelling via Escape or the clear button — the
// restored focus would otherwise trip handleOnBlur into saving.
if (cancellingRef.current) {
cancellingRef.current = false;
return;
}
// Skip save when focus moved to the cancel button so its click handler can run.
if (e.relatedTarget && wrapperRef.current?.contains(e.relatedTarget)) {
return;
}
if (!saveOnBlur) {
if (tempValue.trim()) {
return;
}
setIsEditing(false);
onCancel?.();
return;
}
onSave(tempValue.trim());
setIsEditing(false);
}
function handleClear() {
cancellingRef.current = true;
setIsEditing(false);
setTempValue(value);
onCancel?.();
handleRestoreFocus();
}
const input = (
setTempValue(event.target.value)}
onBlur={handleOnBlur}
onKeyDown={handleKeyDown}
{...(inputL10nId ? { "data-l10n-id": inputL10nId } : {})}
{...(inputL10nId ? { "data-l10n-attrs": inputL10nAttrs } : {})}
/>
);
if (!isEditing) {
return [children];
}
if (type === "list") {
return (
{input}
);
}
return input;
}
export { Lists };