From 10d52697a23d60159b16b8df76ca945ecbbb0377 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:58:57 +0000 Subject: [PATCH 1/3] feat: add sortable shortcuts with drag-and-drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @dnd-kit/core, @dnd-kit/sortable, and @dnd-kit/utilities dependencies - Implement drag-and-drop sorting for shortcuts list (horizontal layout) - Implement drag-and-drop sorting for form inputs (vertical layout) - Sorting only enabled for custom links (manual mode) - Visual feedback during drag with reduced opacity - Changes persist automatically when reordering 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Chris Bongers --- .../newtab/ShortcutLinks/ShortcutLinks.tsx | 10 +- .../ShortcutLinks/ShortcutLinksItem.tsx | 22 ++++ .../ShortcutLinks/ShortcutLinksList.tsx | 61 ++++++++- packages/shared/package.json | 3 + .../shortcuts/components/LinksForm.tsx | 123 +++++++++++++++++- 5 files changed, 216 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index a9cead6742..4a854c2dd4 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -24,7 +24,8 @@ export default function ShortcutLinks({ shouldUseListFeedLayout, }: ShortcutLinksProps): ReactElement { const { openModal } = useLazyModal(); - const { showTopSites, toggleShowTopSites } = useSettingsContext(); + const { showTopSites, toggleShowTopSites, updateCustomLinks } = + useSettingsContext(); const { logEvent } = useLogContext(); const { shortcutLinks, @@ -32,6 +33,7 @@ export default function ShortcutLinks({ isTopSiteActive, showGetStarted, hideShortcuts, + isManual, } = useShortcutLinks(); const { showPermissionsModal } = useShortcuts(); @@ -88,6 +90,10 @@ export default function ShortcutLinks({ }); }; + const onReorder = (reorderedLinks: string[]) => { + updateCustomLinks(reorderedLinks); + }; + if (!showTopSites) { return <>; } @@ -110,6 +116,8 @@ export default function ShortcutLinks({ showTopSites, toggleShowTopSites, hasCheckedPermission, + onReorder, + isManual, }} /> ))} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx index 49c4053582..9d6f08a35d 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx @@ -6,6 +6,8 @@ import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { combinedClicks } from '@dailydotdev/shared/src/lib/click'; import { apiUrl } from '@dailydotdev/shared/src/lib/config'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; const pixelRatio = globalThis?.window.devicePixelRatio ?? 1; const iconSize = Math.round(24 * pixelRatio); @@ -51,8 +53,28 @@ export function ShortcutLinksItem({ onLinkClick: () => void; }): ReactElement { const cleanUrl = url.replace(/http(s)?(:)?(\/\/)?|(\/\/)?(www\.)?/g, ''); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: url }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + return ( void; hasCheckedPermission?: boolean; + onReorder?: (links: string[]) => void; + isManual?: boolean; } const placeholderLinks = Array.from({ length: 6 }).map((_, index) => index); @@ -44,9 +61,32 @@ export function ShortcutLinksList({ toggleShowTopSites, shortcutLinks, shouldUseListFeedLayout, + onReorder, + isManual, }: ShortcutLinksListProps): ReactElement { const hasShortcuts = shortcutLinks?.length > 0; + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id || !onReorder || !isManual) { + return; + } + + const oldIndex = shortcutLinks.indexOf(active.id as string); + const newIndex = shortcutLinks.indexOf(over.id as string); + + const reorderedLinks = arrayMove(shortcutLinks, oldIndex, newIndex); + onReorder(reorderedLinks); + }; + const options = [ { icon: , @@ -60,7 +100,7 @@ export function ShortcutLinksList({ }, ]; - return ( + const content = (
); + + if (hasShortcuts && isManual && onReorder) { + return ( + + + {content} + + + ); + } + + return content; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 2a9cffbbef..03db87d7d5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -101,6 +101,9 @@ "typescript": "5.6.3" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^9.0.1", + "@dnd-kit/utilities": "^3.2.2", "@growthbook/growthbook": "0.26.0", "@growthbook/growthbook-react": "^0.17.0", "@hookform/resolvers": "5.2.2", diff --git a/packages/shared/src/features/shortcuts/components/LinksForm.tsx b/packages/shared/src/features/shortcuts/components/LinksForm.tsx index 981c149350..88cb04937b 100644 --- a/packages/shared/src/features/shortcuts/components/LinksForm.tsx +++ b/packages/shared/src/features/shortcuts/components/LinksForm.tsx @@ -2,15 +2,86 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; import { TextField } from '../../../components/fields/TextField'; import { useShortcutLinks } from '../hooks/useShortcutLinks'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; const limit = 8; const list = Array(limit).fill(0); +interface SortableTextFieldProps { + index: number; + value: string; + isFormReadonly: boolean; + validInputs: Record; + onChange: (i: number, isValid: boolean) => void; +} + +function SortableTextField({ + index, + value, + isFormReadonly, + validInputs, + onChange, +}: SortableTextFieldProps): ReactElement { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: `shortcutLink-${index}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ onChange(index, isValid)} + placeholder="http://example.com" + className={{ + input: isFormReadonly ? '!text-text-quaternary' : undefined, + }} + /> +
+ ); +} + export function LinksForm(): ReactElement { const { formLinks = [], isManual } = useShortcutLinks(); const [validInputs, setValidInputs] = useState({}); + const [orderedLinks, setOrderedLinks] = useState(formLinks); const isFormReadonly = isManual === false; + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const onChange = (i: number, isValid: boolean) => { if (validInputs[i] === isValid) { return; @@ -19,6 +90,56 @@ export function LinksForm(): ReactElement { setValidInputs((state) => ({ ...state, [i]: isValid })); }; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id || isFormReadonly) { + return; + } + + const activeIndex = parseInt( + (active.id as string).replace('shortcutLink-', ''), + 10, + ); + const overIndex = parseInt( + (over.id as string).replace('shortcutLink-', ''), + 10, + ); + + setOrderedLinks((items) => arrayMove(items, activeIndex, overIndex)); + }; + + const displayLinks = orderedLinks.length > 0 ? orderedLinks : formLinks; + + if (!isFormReadonly && isManual) { + return ( + + `shortcutLink-${i}`)} + strategy={verticalListSortingStrategy} + > +
+ {list.map((_, i) => ( + + ))} +
+
+
+ ); + } + return (
{list.map((_, i) => ( @@ -31,7 +152,7 @@ export function LinksForm(): ReactElement { autoComplete="off" fieldType="tertiary" label="Add shortcuts" - value={formLinks[i]} + value={displayLinks[i]} valid={validInputs[i] !== false} hint={validInputs[i] === false && 'Must be a valid HTTP/S link'} readOnly={isFormReadonly} From a2ed2405bc89e93490a9565499f957d51fada999 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 6 Jan 2026 15:18:01 +0200 Subject: [PATCH 2/3] fix: small fixes --- packages/extension/package.json | 3 + .../ShortcutLinks/ShortcutLinksItem.tsx | 17 ++++- packages/shared/package.json | 2 +- .../shortcuts/components/LinksForm.tsx | 74 +++++++++++++------ pnpm-lock.yaml | 65 ++++++++++++++++ 5 files changed, 135 insertions(+), 26 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 26f906ec44..d8ce54fc0d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -12,6 +12,9 @@ "prettier": "@dailydotdev/prettier-config", "dependencies": { "@dailydotdev/shared": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@kickass-coderz/react": "^0.0.4", "@tanstack/react-query": "^5.80.5", "@tanstack/react-query-devtools": "^5.80.5", diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx index 9d6f08a35d..d4ba371c84 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; +import classNames from 'classnames'; +import { MenuIcon, PlusIcon } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { combinedClicks } from '@dailydotdev/shared/src/lib/click'; @@ -66,7 +67,6 @@ export function ShortcutLinksItem({ const style = { transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.5 : 1, }; return ( @@ -78,14 +78,23 @@ export function ShortcutLinksItem({ href={url} rel="noopener noreferrer" {...combinedClicks(onLinkClick)} - className="group mr-4 flex flex-col items-center" + className={classNames( + 'group relative mr-4 flex cursor-grab flex-col items-center active:cursor-grabbing', + isDragging && 'opacity-50', + )} > -
+
{url} +
+ +
{cleanUrl} diff --git a/packages/shared/package.json b/packages/shared/package.json index 03db87d7d5..cc4fcf4590 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -102,7 +102,7 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^9.0.1", + "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@growthbook/growthbook": "0.26.0", "@growthbook/growthbook-react": "^0.17.0", diff --git a/packages/shared/src/features/shortcuts/components/LinksForm.tsx b/packages/shared/src/features/shortcuts/components/LinksForm.tsx index 88cb04937b..c58a0e963d 100644 --- a/packages/shared/src/features/shortcuts/components/LinksForm.tsx +++ b/packages/shared/src/features/shortcuts/components/LinksForm.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; -import { TextField } from '../../../components/fields/TextField'; -import { useShortcutLinks } from '../hooks/useShortcutLinks'; +import classNames from 'classnames'; import { DndContext, closestCenter, @@ -19,6 +18,14 @@ import { useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { TextField } from '../../../components/fields/TextField'; +import { MenuIcon } from '../../../components/icons'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { useShortcutLinks } from '../hooks/useShortcutLinks'; const limit = 8; const list = Array(limit).fill(0); @@ -38,8 +45,14 @@ function SortableTextField({ validInputs, onChange, }: SortableTextFieldProps): ReactElement { - const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id: `shortcutLink-${index}` }); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `shortcutLink-${index}` }); const style = { transform: CSS.Transform.toString(transform), @@ -47,24 +60,43 @@ function SortableTextField({ }; return ( -
- onChange(index, isValid)} - placeholder="http://example.com" - className={{ - input: isFormReadonly ? '!text-text-quaternary' : undefined, - }} +
+
); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ae8d88c2b..f11a8789ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,15 @@ importers: '@dailydotdev/shared': specifier: workspace:* version: link:../shared + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^8.0.0 + version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@kickass-coderz/react': specifier: ^0.0.4 version: 0.0.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -390,6 +399,15 @@ importers: packages/shared: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^8.0.0 + version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@growthbook/growthbook': specifier: 0.26.0 version: 0.26.0 @@ -1899,6 +1917,28 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@8.0.0': + resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==} + peerDependencies: + '@dnd-kit/core': ^6.1.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/runtime@1.4.5': resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} @@ -10864,6 +10904,31 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@emnapi/runtime@1.4.5': dependencies: tslib: 2.8.1 From ca7c2a4355b89aa9c1427d9e6fd4330fc3e3d346 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 6 Jan 2026 15:27:27 +0200 Subject: [PATCH 3/3] fix: tests --- packages/extension/__tests__/setup.ts | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/extension/__tests__/setup.ts b/packages/extension/__tests__/setup.ts index 3ac069e8f9..16b335649d 100644 --- a/packages/extension/__tests__/setup.ts +++ b/packages/extension/__tests__/setup.ts @@ -1,5 +1,6 @@ import '@testing-library/jest-dom'; import 'fake-indexeddb/auto'; +import type { ReactNode } from 'react'; import { clear } from 'idb-keyval'; import nodeFetch from 'node-fetch'; import { storageWrapper as storage } from '@dailydotdev/shared/src/lib/storageWrapper'; @@ -119,3 +120,42 @@ Object.defineProperty(global, 'ResizeObserver', { }); structuredCloneJsonPolyfill(); + +// Mock dnd-kit for tests +jest.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: ReactNode }) => children, + closestCenter: jest.fn(), + KeyboardSensor: jest.fn(), + PointerSensor: jest.fn(), + useSensor: jest.fn(), + useSensors: jest.fn(() => []), +})); + +jest.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: ReactNode }) => children, + arrayMove: jest.fn((arr, from, to) => { + const result = [...arr]; + const [removed] = result.splice(from, 1); + result.splice(to, 0, removed); + return result; + }), + sortableKeyboardCoordinates: jest.fn(), + horizontalListSortingStrategy: jest.fn(), + verticalListSortingStrategy: jest.fn(), + useSortable: jest.fn(() => ({ + attributes: {}, + listeners: {}, + setNodeRef: jest.fn(), + transform: null, + transition: null, + isDragging: false, + })), +})); + +jest.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: jest.fn(() => ''), + }, + }, +}));