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 };