Skip to content

Commit bb9ca35

Browse files
feat: add sortable shortcuts with drag-and-drop (#5247)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Chris Bongers <[email protected]>
1 parent fdfd2d8 commit bb9ca35

File tree

8 files changed

+368
-6
lines changed

8 files changed

+368
-6
lines changed

packages/extension/__tests__/setup.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import '@testing-library/jest-dom';
22
import 'fake-indexeddb/auto';
3+
import type { ReactNode } from 'react';
34
import { clear } from 'idb-keyval';
45
import nodeFetch from 'node-fetch';
56
import { storageWrapper as storage } from '@dailydotdev/shared/src/lib/storageWrapper';
@@ -119,3 +120,42 @@ Object.defineProperty(global, 'ResizeObserver', {
119120
});
120121

121122
structuredCloneJsonPolyfill();
123+
124+
// Mock dnd-kit for tests
125+
jest.mock('@dnd-kit/core', () => ({
126+
DndContext: ({ children }: { children: ReactNode }) => children,
127+
closestCenter: jest.fn(),
128+
KeyboardSensor: jest.fn(),
129+
PointerSensor: jest.fn(),
130+
useSensor: jest.fn(),
131+
useSensors: jest.fn(() => []),
132+
}));
133+
134+
jest.mock('@dnd-kit/sortable', () => ({
135+
SortableContext: ({ children }: { children: ReactNode }) => children,
136+
arrayMove: jest.fn((arr, from, to) => {
137+
const result = [...arr];
138+
const [removed] = result.splice(from, 1);
139+
result.splice(to, 0, removed);
140+
return result;
141+
}),
142+
sortableKeyboardCoordinates: jest.fn(),
143+
horizontalListSortingStrategy: jest.fn(),
144+
verticalListSortingStrategy: jest.fn(),
145+
useSortable: jest.fn(() => ({
146+
attributes: {},
147+
listeners: {},
148+
setNodeRef: jest.fn(),
149+
transform: null,
150+
transition: null,
151+
isDragging: false,
152+
})),
153+
}));
154+
155+
jest.mock('@dnd-kit/utilities', () => ({
156+
CSS: {
157+
Transform: {
158+
toString: jest.fn(() => ''),
159+
},
160+
},
161+
}));

packages/extension/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"prettier": "@dailydotdev/prettier-config",
1313
"dependencies": {
1414
"@dailydotdev/shared": "workspace:*",
15+
"@dnd-kit/core": "^6.3.1",
16+
"@dnd-kit/sortable": "^8.0.0",
17+
"@dnd-kit/utilities": "^3.2.2",
1518
"@kickass-coderz/react": "^0.0.4",
1619
"@tanstack/react-query": "^5.80.5",
1720
"@tanstack/react-query-devtools": "^5.80.5",

packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,16 @@ export default function ShortcutLinks({
2424
shouldUseListFeedLayout,
2525
}: ShortcutLinksProps): ReactElement {
2626
const { openModal } = useLazyModal();
27-
const { showTopSites, toggleShowTopSites } = useSettingsContext();
27+
const { showTopSites, toggleShowTopSites, updateCustomLinks } =
28+
useSettingsContext();
2829
const { logEvent } = useLogContext();
2930
const {
3031
shortcutLinks,
3132
hasCheckedPermission,
3233
isTopSiteActive,
3334
showGetStarted,
3435
hideShortcuts,
36+
isManual,
3537
} = useShortcutLinks();
3638

3739
const { showPermissionsModal } = useShortcuts();
@@ -88,6 +90,10 @@ export default function ShortcutLinks({
8890
});
8991
};
9092

93+
const onReorder = (reorderedLinks: string[]) => {
94+
updateCustomLinks(reorderedLinks);
95+
};
96+
9197
if (!showTopSites) {
9298
return <></>;
9399
}
@@ -110,6 +116,8 @@ export default function ShortcutLinks({
110116
showTopSites,
111117
toggleShowTopSites,
112118
hasCheckedPermission,
119+
onReorder,
120+
isManual,
113121
}}
114122
/>
115123
))}

packages/extension/src/newtab/ShortcutLinks/ShortcutLinksItem.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type { ReactElement } from 'react';
22
import React from 'react';
3-
import { PlusIcon } from '@dailydotdev/shared/src/components/icons';
3+
import classNames from 'classnames';
4+
import { MenuIcon, PlusIcon } from '@dailydotdev/shared/src/components/icons';
45

56
import { IconSize } from '@dailydotdev/shared/src/components/Icon';
67
import { combinedClicks } from '@dailydotdev/shared/src/lib/click';
78

89
import { apiUrl } from '@dailydotdev/shared/src/lib/config';
10+
import { useSortable } from '@dnd-kit/sortable';
11+
import { CSS } from '@dnd-kit/utilities';
912

1013
const pixelRatio = globalThis?.window.devicePixelRatio ?? 1;
1114
const iconSize = Math.round(24 * pixelRatio);
@@ -51,19 +54,47 @@ export function ShortcutLinksItem({
5154
onLinkClick: () => void;
5255
}): ReactElement {
5356
const cleanUrl = url.replace(/http(s)?(:)?(\/\/)?|(\/\/)?(www\.)?/g, '');
57+
58+
const {
59+
attributes,
60+
listeners,
61+
setNodeRef,
62+
transform,
63+
transition,
64+
isDragging,
65+
} = useSortable({ id: url });
66+
67+
const style = {
68+
transform: CSS.Transform.toString(transform),
69+
transition,
70+
};
71+
5472
return (
5573
<a
74+
ref={setNodeRef}
75+
style={style}
76+
{...attributes}
77+
{...listeners}
5678
href={url}
5779
rel="noopener noreferrer"
5880
{...combinedClicks(onLinkClick)}
59-
className="group mr-4 flex flex-col items-center"
81+
className={classNames(
82+
'group relative mr-4 flex cursor-grab flex-col items-center active:cursor-grabbing',
83+
isDragging && 'opacity-50',
84+
)}
6085
>
61-
<div className="mb-2 flex size-12 items-center justify-center rounded-full bg-surface-float text-text-secondary">
86+
<div className="relative mb-2 flex size-12 items-center justify-center rounded-full bg-surface-float text-text-secondary">
6287
<img
6388
src={`${apiUrl}/icon?url=${encodeURIComponent(url)}&size=${iconSize}`}
6489
alt={url}
6590
className="size-6"
6691
/>
92+
<div className="rounded shadow-1 absolute -bottom-1 left-1/2 flex -translate-x-1/2 items-center justify-center bg-surface-primary opacity-0 transition-opacity group-hover:opacity-100">
93+
<MenuIcon
94+
size={IconSize.XSmall}
95+
className="rotate-90 text-text-quaternary"
96+
/>
97+
</div>
6798
</div>
6899
<span className="max-w-12 truncate text-text-tertiary typo-caption2">
69100
{cleanUrl}

packages/extension/src/newtab/ShortcutLinks/ShortcutLinksList.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ import {
2121
DropdownMenuOptions,
2222
DropdownMenuTrigger,
2323
} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu';
24+
import {
25+
DndContext,
26+
closestCenter,
27+
KeyboardSensor,
28+
PointerSensor,
29+
useSensor,
30+
useSensors,
31+
} from '@dnd-kit/core';
32+
import type { DragEndEvent } from '@dnd-kit/core';
33+
import {
34+
arrayMove,
35+
SortableContext,
36+
sortableKeyboardCoordinates,
37+
horizontalListSortingStrategy,
38+
} from '@dnd-kit/sortable';
2439
import {
2540
ShortcutLinksItem,
2641
ShortcutItemPlaceholder,
@@ -34,6 +49,8 @@ interface ShortcutLinksListProps {
3449
showTopSites: boolean;
3550
toggleShowTopSites: () => void;
3651
hasCheckedPermission?: boolean;
52+
onReorder?: (links: string[]) => void;
53+
isManual?: boolean;
3754
}
3855

3956
const placeholderLinks = Array.from({ length: 6 }).map((_, index) => index);
@@ -44,9 +61,32 @@ export function ShortcutLinksList({
4461
toggleShowTopSites,
4562
shortcutLinks,
4663
shouldUseListFeedLayout,
64+
onReorder,
65+
isManual,
4766
}: ShortcutLinksListProps): ReactElement {
4867
const hasShortcuts = shortcutLinks?.length > 0;
4968

69+
const sensors = useSensors(
70+
useSensor(PointerSensor),
71+
useSensor(KeyboardSensor, {
72+
coordinateGetter: sortableKeyboardCoordinates,
73+
}),
74+
);
75+
76+
const handleDragEnd = (event: DragEndEvent) => {
77+
const { active, over } = event;
78+
79+
if (!over || active.id === over.id || !onReorder || !isManual) {
80+
return;
81+
}
82+
83+
const oldIndex = shortcutLinks.indexOf(active.id as string);
84+
const newIndex = shortcutLinks.indexOf(over.id as string);
85+
86+
const reorderedLinks = arrayMove(shortcutLinks, oldIndex, newIndex);
87+
onReorder(reorderedLinks);
88+
};
89+
5090
const options = [
5191
{
5292
icon: <WrappingMenuIcon Icon={EyeIcon} />,
@@ -60,7 +100,7 @@ export function ShortcutLinksList({
60100
},
61101
];
62102

63-
return (
103+
const content = (
64104
<div
65105
className={classNames(
66106
'hidden tablet:flex',
@@ -109,4 +149,23 @@ export function ShortcutLinksList({
109149
)}
110150
</div>
111151
);
152+
153+
if (hasShortcuts && isManual && onReorder) {
154+
return (
155+
<DndContext
156+
sensors={sensors}
157+
collisionDetection={closestCenter}
158+
onDragEnd={handleDragEnd}
159+
>
160+
<SortableContext
161+
items={shortcutLinks}
162+
strategy={horizontalListSortingStrategy}
163+
>
164+
{content}
165+
</SortableContext>
166+
</DndContext>
167+
);
168+
}
169+
170+
return content;
112171
}

packages/shared/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@
101101
"typescript": "5.6.3"
102102
},
103103
"dependencies": {
104+
"@dnd-kit/core": "^6.3.1",
105+
"@dnd-kit/sortable": "^8.0.0",
106+
"@dnd-kit/utilities": "^3.2.2",
104107
"@growthbook/growthbook": "0.26.0",
105108
"@growthbook/growthbook-react": "^0.17.0",
106109
"@hookform/resolvers": "5.2.2",

0 commit comments

Comments
 (0)