Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The search box on the team page filters projects by name. The sort button next t
- **Newest First** and **Oldest First**: by creation date.
- **A → Z** and **Z → A**: by name.

To arrange projects, keep the sort on **Custom**, clear the search, and drag a card to its new place. On a phone, press and hold a card first. With a keyboard, focus a card, press **Space**, move it with the arrow keys and press **Space** again.
To arrange projects, keep the sort on **Custom**, clear the search, and drag a card to its new place. Pressing and holding a card also picks it up, and on a phone you hold it first. With a keyboard, focus a card, press **Space**, move it with the arrow keys and press **Space** again.

The custom order is shared: everyone in the team sees the same one. Changing it needs the editor role. New projects go to the top.

Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/service/sortable-service-blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { TServicesResult } from "@/components/service/services-context";
import SortableServiceCard from "@/components/service/sortable-service-card";
import {
blockClickAfterDrag,
cardMeasuring,
cardOverlayProps,
closestInSameList,
keepInPlace,
releaseClickAfterDrag,
Expand Down Expand Up @@ -99,6 +101,7 @@ export default function SortableServiceBlocks({ blocks, disabled, ...scope }: TP
<DndContext
sensors={sensors}
collisionDetection={closestInSameList}
measuring={cardMeasuring}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
Expand All @@ -124,7 +127,7 @@ export default function SortableServiceBlocks({ blocks, disabled, ...scope }: TP
),
)}
</SortableContext>
<DragOverlay>
<DragOverlay {...cardOverlayProps}>
{active?.type === "service" && (
<ServiceCard service={active.service} {...scope} isDragging className="h-full" />
)}
Expand Down
49 changes: 45 additions & 4 deletions apps/web/src/components/sortable/card-sorting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
closestCenter,
defaultDropAnimation,
getClientRect,
KeyboardSensor,
MouseSensor,
pointerWithin,
Expand All @@ -8,6 +10,8 @@ import {
useSensors,
type Activator,
type CollisionDetection,
type DropAnimationFunction,
type MeasuringConfiguration,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates, type SortingStrategy } from "@dnd-kit/sortable";
import type { SyntheticEvent } from "react";
Expand All @@ -16,12 +20,15 @@ import type { SyntheticEvent } from "react";
const keyboardCodes = { start: ["Space"], cancel: ["Escape"], end: ["Space", "Enter"] };

// Panels and dialogs a card opens are portaled out of it, but React still bubbles
// their events to the card. Only presses on the card itself pick it up.
// their events to the card. Only presses on the card itself pick it up, and not on
// its menu buttons, which open on press.
function onCardItself<A extends Activator<never>>(activators: A[]): A[] {
return activators.map((activator) => ({
...activator,
handler: (event: SyntheticEvent, ...rest: [never, never]) => {
if (!(event.currentTarget as Node).contains(event.target as Node)) return false;
const target = event.target as Element;
if (!(event.currentTarget as Node).contains(target)) return false;
if (target.closest('[aria-haspopup="menu"]')) return false;
return activator.handler(event, ...rest);
},
}));
Expand All @@ -39,10 +46,13 @@ class CardKeyboardSensor extends KeyboardSensor {
static activators = onCardItself(KeyboardSensor.activators);
}

// Short clicks and taps stay navigation, only a real drag reorders
// Short clicks and taps stay navigation. A card is picked up once it moves, or after
// being held still for a moment. Moving never cancels the hold.
export function useCardSensors() {
return useSensors(
useSensor(CardMouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(CardMouseSensor, {
activationConstraint: { distance: 6, delay: 300, tolerance: Infinity },
}),
useSensor(CardTouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
useSensor(CardKeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, keyboardCodes }),
);
Expand All @@ -66,6 +76,37 @@ export const closestInSameList: CollisionDetection = (args) => {
// instead and the page lays it out, since cards can differ in size
export const keepInPlace: SortingStrategy = () => null;

const translate = (x: number, y: number) => `translate3d(${x}px, ${y}px, 0)`;

// The picked up card shrinks a little, and grows back as it lands. It grows back
// even when it lands where it was picked up, which dnd-kit's own drop would skip.
const dropCard: DropAnimationFunction = (params) => {
const { active, dragOverlay, transform } = params;
const card = dragOverlay.node.firstElementChild;
const x = transform.x - (dragOverlay.rect.left - active.rect.left);
const y = transform.y - (dragOverlay.rect.top - active.rect.top);
const { duration, easing } = defaultDropAnimation;
const cleanup = defaultDropAnimation.sideEffects?.(params);
const animations = [
dragOverlay.node.animate(
{ transform: [translate(transform.x, transform.y), translate(x, y)] },
{ duration, easing, fill: "forwards" },
),
card?.animate(
{ transform: [getComputedStyle(card).transform, "none"] },
{ duration, easing, fill: "forwards" },
),
];
return Promise.allSettled(animations.map((a) => a?.finished)).then(() => cleanup?.());
};

export const cardOverlayProps = { className: "*:animate-card-grab", dropAnimation: dropCard };

// dnd-kit measures the overlay's card, so it has to see past the shrink to place the drop
export const cardMeasuring: MeasuringConfiguration = {
dragOverlay: { measure: (node) => getClientRect(node, { ignoreTransform: true }) },
};

// The click that ends a mouse drag would otherwise open the card
function preventClick(e: MouseEvent) {
e.preventDefault();
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/team/sortable-project-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { toast } from "@/components/ui/toast";
import { queryKeyProjects, reorderProjects, type TProjectShallow } from "@/lib/queries/projects";
import {
blockClickAfterDrag,
cardMeasuring,
cardOverlayProps,
closestInSameList,
keepInPlace,
releaseClickAfterDrag,
Expand Down Expand Up @@ -73,6 +75,7 @@ export default function SortableProjectCards({ teamId, projects, disabled, class
<DndContext
sensors={sensors}
collisionDetection={closestInSameList}
measuring={cardMeasuring}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
Expand All @@ -88,7 +91,7 @@ export default function SortableProjectCards({ teamId, projects, disabled, class
/>
))}
</SortableContext>
<DragOverlay>
<DragOverlay {...cardOverlayProps}>
{active && <ProjectCard project={active} isDragging className="h-full" />}
</DragOverlay>
</DndContext>
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
--animate-hourglass-long: hourglass 4s cubic-bezier(0.75, 0, 0.25, 1) infinite;
--animate-loading-bar: loading-bar 45s linear infinite;
--animate-line-in: line-in 0.01s step-end forwards;
--animate-card-grab: card-grab 150ms ease-out both;
--animate-ping-pong: ping-pong 2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
--animate-ping-pong-long: ping-pong 4s cubic-bezier(0.5, 0, 0.5, 1) infinite;

Expand Down Expand Up @@ -238,6 +239,14 @@
opacity: 1;
}
}
@keyframes card-grab {
0% {
transform: scale(1);
}
100% {
transform: scale(0.97);
}
}
@keyframes ping-pong {
0% {
transform: translateX(-95%);
Expand Down