diff --git a/.changeset/public-drag-to-snap-points.md b/.changeset/public-drag-to-snap-points.md new file mode 100644 index 0000000000000..f4b470e4573d3 --- /dev/null +++ b/.changeset/public-drag-to-snap-points.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feat] `createDragToSnapPoints` and its hook `useDragToSnapPoints` let a drag move an element along one axis and settle it on snap points (px offsets from its resting position) at the release: the nearest one, the next one in its direction after a flick faster than `flickThreshold` (default 1008 px/s), or the one it started from when the release moves back. The settle continues the release velocity, `settleTo(index)` settles without a drag, and `onSettle(index)` reports where it landed. `createDragToSnapPoints` is exported from the package root and `useDragToSnapPoints` from `@astryxdesign/core/hooks`; the CLI's templates gain a playing-card example of each. + +@vjeux diff --git a/apps/sandbox/scripts/next-export-routes.json b/apps/sandbox/scripts/next-export-routes.json index e9a26f36719e0..519ffa8599431 100644 --- a/apps/sandbox/scripts/next-export-routes.json +++ b/apps/sandbox/scripts/next-export-routes.json @@ -294,6 +294,8 @@ "/templates/DividerShowcase/", "/templates/DividerVariants/", "/templates/DividerVertical/", + "/templates/DragToSnapPointsCard/", + "/templates/DragToSnapPointsCardVanilla/", "/templates/DrawerForm/", "/templates/DrawerHeaderShowcase/", "/templates/DrawerHeaderSubtitleEndContent/", diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index 60a7420693a98..beea5af7e88a2 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -1976,6 +1976,11 @@ "path": "examples/create-animator/playing-cards-in-plain-java-script", "aliases": [] }, + { + "name": "example-create-drag-to-snap-points-playing-card-in-plain-java-script", + "path": "examples/create-drag-to-snap-points/playing-card-in-plain-java-script", + "aliases": [] + }, { "name": "example-date-input-clearable", "path": "examples/date-input/clearable", @@ -3711,6 +3716,11 @@ "path": "examples/use-container-reveal/reveal-on-hover-row-actions", "aliases": [] }, + { + "name": "example-use-drag-to-snap-points-playing-card-on-three-places", + "path": "examples/use-drag-to-snap-points/playing-card-on-three-places", + "aliases": [] + }, { "name": "example-use-focus-trap-quick-edit-panel", "path": "examples/use-focus-trap/quick-edit-panel", @@ -3946,6 +3956,11 @@ "path": "hooks/create-animator", "aliases": [] }, + { + "name": "hook-create-drag-to-snap-points", + "path": "hooks/create-drag-to-snap-points", + "aliases": [] + }, { "name": "hook-create-pan-gesture", "path": "hooks/create-pan-gesture", @@ -4011,6 +4026,11 @@ "path": "hooks/use-dev-warning", "aliases": [] }, + { + "name": "hook-use-drag-to-snap-points", + "path": "hooks/use-drag-to-snap-points", + "aliases": [] + }, { "name": "hook-use-entry-animation", "path": "hooks/use-entry-animation", diff --git a/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs new file mode 100644 index 0000000000000..cb6b6445eaaf8 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'createDragToSnapPoints', + name: 'createDragToSnapPoints — Playing card in plain JavaScript', + displayName: 'createDragToSnapPoints — Playing card in plain JavaScript', + description: + 'The three-place playing card from the useDragToSnapPoints example, built in plain JavaScript with createDragToSnapPoints: a sideways drag settles it on the nearest place or the next after a flick, buttons settle it from the keyboard, and a live region announces the place. React only mounts it.', + isReady: true, + aspectRatio: 4 / 3, + componentsUsed: [], +}; diff --git a/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx new file mode 100644 index 0000000000000..947b7f484d16d --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx @@ -0,0 +1,110 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {createDragToSnapPoints} from '@astryxdesign/core'; + +const PLACES = ['Hand', 'Table', 'Discard pile']; + +/** + * A card that snaps between three places, in plain JavaScript: no React + * below this line. It returns a function that takes it down again. + */ +function mountCard(table: HTMLElement, buttons: HTMLElement) { + const card = document.createElement('div'); + card.textContent = 'Q\u2665'; + Object.assign(card.style, { + width: '96px', + height: '136px', + display: 'grid', + placeItems: 'center', + font: 'bold 36px system-ui, sans-serif', + // The theme's surface and text, so the card reads in light and dark. + borderRadius: 'var(--radius-container)', + backgroundColor: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + boxShadow: '0 2px 8px var(--color-shadow)', + touchAction: 'none', + cursor: 'grab', + userSelect: 'none', + }); + table.append(card); + const status = document.createElement('div'); + status.setAttribute('aria-live', 'polite'); + Object.assign(status.style, {position: 'absolute', clip: 'rect(0 0 0 0)'}); + table.append(status); + + // The places run in reading order, so leftward in a right-to-left layout. + // There the snap points are negative, so they sort the other way round. + const steps = [0, 110, 220]; + const isRtl = getComputedStyle(table).direction === 'rtl'; + const placeAt = (index: number) => (isRtl ? steps.length - 1 - index : index); + const snap = createDragToSnapPoints(card, { + axis: 'x', + snapPoints: isRtl ? steps.map(step => -step) : steps, + onSettle: index => { + status.textContent = `The card is on the ${PLACES[placeAt(index)].toLowerCase()}.`; + }, + }); + const controls = PLACES.map((name, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = name; + // The Astryx reset removes a native button's own look, so these take the + // theme's secondary button look. + Object.assign(button.style, { + height: 'var(--size-element-md)', + paddingInline: 'var(--spacing-3)', + border: 'none', + borderRadius: 'var(--radius-element)', + backgroundColor: 'var(--color-neutral)', + color: 'var(--color-text-primary)', + font: 'inherit', + fontSize: 'var(--text-label-size)', + fontWeight: 'var(--font-weight-medium)', + cursor: 'pointer', + }); + button.addEventListener('click', () => snap.settleTo(placeAt(index))); + buttons.append(button); + return button; + }); + return () => { + snap.destroy(); + for (const button of controls) { + button.remove(); + } + card.remove(); + status.remove(); + }; +} + +/** + * Mounts the card when the wrapper attaches, and takes it down when it + * detaches: its cleanup is the callback ref's. + */ +function mountTable(wrapper: HTMLDivElement | null) { + if (wrapper == null) { + return undefined; + } + const table = wrapper.firstElementChild as HTMLElement; + const buttons = wrapper.lastElementChild as HTMLElement; + return mountCard(table, buttons); +} + +export default function DragToSnapPointsCardVanilla() { + return ( +
+
+
+
+ ); +} diff --git a/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.doc.mjs b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.doc.mjs new file mode 100644 index 0000000000000..62261d34f369a --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.doc.mjs @@ -0,0 +1,21 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'useDragToSnapPoints', + name: 'useDragToSnapPoints — Playing card on three places', + displayName: 'useDragToSnapPoints — Playing card on three places', + description: + 'A playing card that a sideways drag moves between three places: a slow release settles on the nearest, a quick flick carries to the next, and a release moving back returns where it started. Buttons settle it from the keyboard, and the new place is announced. Built only on the public useDragToSnapPoints hook.', + isReady: true, + aspectRatio: 4 / 3, + componentsUsed: [ + 'Card', + 'Button', + 'Layout', + 'Text', + 'VisuallyHidden', + 'useDragToSnapPoints', + ], +}; diff --git a/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.tsx b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.tsx new file mode 100644 index 0000000000000..e78ef4921ce3a --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/useDragToSnapPoints/DragToSnapPointsCard.tsx @@ -0,0 +1,74 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {useLayoutEffect, useRef, useState} from 'react'; +import {Button} from '@astryxdesign/core/Button'; +import {Card} from '@astryxdesign/core/Card'; +import {useDragToSnapPoints} from '@astryxdesign/core/hooks'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden'; + +const PLACES = ['Hand', 'Table', 'Discard pile']; +/** How far along the row each place sits, in px from where the card rests. */ +const STEPS = [0, 110, 220]; + +export default function DragToSnapPointsCard() { + const ref = useRef(null); + const [place, setPlace] = useState(0); + // The places run in reading order, so leftward in a right-to-left layout. + // There the snap points are negative, so they sort the other way round. + // The direction is the one the card renders in. + const [isRtl, setIsRtl] = useState(false); + // Reads the direction the browser computed for the card once it is laid + // out. + useLayoutEffect(() => { + if (ref.current != null) { + setIsRtl(getComputedStyle(ref.current).direction === 'rtl'); + } + }, []); + const placeAt = (index: number) => (isRtl ? STEPS.length - 1 - index : index); + const {settleTo} = useDragToSnapPoints(ref, { + axis: 'x', + snapPoints: isRtl ? STEPS.map(step => -step) : STEPS, + onSettle: index => setPlace(placeAt(index)), + }); + + return ( + + + Drag the card sideways and let go: it settles on the nearest place, or + the next one after a quick flick. + + + + + + Q + + {'\u2665'} + + + + + {PLACES.map((name, index) => ( +