Skip to content

Nested lists #21

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
3,549 changes: 3,549 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"url": "git+https://github.com/rodrigodagostino/svelte-sortable-list.git"
},
"scripts": {
"prepare": "npm run build",
"dev": "vite dev",
"build": "vite build && npm run package",
"preview": "vite preview",
Expand Down
76 changes: 44 additions & 32 deletions src/lib/components/SortableList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
areColliding,
getClosestScrollableAncestor,
getCollidingItem,
getGroupSelector,
getId,
getIndex,
getItemRects,
Expand Down Expand Up @@ -74,11 +75,12 @@
export let isLocked: $$Props['isLocked'] = false;
export let isDisabled: $$Props['isDisabled'] = false;
export let announcements: $$Props['announcements'] = undefined;
export let group: $$Props['group'] = undefined;

$: _transition = { duration: 240, easing: 'cubic-bezier(0.2, 1, 0.1, 1)', ...transition };
$: _announcements = announcements || announce;

const rootProps = setRootProps({
const rootProps = setRootProps(group, {
gap,
direction,
transition: _transition,
Expand Down Expand Up @@ -107,24 +109,24 @@
announcements: _announcements,
};

const root = setRoot(null);
const root = setRoot(group, null);
let ghostStatus: GhostProps['status'] = 'unset';
const pointer = setPointer(null);
const pointerOrigin = setPointerOrigin(null);
const itemRects = setItemRects(null);
const draggedItem = setDraggedItem(null);
const targetItem = setTargetItem(null);
const focusedItem = setFocusedItem(null);
const pointer = setPointer(group, null);
const pointerOrigin = setPointerOrigin(group, null);
const itemRects = setItemRects(group, null);
const draggedItem = setDraggedItem(group, null);
const targetItem = setTargetItem(group, null);
const focusedItem = setFocusedItem(group, null);
let liveText: string = '';

const isPointerDragging = setIsPointerDragging(false);
const isPointerDropping = setIsPointerDropping(false);
const isKeyboardDragging = setIsKeyboardDragging(false);
const isKeyboardDropping = setIsKeyboardDropping(false);
const isPointerCanceling = setIsPointerCanceling(false);
const isKeyboardCanceling = setIsKeyboardCanceling(false);
const isBetweenBounds = setIsBetweenBounds(true);
const isRTL = setIsRTL(false);
const isPointerDragging = setIsPointerDragging(group, false);
const isPointerDropping = setIsPointerDropping(group, false);
const isKeyboardDragging = setIsKeyboardDragging(group, false);
const isKeyboardDropping = setIsKeyboardDropping(group, false);
const isPointerCanceling = setIsPointerCanceling(group, false);
const isKeyboardCanceling = setIsKeyboardCanceling(group, false);
const isBetweenBounds = setIsBetweenBounds(group, true);
const isRTL = setIsRTL(group, false);

const dispatch = createEventDispatcher<{
mounted: MountedEventDetail;
Expand Down Expand Up @@ -201,7 +203,7 @@
return;

const target = event.target as HTMLElement;
const currItem = target.closest<HTMLLIElement>('.ssl-item');
const currItem = target.closest<HTMLLIElement>('.ssl-item' + getGroupSelector(group));
if (!currItem) return;

if (
Expand All @@ -226,8 +228,10 @@
event.preventDefault();

// Prevent dragging if the current list item contains a handle, but we’re not dragging from it.
const hasHandle = !!currItem.querySelector('[data-role="handle"]');
const targetIsOrResidesInHandle = target.closest('[data-role="handle"]');
const hasHandle = !!currItem.querySelector('[data-role="handle"]' + getGroupSelector(group));
const targetIsOrResidesInHandle = target.closest(
'[data-role="handle"]' + getGroupSelector(group)
);
if (hasHandle && !targetIsOrResidesInHandle) return;

// Prevent dragging if the current list item contains an interactive element
Expand All @@ -241,7 +245,7 @@
$pointer = { x: event.clientX, y: event.clientY };
$pointerOrigin = { x: event.clientX, y: event.clientY };
$draggedItem = currItem;
$itemRects = getItemRects(rootRef);
$itemRects = getItemRects(group, rootRef);
ghostStatus = 'init';
await tick();
$isPointerDragging = true;
Expand Down Expand Up @@ -291,12 +295,12 @@

// Re-set itemRects only during scrolling.
// (setting it here instead of in the `scroll()` function to reduce the performance impact)
if (scrollingSpeed !== 0) $itemRects = getItemRects(rootRef);
if (scrollingSpeed !== 0) $itemRects = getItemRects(group, rootRef);
await tick();
const collidingItemRect = getCollidingItem(ghostRef, $itemRects);
if (collidingItemRect)
$targetItem = rootRef.querySelector<HTMLLIElement>(
`.ssl-item[data-item-id="${collidingItemRect.id}"]`
`.ssl-item${getGroupSelector(group)}[data-item-id="${collidingItemRect.id}"]`
);
else if (canClearOnDragOut || (canRemoveOnDropOut && !$isBetweenBounds)) $targetItem = null;

Expand Down Expand Up @@ -343,7 +347,7 @@
await tick();
$draggedItem = $focusedItem;
const draggedIndex = getIndex($focusedItem);
$itemRects = getItemRects(rootRef);
$itemRects = getItemRects(group, rootRef);
dispatch('dragstart', {
deviceType: 'keyboard',
draggedItem: $draggedItem,
Expand All @@ -369,7 +373,9 @@

if (!$isKeyboardDragging) {
if (!$focusedItem || focusedIndex === null) {
const firstItem = rootRef.querySelector<HTMLLIElement>('.ssl-item');
const firstItem = rootRef.querySelector<HTMLLIElement>(
'.ssl-item' + getGroupSelector(group)
);
if (!firstItem) return;
firstItem.focus({ preventScroll: true });
if (scrollableAncestor && !isFullyVisible(firstItem, scrollableAncestor))
Expand All @@ -379,7 +385,9 @@

// Prevent focusing the previous item if the current one is the first,
// and focusing the next item if the current one is the last.
const items = rootRef.querySelectorAll<HTMLLIElement>('.ssl-item');
const items = rootRef.querySelectorAll<HTMLLIElement>(
'.ssl-item' + getGroupSelector(group)
);
if (
(step === -1 && focusedIndex === 0) ||
(step === 1 && focusedIndex === items.length - 1)
Expand Down Expand Up @@ -447,7 +455,9 @@
if (key === 'Home' || key === 'End') {
event.preventDefault();

const items = rootRef.querySelectorAll<HTMLLIElement>('.ssl-item');
const items = rootRef.querySelectorAll<HTMLLIElement>(
'.ssl-item' + getGroupSelector(group)
);
const focusedIndex = ($focusedItem && getIndex($focusedItem)) ?? null;

if (!$isKeyboardDragging) {
Expand Down Expand Up @@ -602,11 +612,12 @@
<!-- svelte-ignore a11y-role-supports-aria-props -->
<ul
bind:this={rootRef}
class="ssl-list"
class="ssl-list {$$props.class ?? ''}"
style:--ssl-gap="{gap}px"
style:--ssl-wrap={hasWrapping ? 'wrap' : 'nowrap'}
style:--ssl-transition-duration="{_transition.duration}ms"
style:pointer-events={$focusedItem ? 'none' : 'auto'}
data-group={group}
data-has-drop-marker={hasDropMarker}
data-can-remove-on-drop-out={canRemoveOnDropOut}
data-is-locked={isLocked}
Expand All @@ -622,10 +633,11 @@
aria-orientation={direction}
aria-activedescendant={$focusedItem ? $focusedItem.id : undefined}
aria-disabled={isDisabled}
on:pointerdown={handlePointerDown}
on:pointercancel={handlePointerCancel}
on:keydown={handleKeyDown}
on:itemfocusout={(event) => handlePointerAndKeyboardDrop(event.detail.item, 'keyboard-cancel')}
on:pointerdown|stopPropagation={handlePointerDown}
on:pointercancel|stopPropagation={handlePointerCancel}
on:keydown|stopPropagation={handleKeyDown}
on:itemfocusout|stopPropagation={(event) =>
handlePointerAndKeyboardDrop(event.detail.item, 'keyboard-cancel')}
>
<slot>
<p>
Expand All @@ -634,7 +646,7 @@
</p>
</slot>
</ul>
<SortableListGhost bind:ghostRef status={ghostStatus} />
<SortableListGhost bind:ghostRef status={ghostStatus} {group} />
<div class="ssl-live-region" aria-live="assertive" aria-atomic="true">{liveText}</div>

<!--
Expand Down
22 changes: 12 additions & 10 deletions src/lib/components/SortableListGhost.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,20 @@

export let ghostRef: $$Props['ghostRef'];
export let status: $$Props['status'];
export let group: $$Props['group'] = undefined;

const rootProps = getRootProps();
const rootProps = getRootProps(group);

const root = getRoot();
const pointer = getPointer();
const pointerOrigin = getPointerOrigin();
const itemRects = getItemRects();
const draggedItem = getDraggedItem();
const targetItem = getTargetItem();
const root = getRoot(group);
const pointer = getPointer(group);
const pointerOrigin = getPointerOrigin(group);
const itemRects = getItemRects(group);
const draggedItem = getDraggedItem(group);
const targetItem = getTargetItem(group);

const isPointerDragging = getIsPointerDragging();
const isPointerDropping = getIsPointerDropping();
const isBetweenBounds = getIsBetweenBounds();
const isPointerDragging = getIsPointerDragging(group);
const isPointerDropping = getIsPointerDropping(group);
const isBetweenBounds = getIsBetweenBounds(group);

$: draggedItemId = $draggedItem ? getId($draggedItem) : null;
let lastCloneId: string | null = null;
Expand Down Expand Up @@ -250,6 +251,7 @@
style:transition={styleTransition}
style:visibility={$isPointerDragging || $isPointerDropping ? 'visible' : 'hidden'}
style:z-index={styleZIndex}
data-group={group}
data-is-pointer-dragging={$isPointerDragging}
data-is-pointer-dropping={$isPointerDropping}
data-is-between-bounds={$isBetweenBounds}
Expand Down
44 changes: 25 additions & 19 deletions src/lib/components/SortableListItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
calculateTranslate,
calculateTranslateWithAlignment,
dispatch,
getGroupSelector,
getId,
getIndex,
isInSameRow,
Expand All @@ -37,34 +38,35 @@
export let isDisabled: $$Props['isDisabled'] = false;
export let transitionIn: $$Props['transitionIn'] = undefined;
export let transitionOut: $$Props['transitionOut'] = undefined;
export let group: $$Props['group'] = undefined;

$: _transitionIn = transitionIn || scaleFade;
$: _transitionOut = transitionOut || scaleFade;

const rootProps = getRootProps();
const rootProps = getRootProps(group);

const root = getRoot();
const itemRects = getItemRects();
const draggedItem = getDraggedItem();
const targetItem = getTargetItem();
const focusedItem = getFocusedItem();
const root = getRoot(group);
const itemRects = getItemRects(group);
const draggedItem = getDraggedItem(group);
const targetItem = getTargetItem(group);
const focusedItem = getFocusedItem(group);

const isPointerDragging = getIsPointerDragging();
const isPointerDropping = getIsPointerDropping();
const isKeyboardDragging = getIsKeyboardDragging();
const isKeyboardDropping = getIsKeyboardDropping();
const isPointerCanceling = getIsPointerCanceling();
const isKeyboardCanceling = getIsKeyboardCanceling();
const isBetweenBounds = getIsBetweenBounds();
const isRTL = getIsRTL();
const isPointerDragging = getIsPointerDragging(group);
const isPointerDropping = getIsPointerDropping(group);
const isKeyboardDragging = getIsKeyboardDragging(group);
const isKeyboardDropping = getIsKeyboardDropping(group);
const isPointerCanceling = getIsPointerCanceling(group);
const isKeyboardCanceling = getIsKeyboardCanceling(group);
const isBetweenBounds = getIsBetweenBounds(group);
const isRTL = getIsRTL(group);

let hasHandle = false;
$: {
setInteractiveElementsTabIndex($isKeyboardDragging, focusedId);
}

onMount(() => {
hasHandle = !!itemRef?.querySelector('[data-role="handle"]');
hasHandle = !!itemRef?.querySelector('[data-role="handle"]' + getGroupSelector(group));
setInteractiveElementsTabIndex();
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -224,7 +226,10 @@
// on the current element and it’s descendants too.
async function handleFocusOut(event: FocusEvent) {
const relatedTarget = event.relatedTarget as HTMLElement | null;
if (!relatedTarget || (relatedTarget && !relatedTarget.closest('.ssl-item'))) {
if (
!relatedTarget ||
(relatedTarget && !relatedTarget.closest('.ssl-item' + getGroupSelector(group)))
) {
if (!$focusedItem) return;
dispatch(itemRef, 'itemfocusout', { item: $focusedItem });
await tick();
Expand Down Expand Up @@ -257,7 +262,7 @@ Serves as an individual item within `<SortableList.Root>`. Holds the data and co
<li
bind:this={itemRef}
{id}
class="ssl-item"
class="ssl-item {$$props.class ?? ''}"
style:cursor={styleCursor}
style:width={styleWidth}
style:height={styleHeight}
Expand All @@ -269,6 +274,7 @@ Serves as an individual item within `<SortableList.Root>`. Holds the data and co
style:transition={styleTransition}
data-item-id={id}
data-item-index={index}
data-group={group}
data-is-pointer-dragging={$isPointerDragging && draggedId === String(id)}
data-is-pointer-dropping={$isPointerDropping && draggedId === String(id)}
data-is-keyboard-dragging={$isKeyboardDragging && draggedId === String(id)}
Expand All @@ -281,8 +287,8 @@ Serves as an individual item within `<SortableList.Root>`. Holds the data and co
aria-labelledby={$$restProps['aria-labelledby'] || undefined}
aria-selected={focusedId === String(id)}
aria-disabled={$rootProps.isDisabled || isDisabled}
on:focus={handleFocus}
on:focusout={handleFocusOut}
on:focus|stopPropagation={handleFocus}
on:focusout|stopPropagation={handleFocusOut}
in:_transitionIn
out:_transitionOut
>
Expand Down
8 changes: 7 additions & 1 deletion src/lib/components/SortableListItemHandle.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { getIsPointerDragging } from '$lib/stores/index.js';
import type { SortableListItemHandleProps as ItemHandleProps } from '$lib/types/props.js';

const isPointerDragging = getIsPointerDragging();
type $$Props = ItemHandleProps;

export let group: $$Props['group'] = undefined;

const isPointerDragging = getIsPointerDragging(group);

const classes = ['ssl-item-handle', ...($$restProps.class ? [$$restProps.class] : [])].join(' ');
</script>

<span
style:cursor={$isPointerDragging ? 'grabbing' : 'grab'}
data-role="handle"
data-group={group}
aria-hidden="true"
{...$$restProps}
class={classes}
Expand Down
22 changes: 17 additions & 5 deletions src/lib/components/SortableListItemRemove.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { getFocusedItem, getRoot } from '$lib/stores/index.js';
import { getIndex } from '$lib/utils/index.js';
import type { SortableListItemRemoveProps as ItemRemoveProps } from '$lib/types/props.js';
import { getGroupSelector, getIndex } from '$lib/utils/index.js';

const root = getRoot();
const focusedItem = getFocusedItem();
type $$Props = ItemRemoveProps;

export let group: $$Props['group'] = undefined;

const root = getRoot(group);
const focusedItem = getFocusedItem(group);

function handleClick() {
if ($focusedItem && $root) {
const items = $root.querySelectorAll<HTMLLIElement>('.ssl-item');
const items = $root.querySelectorAll<HTMLLIElement>('.ssl-item' + getGroupSelector(group));
if (items.length > 1) {
// Focus the next/previous item (if it exists) before removing.
const step = getIndex($focusedItem) !== items.length - 1 ? 1 : -1;
Expand Down Expand Up @@ -41,7 +46,14 @@ Serves as a `<button>` element that (when pressed) removes an item. Including it
```
-->

<button data-role="remove" on:click={handleClick} on:click {...$$restProps} class={classes}>
<button
data-role="remove"
data-group={group}
on:click={handleClick}
on:click
{...$$restProps}
class={classes}
>
<slot>
<Icon name="remove" />
</slot>
Expand Down
Loading