diff --git a/packages/shared/src/components/fields/Switch.module.css b/packages/shared/src/components/fields/Switch.module.css index c23c2f78dd8..256be269eed 100644 --- a/packages/shared/src/components/fields/Switch.module.css +++ b/packages/shared/src/components/fields/Switch.module.css @@ -1,61 +1,95 @@ +/* + * Toggle / Switch — daily.dev design system. + * Track 44x24 (rounded-8), knob 20x20 (rounded-6) with a 2px inset. + * On press the knob squeezes to the track center (12x12) for tactile feedback, + * then snaps to the destination side on release. + */ + .track { - will-change: background-color, opacity; - transition: background-color 0.1s linear, opacity 0.2s linear; + background: var(--theme-surface-active); + will-change: background-color; + transition: background-color 0.12s linear; +} + +.hoverLayer { + opacity: 0; + background: var(--theme-surface-hover); + will-change: opacity, background-color; + transition: opacity 0.15s linear, background-color 0.12s linear; +} + +.focusRing { + opacity: 0; + border-color: var(--theme-surface-focus); + transition: opacity 0.1s linear; } +/* Knob is solid primary white in both themes and states. */ .knob { - will-change: transform, background-color; - transition: background-color 0.1s linear, transform 0.2s linear; + background: theme('colors.raw.salt.0'); + transform: translateX(0) scale(1); + transform-origin: center; + will-change: transform, background-color, opacity; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), + background-color 0.12s linear, opacity 0.12s linear; +} + +.children { + color: var(--theme-text-secondary); + transition: color 0.12s linear; +} + +.switch:hover .hoverLayer { + opacity: 1; } -.switch { - &:hover .knob { - background: var(--theme-text-primary); - } +.switch:hover .children, +.switch:active .children { + color: var(--theme-text-primary); +} - &:hover input:checked ~ * .knob { - background: theme('colors.raw.cabbage.20'); - } +/* Keyboard focus */ +.switch input:focus-visible ~ * .focusRing, +.switch input:focus-visible ~ * .hoverLayer { + opacity: 1; +} - &:active { - background: none; - } +.switch input:focus-visible ~ .children { + color: var(--theme-text-primary); +} - & input:checked { - & ~ * .track { - background: theme('colors.raw.cabbage.50'); - opacity: 0.24; - } +/* Checked (on) — solid brand track, white knob (à la iOS/Chrome) */ +.switch input:checked ~ * .track { + background: var(--theme-accent-cabbage-default); +} - & ~ * .knob { - transform: translateX(100%); - background: theme('colors.raw.cabbage.40'); - } +.switch input:checked ~ * .hoverLayer { + background: color-mix(in srgb, var(--theme-surface-invert), transparent 88%); +} - & ~ .children { - color: var(--theme-text-primary); - } - } +.switch input:checked ~ * .knob { + transform: translateX(20px) scale(1); } -:global(.light) .switch { - & input:checked ~ * .knob { - background: theme('colors.raw.cabbage.80'); - } +.switch input:checked ~ .children { + color: var(--theme-text-primary); +} + +/* Press — knob squeezes toward the track center for both states */ +.switch:active .knob, +.switch:active input:checked ~ * .knob { + transform: translateX(10px) scale(0.6); +} - &:hover input:checked ~ * .knob { - background: theme('colors.raw.cabbage.60'); - } +/* Disabled — bump the off track so the toggle stays visible; knob unchanged */ +.disabled .track { + background: var(--theme-surface-disabled); } -@media (prefers-color-scheme: light) { - :global(.auto) .switch { - & input:checked ~ * .knob { - background: theme('colors.raw.cabbage.80'); - } +.disabled .knob { + opacity: 0.32; +} - &:hover input:checked ~ * .knob { - background: theme('colors.raw.cabbage.60'); - } - } +.disabled .children { + color: var(--theme-text-disabled); } diff --git a/packages/shared/src/components/fields/Switch.tsx b/packages/shared/src/components/fields/Switch.tsx index 5f417fb9594..ee77f2fc0b4 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,21 +54,123 @@ 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