Skip to content
Closed
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
33 changes: 11 additions & 22 deletions packages/shared/src/components/fields/Switch.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

.track {
background: var(--theme-surface-float);
background: var(--theme-surface-active);
will-change: background-color;
transition: background-color 0.12s linear;
}
Expand All @@ -24,8 +24,9 @@
transition: opacity 0.1s linear;
}

/* Knob is solid primary white in both themes and states. */
.knob {
background: var(--theme-surface-secondary);
background: theme('colors.raw.salt.0');
transform: translateX(0) scale(1);
transform-origin: center;
will-change: transform, background-color, opacity;
Expand All @@ -38,13 +39,6 @@
transition: color 0.12s linear;
}

/* Off knob turns to the primary surface on hover / focus / press */
.switch:hover input:not(:checked) ~ * .knob,
.switch:active input:not(:checked) ~ * .knob,
.switch input:not(:checked):focus-visible ~ * .knob {
background: var(--theme-surface-primary);
}

.switch:hover .hoverLayer {
opacity: 1;
}
Expand All @@ -64,25 +58,16 @@
color: var(--theme-text-primary);
}

/* Checked (on) */
/* Checked (on) — solid brand track, white knob (à la iOS/Chrome) */
.switch input:checked ~ * .track {
background: color-mix(
in srgb,
var(--theme-accent-cabbage-bolder),
transparent 84%
);
background: var(--theme-accent-cabbage-default);
}

.switch input:checked ~ * .hoverLayer {
background: color-mix(
in srgb,
var(--theme-accent-cabbage-bolder),
transparent 88%
);
background: color-mix(in srgb, var(--theme-surface-invert), transparent 88%);
}

.switch input:checked ~ * .knob {
background: var(--theme-accent-cabbage-default);
transform: translateX(20px) scale(1);
}

Expand All @@ -96,7 +81,11 @@
transform: translateX(10px) scale(0.6);
}

/* Disabled */
/* Disabled — bump the off track so the toggle stays visible; knob unchanged */
.disabled .track {
background: var(--theme-surface-disabled);
}

.disabled .knob {
opacity: 0.32;
}
Expand Down
134 changes: 131 additions & 3 deletions packages/shared/src/components/fields/Switch.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import type {
ForwardedRef,
InputHTMLAttributes,
PointerEvent as ReactPointerEvent,
MouseEvent as ReactMouseEvent,
ReactElement,
ReactNode,
} from 'react';
import React from 'react';
import React, { useCallback, useRef, useState } from 'react';
import classNames from 'classnames';
import styles from './Switch.module.css';

// Knob slides this many px between the off and on positions (matches the CSS).
const KNOB_TRAVEL = 20;
// Knob center when translateX is 0 (2px track inset + 10px half knob width).
const KNOB_CENTER_OFFSET = 12;
// How far the pointer must move before a hold turns into a drag.
const DRAG_THRESHOLD = 3;

interface DragState {
pointerId: number;
startX: number;
startChecked: boolean;
moved: boolean;
lastPos: number;
}

export interface SwitchProps extends InputHTMLAttributes<HTMLInputElement> {
children?: ReactNode;
className?: string;
Expand Down Expand Up @@ -37,8 +54,109 @@ function SwitchComponent(
}: SwitchProps,
ref: ForwardedRef<HTMLLabelElement>,
): ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const trackRef = useRef<HTMLSpanElement>(null);
const dragRef = useRef<DragState | null>(null);
// A drag ends with a synthetic click we must swallow so it doesn't re-toggle.
const suppressClickRef = useRef(false);
const [dragX, setDragX] = useState<number | null>(null);

const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => {
if (disabled || event.button !== 0) {
return;
}
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startChecked: !!checked,
moved: false,
lastPos: checked ? KNOB_TRAVEL : 0,
};
trackRef.current?.setPointerCapture(event.pointerId);
},
[disabled, checked],
);

const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
if (
!drag.moved &&
Math.abs(event.clientX - drag.startX) < DRAG_THRESHOLD
) {
return;
}
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
drag.moved = true;
const pos = Math.min(
KNOB_TRAVEL,
Math.max(0, event.clientX - rect.left - KNOB_CENTER_OFFSET),
);
drag.lastPos = pos;
setDragX(pos);
},
[],
);

const endDrag = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
dragRef.current = null;
if (trackRef.current?.hasPointerCapture(event.pointerId)) {
trackRef.current.releasePointerCapture(event.pointerId);
}
if (drag.moved) {
const target = drag.lastPos >= KNOB_TRAVEL / 2;
// Swallow the click that fires after a drag so we toggle only once.
suppressClickRef.current = true;
if (target !== !!checked) {
if (inputRef.current) {
inputRef.current.checked = target;
}
onToggle?.();
}
}
// Drop the inline transform so the knob snaps to its side with the CSS transition.
setDragX(null);
},
[checked, onToggle],
);

const handlePointerCancel = useCallback(() => {
if (!dragRef.current) {
return;
}
dragRef.current = null;
setDragX(null);
}, []);

const handleClick = useCallback(
(event: ReactMouseEvent<HTMLLabelElement>) => {
if (suppressClickRef.current) {
event.preventDefault();
suppressClickRef.current = false;
}
},
[],
);

const knobStyle =
dragX !== null
? { transform: `translateX(${dragX}px) scale(0.6)`, transition: 'none' }
: undefined;

return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
// eslint-disable-next-line jsx-a11y/label-has-associated-control, jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<label
className={classNames(
className,
Expand All @@ -48,9 +166,11 @@ function SwitchComponent(
disabled && styles.disabled,
)}
ref={ref}
onClick={handleClick}
>
<input
{...props}
ref={inputRef}
disabled={disabled}
id={inputId}
name={name}
Expand All @@ -59,7 +179,14 @@ function SwitchComponent(
onChange={onToggle}
className="absolute h-0 w-0 opacity-0"
/>
<span className="relative block h-6 w-11">
<span
ref={trackRef}
className="relative block h-6 w-11 touch-none select-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={handlePointerCancel}
>
<span
className={classNames('absolute inset-0 rounded-8', styles.track)}
/>
Expand All @@ -76,6 +203,7 @@ function SwitchComponent(
)}
/>
<span
style={knobStyle}
className={classNames(
'absolute left-0.5 top-0.5 h-5 w-5 rounded-6',
styles.knob,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export const Comparison: Story = {

<Section
title="Interactions & animations"
description="These are real, interactive toggles. Hover to reveal the surface layer, click and hold to see the knob squeeze to the centre, and use Tab to see the keyboard focus ring."
description="These are real, interactive toggles. Hover to reveal the surface layer, click and hold to squeeze the knob then drag it left/right to slide the switch (it snaps to the nearest side on release), and use Tab to see the keyboard focus ring."
>
<div className="flex flex-wrap gap-4">
<Card label="Hover me">
Expand Down
Loading