Skip to content

Commit

Permalink
Add draggable switch story (#380)
Browse files Browse the repository at this point in the history
  • Loading branch information
clauderic authored Jul 22, 2021
1 parent 8d70540 commit d77643f
Show file tree
Hide file tree
Showing 8 changed files with 339 additions and 12 deletions.
4 changes: 2 additions & 2 deletions .changeset/prevent-click-propagation.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
"@dnd-kit/core": minor
'@dnd-kit/core': minor
---

Pointer, Mouse and Touch sensors now stop propagation of click events on the draggable node once activation constraints are met
Pointer, Mouse and Touch sensors now stop propagation of click events once activation constraints are met.
15 changes: 5 additions & 10 deletions packages/core/src/sensors/pointer/AbstractPointerSensor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export class AbstractPointerSensor implements SensorInstance {
private initialCoordinates: Coordinates;
private timeoutId: NodeJS.Timeout | null = null;
private listeners: Listeners;
private nodeListeners: Listeners;
private documentListeners: Listeners;
private windowListeners: Listeners;

Expand All @@ -70,17 +69,13 @@ export class AbstractPointerSensor implements SensorInstance {
private events: PointerEventHandlers,
listenerTarget = getEventListenerTarget(props.event.target)
) {
const {
event,
activeNode: {node},
} = props;
const {event} = props;
const {target} = event;

this.props = props;
this.events = events;
this.documentListeners = new Listeners(getOwnerDocument(target));
this.listeners = new Listeners(listenerTarget);
this.nodeListeners = new Listeners(node.current);
this.windowListeners = new Listeners(getWindow(target));
this.initialCoordinates = getEventCoordinates(event);
this.handleStart = this.handleStart.bind(this);
Expand Down Expand Up @@ -126,10 +121,10 @@ export class AbstractPointerSensor implements SensorInstance {
private detach() {
this.listeners.removeAll();
this.windowListeners.removeAll();
this.documentListeners.removeAll();

// Wait until the next event loop before removing click listeners
setTimeout(this.nodeListeners.removeAll);
// Wait until the next event loop before removing document listeners
// This is necessary because we listen for `click` events on the document
setTimeout(this.documentListeners.removeAll);

if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
Expand All @@ -144,7 +139,7 @@ export class AbstractPointerSensor implements SensorInstance {
if (initialCoordinates) {
this.activated = true;
// Stop propagation of click events once activation constraints are met
this.nodeListeners.add(EventName.Click, stopPropagation, {
this.documentListeners.add(EventName.Click, stopPropagation, {
capture: true,
});

Expand Down
133 changes: 133 additions & 0 deletions stories/3 - Examples/FormElements/Switch/Switch.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
$switch-border-radius: 50px;
$switch-track-length: 50px;
$switch-track-height: 32px;
$switch-track-border: 2px;
$switch-thumb-diameter: 28px;
$switch-thumb-offset-left: 1px;
$switch-thumb-offset-right: $switch-track-length - $switch-thumb-diameter -
$switch-thumb-offset-left - $switch-track-border;

.Switch {
--color: rgba(70, 70, 70, 0.5);
--background-color: rgba(0, 0, 0, 0.02);
--cursor: grab;

position: relative;
appearance: none;
background-color: var(--background-color);
border: 0;
border-radius: $switch-border-radius;
cursor: pointer;
display: inline-block;
padding: 0;
margin: 0 1rem;
user-select: none;
box-sizing: border-box;
outline: none;

&:focus-visible {
box-shadow: 0 0 3px 2px #4c9ffe;
}

&::after {
border-radius: $switch-border-radius;
}

&.checked:not(.off):not(.disabled),
&.on {
--color: #2389ff;
--background-color: #56a1f8;
}

&.disabled {
--color: rgba(34, 33, 81, 0.5);
}

&.dragging {
--cursor: grabbing;
cursor: var(--cursor);
}

&:not(.disabled) .Thumb {
cursor: var(--cursor);
}

&:hover:not(.disabled, .dragging) .Track {
background-color: rgba(0, 0, 0, 0.05);
}
}

.Track {
display: block;
box-sizing: border-box;
border-radius: $switch-border-radius;
height: $switch-track-height;
width: $switch-track-length;
border: $switch-track-border solid var(--color);
transition: background-color 150ms ease;
}

.ThumbWrapper {
position: absolute;
top: $switch-track-border;
left: $switch-thumb-offset-left;
bottom: $switch-track-border;
right: $switch-track-border;
}

.Thumb {
position: absolute;
top: 0;
left: 0;
display: block;
box-sizing: border-box;
background-color: #fff;
border: 0.1em solid var(--color);
border-radius: 50%;
width: $switch-thumb-diameter;
height: $switch-thumb-diameter;
transition: transform 150ms ease;
transform: translate3d(var(--transform), 0, 0);

.checked & {
transform: translate3d(
calc($switch-thumb-offset-right + var(--transform)),
0,
0
);
}

.checked:not(.off) &,
.on & {
border-color: transparent;
transform: translateX($switch-thumb-offset-right);
}
}

.OnRegion,
.OffRegion {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
pointer-events: none;
}

.OnRegion {
right: 0;
}

.OffRegion {
left: 0;
}

.disabled {
cursor: not-allowed;
opacity: 0.5;
}

.Label {
display: block;
color: #666;
padding: 1rem;
}
29 changes: 29 additions & 0 deletions stories/3 - Examples/FormElements/Switch/Switch.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React, {ComponentProps, useState} from 'react';

import {Switch} from './Switch';

export default {
title: 'Examples/Form Elements/Switch',
};

type Props = Pick<ComponentProps<typeof Switch>, 'disabled' | 'label'>;

function SwitchExample({disabled, label}: Props) {
const [checked, setChecked] = useState(false);

return (
<div style={{padding: 40}}>
<Switch
checked={checked}
label={label}
onChange={setChecked}
disabled={disabled}
/>
</div>
);
}

export const BasicSetup = () => <SwitchExample label="Draggable switch" />;
export const Disabled = () => (
<SwitchExample label="Disabled switch" disabled />
);
120 changes: 120 additions & 0 deletions stories/3 - Examples/FormElements/Switch/Switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React, {useState} from 'react';
import {
DndContext,
DragEndEvent,
DragOverEvent,
getBoundingClientRect,
MeasuringConfiguration,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
restrictToHorizontalAxis,
restrictToParentElement,
} from '@dnd-kit/modifiers';
import classNames from 'classnames';

import {State} from './constants';
import {Thumb} from './Thumb';
import {Track} from './Track';
import styles from './Switch.module.css';

export interface Props {
accessibilityLabel?: string;
disabled?: boolean;
id?: string;
label?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}

const modifiers = [restrictToParentElement, restrictToHorizontalAxis];
const measuring: MeasuringConfiguration = {
draggable: {
measure: getBoundingClientRect,
},
};

export function Switch({
accessibilityLabel = '',
label = '',
disabled = false,
id,
checked,
onChange,
}: Props) {
const [overId, setOverId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 2,
},
})
);

const markup = (
<>
{label ? (
<label className={styles.Label} htmlFor={id}>
{label}
</label>
) : null}
<button
id={id}
type="button"
className={classNames(styles.Switch, {
[styles.checked]: checked,
[styles.dragging]: overId != null,
[styles.on]: overId === State.On,
[styles.off]: overId === State.Off,
[styles.disabled]: disabled,
})}
onClick={disabled ? undefined : handleClick}
aria-pressed={checked}
aria-label={accessibilityLabel}
disabled={disabled}
>
<Track />
<Thumb />
</button>
</>
);

return disabled ? (
markup
) : (
<DndContext
autoScroll={false}
measuring={measuring}
modifiers={modifiers}
sensors={sensors}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{markup}
</DndContext>
);

function handleClick() {
onChange(!checked);
}

function handleDragOver({over}: DragOverEvent) {
setOverId(over?.id ?? null);
}

function handleDragEnd({over}: DragEndEvent) {
if (over) {
const checked = over.id === State.On;
onChange(checked);
}

setOverId(null);
}

function handleDragCancel() {
setOverId(null);
}
}
25 changes: 25 additions & 0 deletions stories/3 - Examples/FormElements/Switch/Thumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import {useDraggable} from '@dnd-kit/core';
import classNames from 'classnames';

import styles from './Switch.module.css';

export function Thumb() {
const {isDragging, listeners, transform, setNodeRef} = useDraggable({
id: 'thumb',
});

return (
<span className={styles.ThumbWrapper} {...listeners}>
<span
ref={setNodeRef}
className={classNames(styles.Thumb, isDragging && styles.dragging)}
style={
{
'--transform': `${transform?.x ?? 0}px`,
} as React.CSSProperties
}
/>
</span>
);
}
21 changes: 21 additions & 0 deletions stories/3 - Examples/FormElements/Switch/Track.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import {useDroppable} from '@dnd-kit/core';

import {State} from './constants';
import styles from './Switch.module.css';

export function Track() {
const {setNodeRef: setOnRegionNodeRef} = useDroppable({
id: State.On,
});
const {setNodeRef: setOffRegionNodeRef} = useDroppable({
id: State.Off,
});

return (
<span className={styles.Track}>
<span ref={setOffRegionNodeRef} className={styles.OffRegion} />
<span ref={setOnRegionNodeRef} className={styles.OnRegion} />
</span>
);
}
4 changes: 4 additions & 0 deletions stories/3 - Examples/FormElements/Switch/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum State {
On = 'on',
Off = 'off',
}

0 comments on commit d77643f

Please sign in to comment.