/* 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) && (
{showInlineAddButton && renderAddTaskButton()}
saveTask()} onChange={e => setNewTask(e.target.value)} value={newTask} data-l10n-id="newtab-widget-lists-input-add-an-item2" data-l10n-attrs="placeholder,aria-label" className="add-task-input" onKeyDown={handleKeyDown} type="text" maxLength={100} disabled={isAtMaxListItemsLimit} />
)}
{showEmptyState ? (
) : (
{isMediumSize ? hasIncompleteTasks && selectedList.tasks.map((task, index) => ( )) : hasIncompleteTasks && selectedList.tasks.map((task, index) => ( ))} {showCompletedTasks && (
{selectedList.completed.map(completedTask => ( ))}
)}
)}
); } 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 (
{isCompleted ? ( taskLabel ) : ( {taskLabel} )}
{!isCompleted && ( <> {task.isUrl && ( window.open(task.value, "_blank", "noopener")} > )} moveTask(task, "up")} data-l10n-id="newtab-widget-lists-input-menu-move-up" > moveTask(task, "down")} data-l10n-id="newtab-widget-lists-input-menu-move-down" > setIsEditing(true)} > )}
); } 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 };