Skip to content

Instantly share code, notes, and snippets.

@christopherbauer
Created January 25, 2024 16:09
Show Gist options
  • Save christopherbauer/267109f7fe99e2a57b9e62a25fbf3ff6 to your computer and use it in GitHub Desktop.
Save christopherbauer/267109f7fe99e2a57b9e62a25fbf3ff6 to your computer and use it in GitHub Desktop.
import React, { createContext, useContext, useMemo } from 'react';
import type { CSSProperties, PropsWithChildren } from 'react';
import type { DraggableSyntheticListeners, UniqueIdentifier } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Icon, IconProps } from '../Icon';
import { Button, ListItem, ListItemProps } from '@chakra-ui/react';
import styled from '@emotion/styled';
type Props = ListItemProps & {
id: UniqueIdentifier;
};
interface Context {
attributes: Record<string, any>;
listeners: DraggableSyntheticListeners;
ref(node: HTMLElement | null): void;
}
const SortableItemContext = createContext<Context>({
attributes: {},
listeners: undefined,
ref() {},
});
export function SortableItem({ children, id, ...rest }: PropsWithChildren<Props>) {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({ id });
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
}),
[attributes, listeners, setActivatorNodeRef]
);
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
};
return (
<SortableItemContext.Provider value={context}>
<DraggableItem ref={setNodeRef} style={style} {...rest}>
{children}
</DraggableItem>
</SortableItemContext.Provider>
);
}
const DraggableItem = styled(ListItem)`
display: flex;
justify-content: space-between;
flex-grow: 1;
align-items: center;
box-shadow: 0 0 0 calc(1px / var(--scale-x, 1)) rgba(63, 63, 68, 0.05), 0 1px calc(3px / var(--scale-x, 1)) 0 rgba(34, 33, 81, 0.15);
border-radius: calc(4px / var(--scale-x, 1));
box-sizing: border-box;
list-style: none;
color: #333;
font-weight: 400;
font-size: 1rem;
font-family: sans-serif;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
&:focus-visible {
box-shadow: 0 0px 0px 2px #4c9ffe;
}
`;
type DragHandleProps = Omit<IconProps, 'icon'>;
export const DragHandle = (props: DragHandleProps) => {
const { attributes, listeners, ref } = useContext(SortableItemContext);
return (
<Dragger {...attributes} {...listeners} ref={ref}>
<Icon {...props} icon="grip-lines-vertical" />
</Dragger>
);
};
const Dragger = styled(Button)`
display: flex;
width: 12px;
padding: 15px;
align-items: center;
justify-content: center;
flex: 0 0 auto;
touch-action: none;
cursor: var(--cursor, pointer);
border-radius: 5px;
border: none;
outline: none;
appearance: none;
background-color: transparent;
-webkit-tap-highlight-color: transparent;
`;
import React, { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import type { Active, UniqueIdentifier } from '@dnd-kit/core';
import { SortableContext, arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { DragHandle, SortableItem } from './SortableItem';
import styled from '@emotion/styled';
import { Container, List, ListProps } from '@chakra-ui/react';
import { SortableOverlay } from './SortableOverlay';
interface BaseItem {
id: UniqueIdentifier;
}
type Props<T extends BaseItem> = Omit<ListProps, 'onChange'> & {
items: T[];
onChange(items: T[]): void;
renderItem(item: T, i?: number): ReactNode;
};
export function SortableList<T extends BaseItem>({ items, onChange, renderItem, ...rest }: Props<T>) {
const [active, setActive] = useState<Active | null>(null);
const activeItem = useMemo(() => items.find((item) => item.id === active?.id), [active, items]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
return (
<DndContext
sensors={sensors}
onDragStart={({ active }) => {
setActive(active);
}}
onDragEnd={({ active, over }) => {
if (over && active.id !== over?.id) {
const activeIndex = items.findIndex(({ id }) => id === active.id);
const overIndex = items.findIndex(({ id }) => id === over.id);
onChange(arrayMove(items, activeIndex, overIndex));
}
setActive(null);
}}
onDragCancel={() => {
setActive(null);
}}
>
<List {...rest}>
<SortableContext items={items}>
<SortableListComponent role="application">
{items.map((item, i) => (
<React.Fragment key={item.id}>{renderItem(item, i)}</React.Fragment>
))}
</SortableListComponent>
</SortableContext>
<SortableOverlay>{activeItem ? renderItem(activeItem) : null}</SortableOverlay>
</List>
</DndContext>
);
}
const SortableListComponent = styled(Container)`
display: flex;
flex-direction: column;
gap: 10px;
padding: 0;
list-style: none;
`;
SortableList.Item = SortableItem;
SortableList.DragHandle = DragHandle;
import type { PropsWithChildren } from 'react';
import { DragOverlay, defaultDropAnimationSideEffects } from '@dnd-kit/core';
import type { DropAnimation } from '@dnd-kit/core';
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.4',
},
},
}),
};
interface Props {}
export function SortableOverlay({ children }: PropsWithChildren<Props>) {
return <DragOverlay dropAnimation={dropAnimationConfig}>{children}</DragOverlay>;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment