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%);