Skip to content

Commit

Permalink
Merge pull request #3316 from dikwickley/draggable-sort-conditions-2349
Browse files Browse the repository at this point in the history
Added drag functionality to update precendence for sort condition
  • Loading branch information
seancolsen authored Dec 10, 2023
2 parents 1c71c26 + 7e99f09 commit d375575
Show file tree
Hide file tree
Showing 5 changed files with 425 additions and 53 deletions.
66 changes: 66 additions & 0 deletions mathesar_ui/src/components/sortable/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Sortable

> **Note**
>
> We'd like to move this code into its own package at some point, which is why it has no imports.
This is a set of Svelte actions which make it easy to add drag-and-drop sorting to UI elements.

## Usage

1. Apply the `sortableContainer` action to the container element which contains the items you want to sort. This action takes two arguments:

- `getItems` — a function which returns the items to be sorted
- `onSort` — a function which is called when the user drops an item, with the new sorted items as its argument. Use this to update your data.

1. Apply the `sortableItem` action to each item you want to be sortable.

These items must be descendants of the `sortableContainer` element.

1. Apply the `sortableTrigger` action to the element which the user should drag to sort the item.

This element can be the same as the `sortableItem` element or a descendant of it.

```svelte
<script>
import { sortableContainer, sortableItem, sortableTrigger } from 'sortable';
</script>
<div
use:sortableContainer={{
getItems: () => items,
onSort: (newItems) => { items = newItems; }
}}
>
{#each items as item}
<div use:sortableItem>
<div use:sortableTrigger>(trigger UI here)</div>
(more complex per-item UI here)
</div>
{/each}
</div>
<style lang="scss">
@import '/src/components/sortable/sortable.css';
</style>
```

## Features

- ✅ Actions-only API — no components and easy to style to your liking
- ✅ Sortable trigger is defined separately from the sortable item, making drag handles possible within more complex UIs
- ✅ Touch support
- ✅ Animated transitions while sorting
- ✅ Sort preview is done in pure CSS without manipulating the DOM before the sort is confirmed
- ✅ No dependencies

## Current limitations

(Improvements welcome!)

- Vertical sorting only
- No support for nested sorting containers
- Data cannot be updated in realtime while the user is dragging an item — updates are only applied when the user drops the item
- No transitions after the user drops an item
- Touch support is restricted to one touch at a time
- The browser must support [pointer events](https://caniuse.com/pointer)
21 changes: 21 additions & 0 deletions mathesar_ui/src/components/sortable/sortable.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[data-sortable-container] {
isolation: isolate;
}

[data-sortable-container].is-sorting [data-sortable-item]:not(.is-dragging) {
transition: transform 0.2s ease-in-out;
}

[data-sortable-item].is-dragging {
position: relative;
z-index: 1;
}

[data-sortable-trigger] {
cursor: grab;
touch-action: none;
}

[data-sortable-item].is-dragging [data-sortable-trigger] {
cursor: grabbing;
}
266 changes: 266 additions & 0 deletions mathesar_ui/src/components/sortable/sortable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
const CONTAINER_ATTR = 'data-sortable-container';
const ITEM_ATTR = 'data-sortable-item';
const TRIGGER_ATTR = 'data-sortable-trigger';
const DRAGGING_CLASS = 'is-dragging';
const SORTING_CLASS = 'is-sorting';

function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}

interface Controller<Item> {
isSorting: boolean;
getItems: () => Item[];
onSort: (newItems: Item[]) => void;
}

interface ContainerElement<Item> extends HTMLElement {
sortableController: Controller<Item>;
}

function getItemFromTrigger(element: HTMLElement): HTMLElement | undefined {
if (element.hasAttribute(ITEM_ATTR)) return element;
return (element.closest(`[${ITEM_ATTR}]`) ?? undefined) as
| HTMLElement
| undefined;
}

function getContainerFromTrigger<Item>(
element: HTMLElement,
): ContainerElement<Item> | undefined {
return (element.closest(`[${CONTAINER_ATTR}]`) ?? undefined) as
| ContainerElement<Item>
| undefined;
}

function preventDefault(e: Event) {
e.preventDefault();
}

function midpoint(rect: DOMRect) {
return rect.top + rect.height / 2;
}

function setTransform(element: HTMLElement, value: number) {
if (value === 0) element.style.removeProperty('transform');
element.style.setProperty('transform', `translateY(${value}px)`);
}

function analyze(container: HTMLElement, draggingItem: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const items = container.querySelectorAll<HTMLElement>(`[${ITEM_ATTR}]`);
const itemIndexes = [...Array(items.length).keys()];
const rects = [...items].map((i) => i.getBoundingClientRect());
const draggingItemIndex = [...items].indexOf(draggingItem);
const draggingItemRect = rects[draggingItemIndex];
return {
items,
draggingItemIndex,
draggingItemHeight: draggingItemRect.height,
draggingItemMarginTop: rects[draggingItemIndex - 1]
? draggingItemRect.top - rects[draggingItemIndex - 1].bottom
: 0,
draggingItemMarginBottom: rects[draggingItemIndex + 1]
? rects[draggingItemIndex + 1].top - draggingItemRect.bottom
: 0,
/** The most extreme negative drag change possible for the item */
minDelta: containerRect.top - draggingItemRect.top,
/** The most extreme positive drag change possible for the item */
maxDelta: containerRect.bottom - draggingItemRect.bottom,
/**
* An array of lower bounds which describe the possible destination
* positions to which the dragging item can be moved. For each entry in this
* array, the index indicates the destination index, and the value indicates
* the minimum possible drag delta required to move the dragging item to
* that destination. Adjacent entries in this array can be used to validate
* a potential destination index, given the drag delta.
*
* The bounds are computed such that the leading edge of the dragging item
* must be moved to the midpoint of the destination item in order for the
* destination to be valid.
*/
destinationsLowerBounds: itemIndexes.map((i) => {
if (i === 0) return -Infinity;
if (i <= draggingItemIndex) {
return midpoint(rects[i - 1]) - draggingItemRect.top;
}
return midpoint(rects[i]) - draggingItemRect.bottom;
}),
};
}
type Analysis = ReturnType<typeof analyze>;

/**
* Walks through destinations from a best-guess starting point to efficiently
* find a matching destination.
*
* In theory we could make this function much simpler by searching through
* destinations using `findIndex` or similar, which would obviate the need to
* supply a `destinationToTry`. But, given that the new destination will almost
* always be adjacent to the old destination, we can make this much more
* efficient using a walking search. Efficiency is important because this is
* called on every pointer move event.
*/
function getDestination(
delta: number,
destinationToTry: number,
destinationsLowerBounds: number[],
/**
* The direction we're moving with our walking search. 0 indicates we haven't
* started walking yet.
*/
searchDirection: -1 | 0 | 1 = 0,
): number {
const target = destinationToTry + searchDirection;

// Test if lower bounds are met
if (searchDirection === -1 || searchDirection === 0) {
const lowerBound = destinationsLowerBounds[target];
if (delta < lowerBound) {
// Continue searching lower destinations
return getDestination(delta, target, destinationsLowerBounds, -1);
}
}

// Test if upper bounds are met
if (searchDirection === 1 || searchDirection === 0) {
const upperBound = destinationsLowerBounds[target + 1] ?? Infinity;
if (delta > upperBound) {
// Continue searching higher destinations
return getDestination(delta, target, destinationsLowerBounds, 1);
}
}

return target;
}

function getItemShift(
itemIndex: number,
analysis: Analysis,
destination: number,
): number {
const {
draggingItemIndex,
draggingItemHeight,
draggingItemMarginBottom,
draggingItemMarginTop,
} = analysis;
if (itemIndex >= destination && itemIndex < draggingItemIndex) {
// We are shifting the item down
return draggingItemHeight + draggingItemMarginTop;
}
if (itemIndex <= destination && itemIndex > draggingItemIndex) {
// We are shifting the item up
return -1 * (draggingItemHeight + draggingItemMarginBottom);
}
return 0;
}

/** A Svelte action for the element containing all sortable items */
export function sortableContainer<Item>(
node: HTMLElement,
options: {
getItems: () => Item[];
onSort: (newItems: Item[]) => void;
},
) {
node.setAttribute(CONTAINER_ATTR, '');
const containerElement = node as ContainerElement<Item>;
containerElement.sortableController = {
isSorting: false,
getItems: options.getItems,
onSort: options.onSort,
};
return {};
}

/** A Svelte action for each sortable item */
export function sortableItem(itemElement: Element) {
itemElement.setAttribute(ITEM_ATTR, '');
return {};
}

/** A Svelte action for the drag trigger element within each sortable item */
export function sortableTrigger(triggerElement: HTMLElement) {
let containerElement: ContainerElement<unknown>;
let itemElement: HTMLElement;
let initialY: number;
let destination: number;
let analysis: Analysis;

function arrange() {
for (const [itemIndex, item] of analysis.items.entries()) {
if (itemIndex === analysis.draggingItemIndex) continue;
setTransform(item, getItemShift(itemIndex, analysis, destination));
}
}

function handlePointerMove(event: PointerEvent) {
const rawDelta = event.clientY - initialY;
const clampedDelta = clamp(rawDelta, analysis.minDelta, analysis.maxDelta);
setTransform(itemElement, clampedDelta);
const newDestination = getDestination(
clampedDelta,
destination,
analysis.destinationsLowerBounds,
);
if (newDestination !== destination) {
destination = newDestination;
arrange();
}
}

function handlePointerUp(event: PointerEvent) {
triggerElement.removeEventListener('pointermove', handlePointerMove);
triggerElement.removeEventListener('pointerup', handlePointerUp);
triggerElement.removeEventListener('pointercancel', handlePointerUp);
window.removeEventListener('selectstart', preventDefault);
triggerElement.releasePointerCapture(event.pointerId);
containerElement.classList.remove(SORTING_CLASS);
itemElement.classList.remove(DRAGGING_CLASS);
const controller = containerElement.sortableController;
controller.isSorting = false;

for (const item of analysis.items) {
setTransform(item, 0);
}

if (analysis.draggingItemIndex !== destination) {
const items = [...controller.getItems()];
const draggedItem = items.splice(analysis.draggingItemIndex, 1)[0];
items.splice(destination, 0, draggedItem);
void controller.onSort(items);
}
}

function handlePointerDown(event: PointerEvent) {
const item = getItemFromTrigger(triggerElement);
if (!item) return;
itemElement = item;
const container = getContainerFromTrigger(triggerElement);
if (!container) return;
containerElement = container;
containerElement.classList.add(SORTING_CLASS);
const controller = containerElement.sortableController;
if (controller.isSorting) return; // To prevent multi-touch
controller.isSorting = true;
itemElement.classList.add(DRAGGING_CLASS);
analysis = analyze(containerElement, itemElement);
destination = analysis.draggingItemIndex;
initialY = event.clientY;
triggerElement.setPointerCapture(event.pointerId);
triggerElement.addEventListener('pointermove', handlePointerMove);
triggerElement.addEventListener('pointerup', handlePointerUp);
triggerElement.addEventListener('pointercancel', handlePointerUp);
window.addEventListener('selectstart', preventDefault);
}

triggerElement.setAttribute(TRIGGER_ATTR, '');
triggerElement.addEventListener('pointerdown', handlePointerDown);
triggerElement.addEventListener('contextmenu', preventDefault);
return {
destroy() {
triggerElement.removeEventListener('pointerdown', handlePointerDown);
},
};
}
2 changes: 2 additions & 0 deletions mathesar_ui/src/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
faShareFromSquare,
faXmark,
faExternalLink,
faGrip,
} from '@fortawesome/free-solid-svg-icons';
import type { IconProps } from '@mathesar-component-library/types';
import {
Expand Down Expand Up @@ -142,6 +143,7 @@ export const iconShare: IconProps = { data: faShareFromSquare };
export const iconRecreate: IconProps = { data: faRedo };
export const iconDisable: IconProps = { data: faXmark };
export const iconOpenLinkInNewTab = { data: faExternalLink };
export const iconGrip = { data: faGrip };

// THINGS
//
Expand Down
Loading

0 comments on commit d375575

Please sign in to comment.