From d77643f94d0ec70fe1c4794c97202ad6b0f73711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claud=C3=A9ric=20Demers?= Date: Thu, 22 Jul 2021 19:55:27 -0400 Subject: [PATCH] Add draggable switch story (#380) --- .changeset/prevent-click-propagation.md | 4 +- .../sensors/pointer/AbstractPointerSensor.ts | 15 +- .../FormElements/Switch/Switch.module.css | 133 ++++++++++++++++++ .../FormElements/Switch/Switch.story.tsx | 29 ++++ .../FormElements/Switch/Switch.tsx | 120 ++++++++++++++++ .../FormElements/Switch/Thumb.tsx | 25 ++++ .../FormElements/Switch/Track.tsx | 21 +++ .../FormElements/Switch/constants.ts | 4 + 8 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 stories/3 - Examples/FormElements/Switch/Switch.module.css create mode 100644 stories/3 - Examples/FormElements/Switch/Switch.story.tsx create mode 100644 stories/3 - Examples/FormElements/Switch/Switch.tsx create mode 100644 stories/3 - Examples/FormElements/Switch/Thumb.tsx create mode 100644 stories/3 - Examples/FormElements/Switch/Track.tsx create mode 100644 stories/3 - Examples/FormElements/Switch/constants.ts diff --git a/.changeset/prevent-click-propagation.md b/.changeset/prevent-click-propagation.md index b32e2518..3be69593 100644 --- a/.changeset/prevent-click-propagation.md +++ b/.changeset/prevent-click-propagation.md @@ -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. diff --git a/packages/core/src/sensors/pointer/AbstractPointerSensor.ts b/packages/core/src/sensors/pointer/AbstractPointerSensor.ts index 2122a52c..ac844de7 100644 --- a/packages/core/src/sensors/pointer/AbstractPointerSensor.ts +++ b/packages/core/src/sensors/pointer/AbstractPointerSensor.ts @@ -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; @@ -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); @@ -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); @@ -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, }); diff --git a/stories/3 - Examples/FormElements/Switch/Switch.module.css b/stories/3 - Examples/FormElements/Switch/Switch.module.css new file mode 100644 index 00000000..4ddeb9ef --- /dev/null +++ b/stories/3 - Examples/FormElements/Switch/Switch.module.css @@ -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; +} diff --git a/stories/3 - Examples/FormElements/Switch/Switch.story.tsx b/stories/3 - Examples/FormElements/Switch/Switch.story.tsx new file mode 100644 index 00000000..f46e7a7d --- /dev/null +++ b/stories/3 - Examples/FormElements/Switch/Switch.story.tsx @@ -0,0 +1,29 @@ +import React, {ComponentProps, useState} from 'react'; + +import {Switch} from './Switch'; + +export default { + title: 'Examples/Form Elements/Switch', +}; + +type Props = Pick, 'disabled' | 'label'>; + +function SwitchExample({disabled, label}: Props) { + const [checked, setChecked] = useState(false); + + return ( +
+ +
+ ); +} + +export const BasicSetup = () => ; +export const Disabled = () => ( + +); diff --git a/stories/3 - Examples/FormElements/Switch/Switch.tsx b/stories/3 - Examples/FormElements/Switch/Switch.tsx new file mode 100644 index 00000000..df347738 --- /dev/null +++ b/stories/3 - Examples/FormElements/Switch/Switch.tsx @@ -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(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 2, + }, + }) + ); + + const markup = ( + <> + {label ? ( + + ) : null} + + + ); + + return disabled ? ( + markup + ) : ( + + {markup} + + ); + + 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); + } +} diff --git a/stories/3 - Examples/FormElements/Switch/Thumb.tsx b/stories/3 - Examples/FormElements/Switch/Thumb.tsx new file mode 100644 index 00000000..b10a10b9 --- /dev/null +++ b/stories/3 - Examples/FormElements/Switch/Thumb.tsx @@ -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 ( + + + + ); +} diff --git a/stories/3 - Examples/FormElements/Switch/Track.tsx b/stories/3 - Examples/FormElements/Switch/Track.tsx new file mode 100644 index 00000000..a8ecd6b0 --- /dev/null +++ b/stories/3 - Examples/FormElements/Switch/Track.tsx @@ -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 ( + + + + + ); +} diff --git a/stories/3 - Examples/FormElements/Switch/constants.ts b/stories/3 - Examples/FormElements/Switch/constants.ts new file mode 100644 index 00000000..328304a4 --- /dev/null +++ b/stories/3 - Examples/FormElements/Switch/constants.ts @@ -0,0 +1,4 @@ +export enum State { + On = 'on', + Off = 'off', +}