diff --git a/packages/shared/src/components/fields/Switch.module.css b/packages/shared/src/components/fields/Switch.module.css index 1d1ae2c810..256be269ee 100644 --- a/packages/shared/src/components/fields/Switch.module.css +++ b/packages/shared/src/components/fields/Switch.module.css @@ -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; } @@ -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; @@ -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; } @@ -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); } @@ -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; } diff --git a/packages/shared/src/components/fields/Switch.tsx b/packages/shared/src/components/fields/Switch.tsx index 588003e0b7..ee77f2fc0b 100644 --- a/packages/shared/src/components/fields/Switch.tsx +++ b/packages/shared/src/components/fields/Switch.tsx @@ -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 { children?: ReactNode; className?: string; @@ -37,8 +54,109 @@ function SwitchComponent( }: SwitchProps, ref: ForwardedRef, ): ReactElement { + const inputRef = useRef(null); + const trackRef = useRef(null); + const dragRef = useRef(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(null); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + 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) => { + 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) => { + 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) => { + 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