From 4a90b6b4157b3e8602b692d95e0cad0eba921dc2 Mon Sep 17 00:00:00 2001 From: yekta Date: Tue, 20 Oct 2026 05:26:53 +0000 Subject: [PATCH] web | docs: shrink grabbed cards and pick them up on a short hold - With a mouse, a card is now picked up after being held still for 300ms, not only after it moves. Moving still picks it up right away, and moving during the hold doesn't cancel it. - The picked-up card in the drag overlay shrinks a little. When it is dropped, it grows back to full size as it lands, even if it lands where it started. dnd-kit's default drop animation skips that case. - dnd-kit now measures the overlay without its transform, so the shrink doesn't throw off where the card lands. - Pressing a card's menu button no longer starts a drag, because those menus open on press. - The docs now say that pressing and holding a card picks it up. --- .../teams/projects-and-environments.mdx | 2 +- .../service/sortable-service-blocks.tsx | 5 +- .../src/components/sortable/card-sorting.ts | 49 +++++++++++++++++-- .../team/sortable-project-cards.tsx | 5 +- apps/web/src/globals.css | 9 ++++ 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/(platform)/teams/projects-and-environments.mdx b/apps/docs/content/(platform)/teams/projects-and-environments.mdx index e818abd4..edbffce7 100644 --- a/apps/docs/content/(platform)/teams/projects-and-environments.mdx +++ b/apps/docs/content/(platform)/teams/projects-and-environments.mdx @@ -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. diff --git a/apps/web/src/components/service/sortable-service-blocks.tsx b/apps/web/src/components/service/sortable-service-blocks.tsx index 4c129eb8..d3b27b78 100644 --- a/apps/web/src/components/service/sortable-service-blocks.tsx +++ b/apps/web/src/components/service/sortable-service-blocks.tsx @@ -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, @@ -99,6 +101,7 @@ export default function SortableServiceBlocks({ blocks, disabled, ...scope }: TP - + {active?.type === "service" && ( )} diff --git a/apps/web/src/components/sortable/card-sorting.ts b/apps/web/src/components/sortable/card-sorting.ts index a0a3371e..a2ec2f9e 100644 --- a/apps/web/src/components/sortable/card-sorting.ts +++ b/apps/web/src/components/sortable/card-sorting.ts @@ -1,5 +1,7 @@ import { closestCenter, + defaultDropAnimation, + getClientRect, KeyboardSensor, MouseSensor, pointerWithin, @@ -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"; @@ -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>(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); }, })); @@ -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 }), ); @@ -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(); diff --git a/apps/web/src/components/team/sortable-project-cards.tsx b/apps/web/src/components/team/sortable-project-cards.tsx index 99a04671..6c887ffd 100644 --- a/apps/web/src/components/team/sortable-project-cards.tsx +++ b/apps/web/src/components/team/sortable-project-cards.tsx @@ -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, @@ -73,6 +75,7 @@ export default function SortableProjectCards({ teamId, projects, disabled, class ))} - + {active && } diff --git a/apps/web/src/globals.css b/apps/web/src/globals.css index 6e4d733b..3bfef470 100644 --- a/apps/web/src/globals.css +++ b/apps/web/src/globals.css @@ -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; @@ -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%);